(core) Adding UI for reverse columns

Summary:
- Adding an UI for two-way reference column.
- Reusing table name as label for the reverse column

Test Plan: Updated

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D4344
This commit is contained in:
Jarosław Sadziński
2024-09-13 15:20:18 +02:00
parent da6c39aa50
commit e97a45143f
25 changed files with 1356 additions and 137 deletions

View File

@@ -5,7 +5,8 @@ import DataTableModel from 'app/client/models/DataTableModel';
import { IRowModel } from 'app/client/models/DocModel';
import { ValidationRec } from 'app/client/models/entities/ValidationRec';
import * as modelUtil from 'app/client/models/modelUtil';
import { CellValue, ColValues } from 'app/common/DocActions';
import { buildReassignModal } from 'app/client/ui/buildReassignModal';
import { CellValue, ColValues, DocAction } from 'app/common/DocActions';
import * as ko from 'knockout';
/**
@@ -63,6 +64,19 @@ export class DataRowModel extends BaseRowModel {
try {
return await this._table.sendTableAction(action);
} catch(ex) {
if (ex.code === 'UNIQUE_REFERENCE_VIOLATION') {
// Show modal to repeat the save.
await buildReassignModal({
docModel: this._table.docModel,
actions: [
action as DocAction,
]
});
// Ignore the error here, no point in returning it.
} else {
throw ex;
}
} finally {
// If the action doesn't actually result in an update to a row, it's important to reset the
// observable to the data (if the data did get updated, this will be a no-op). This is also

View File

@@ -43,6 +43,11 @@ export class ViewFieldConfig {
return list.filter(f => !f.isDisposed() && !f.column().isDisposed());
}));
// Helper that lists all not disposed widgets. Many methods below gets all fields
// list which still can contain disposed fields, this helper will filter them out.
const listFields = () => this.fields().filter(f => !f.isDisposed());
// Just a helper field to see if we have multiple selected columns or not.
this.multiselect = owner.autoDispose(ko.pureComputed(() => this.fields().length > 1));
@@ -50,7 +55,7 @@ export class ViewFieldConfig {
// we have normal TextBox and Spinner). This will be used to allow the user to change
// this type if such columns are selected.
this.sameWidgets = owner.autoDispose(ko.pureComputed(() => {
const list = this.fields();
const list = listFields();
// If we have only one field selected, list is always the same.
if (list.length <= 1) { return true; }
// Now get all widget list and calculate intersection of the Sets.
@@ -71,7 +76,7 @@ export class ViewFieldConfig {
}
// If all have the same value, return it, otherwise
// return a default value for this option "undefined"
const values = this.fields().map(f => f.widget());
const values = listFields().map(f => f.widget());
if (allSame(values)) {
return values[0];
} else {
@@ -80,7 +85,7 @@ export class ViewFieldConfig {
},
write: (widget) => {
// Go through all the fields, and reset them all.
for(const field of this.fields.peek()) {
for(const field of listFields()) {
// Reset the entire JSON, so that all options revert to their defaults.
const previous = field.widgetOptionsJson.peek();
// We don't need to bundle anything (actions send in the same tick, are bundled
@@ -100,7 +105,7 @@ export class ViewFieldConfig {
// We will use this, to know which options are allowed to be changed
// when multiple columns are selected.
const commonOptions = owner.autoDispose(ko.pureComputed(() => {
const fields = this.fields();
const fields = listFields();
// Put all options of first widget in the Set, and then remove
// them one by one, if they are not present in other fields.
let options: Set<string>|null = null;
@@ -134,7 +139,7 @@ export class ViewFieldConfig {
// Assemble final json object.
const result: any = {};
// First get all widgetOption jsons from all columns/fields.
const optionList = this.fields().map(f => f.widgetOptionsJson());
const optionList = listFields().map(f => f.widgetOptionsJson());
// And fill only those that are common
const common = commonOptions();
for(const key of common) {
@@ -162,7 +167,7 @@ export class ViewFieldConfig {
}
// Now update all options, for all fields, by amending the options
// object from the field/column.
for(const item of this.fields.peek()) {
for(const item of listFields()) {
const previous = item.widgetOptionsJson.peek();
setter(item.widgetOptionsJson, {
...previous,
@@ -177,9 +182,9 @@ export class ViewFieldConfig {
// Property is not supported by set of columns if it is not a common option.
disabled: prop => ko.pureComputed(() => !commonOptions().has(prop)),
// Property has mixed value, if not all options are the same.
mixed: prop => ko.pureComputed(() => !allSame(this.fields().map(f => f.widgetOptionsJson.prop(prop)()))),
mixed: prop => ko.pureComputed(() => !allSame(listFields().map(f => f.widgetOptionsJson.prop(prop)()))),
// Property has empty value, if all options are empty (are null, undefined, empty Array or empty Object).
empty: prop => ko.pureComputed(() => allEmpty(this.fields().map(f => f.widgetOptionsJson.prop(prop)()))),
empty: prop => ko.pureComputed(() => allEmpty(listFields().map(f => f.widgetOptionsJson.prop(prop)()))),
}));
// This is repeated logic for wrap property in viewFieldRec,
@@ -196,8 +201,8 @@ export class ViewFieldConfig {
// To support this use case we need to compute a snapshot of fields, and use it to save style. Style
// picker will be rebuild every time fields change, and it will have access to last selected fields
// when it will be disposed.
this.style = ko.pureComputed(() => {
const fields = this.fields();
this.style = owner.autoDispose(ko.pureComputed(() => {
const fields = listFields();
const multiSelect = fields.length > 1;
const savableOptions = modelUtil.savingComputed({
read: () => {
@@ -256,10 +261,10 @@ export class ViewFieldConfig {
});
result.revert = () => { zip(fields, state).forEach(([f, s]) => f!.style(s!)); };
return result;
});
}));
this.headerStyle = ko.pureComputed(() => {
const fields = this.fields();
this.headerStyle = owner.autoDispose(ko.pureComputed(() => {
const fields = listFields();
const multiSelect = fields.length > 1;
const savableOptions = modelUtil.savingComputed({
read: () => {
@@ -318,7 +323,7 @@ export class ViewFieldConfig {
});
result.revert = () => { zip(fields, state).forEach(([f, s]) => f!.headerStyle(s!)); };
return result;
});
}));
}
// Helper for Choice/ChoiceList columns, that saves widget options and renames values in a document
@@ -328,7 +333,7 @@ export class ViewFieldConfig {
const tableId = this._field.column.peek().table.peek().tableId.peek();
if (this.multiselect.peek()) {
this._field.config.options.update(options);
const colIds = this.fields.peek().map(f => f.colId.peek());
const colIds = this.fields.peek().filter(f => !f.isDisposed()).map(f => f.colId.peek());
return this._docModel.docData.bundleActions("Update choices configuration", () => Promise.all([
this._field.config.options.save(),
!hasRenames ? null : this._docModel.docData.sendActions(

View File

@@ -63,6 +63,11 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> {
displayColModel: ko.Computed<ColumnRec>;
visibleColModel: ko.Computed<ColumnRec>;
// Reverse Ref/RefList column for this column. Only for Ref/RefList columns in two-way relations.
reverseColModel: ko.Computed<ColumnRec>;
// If this column has a relation.
hasReverse: ko.Computed<boolean>;
disableModifyBase: ko.Computed<boolean>; // True if column config can't be modified (name, type, etc.)
disableModify: ko.Computed<boolean>; // True if column can't be modified (is summary) or is being transformed.
disableEditData: ko.Computed<boolean>; // True to disable editing of the data in this column.
@@ -94,6 +99,12 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> {
saveDisplayFormula(formula: string): Promise<void>|undefined;
createValueParser(): (value: string) => any;
/** Helper method to add a reverse column (only for Ref/RefList) */
addReverseColumn(): Promise<void>;
/** Helper method to remove a reverse column (only for Ref/RefList) */
removeReverseColumn(): Promise<void>;
}
export function createColumnRec(this: ColumnRec, docModel: DocModel): void {
@@ -138,6 +149,8 @@ export function createColumnRec(this: ColumnRec, docModel: DocModel): void {
// The display column to use for the column, or the column itself when no displayCol is set.
this.displayColModel = refRecord(docModel.columns, this.displayColRef);
this.reverseColModel = refRecord(docModel.columns, this.reverseCol);
this.hasReverse = this.autoDispose(ko.pureComputed(() => Boolean(this.reverseColModel().id())));
this.visibleColModel = refRecord(docModel.columns, this.visibleCol);
this.disableModifyBase = ko.pureComputed(() => Boolean(this.summarySourceCol()));
@@ -184,6 +197,21 @@ export function createColumnRec(this: ColumnRec, docModel: DocModel): void {
}
return JSON.stringify(options);
});
this.addReverseColumn = () => {
return docModel.docData.sendAction(['AddReverseColumn', this.table.peek().tableId.peek(), this.colId.peek()]);
};
this.removeReverseColumn = async () => {
if (!this.hasReverse.peek()) {
throw new Error("Column does not have a reverse column");
}
// Remove the other column. Data engine will take care of removing the relation.
const column = this.reverseColModel.peek();
const tableId = column.table.peek().tableId.peek();
const colId = column.colId.peek();
return await docModel.docData.sendAction(['RemoveColumn', tableId, colId]);
};
}
export function formatterForRec(

View File

@@ -129,11 +129,10 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
this.colId = this.autoDispose(ko.pureComputed(() => this.column().colId()));
this.label = this.autoDispose(ko.pureComputed(() => this.column().label()));
this.origLabel = this.autoDispose(ko.pureComputed(() => this.origCol().label()));
this.description = modelUtil.savingComputed({
this.description = this.autoDispose(modelUtil.savingComputed({
read: () => this.column().description(),
write: (setter, val) => setter(this.column().description, val)
});
}));
// displayLabel displays label by default but switches to the more helpful colId whenever a
// formula field in the view is being edited.
this.displayLabel = modelUtil.savingComputed({
@@ -143,17 +142,17 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
// The field knows when we are editing a formula, so that all rows can reflect that.
const _editingFormula = ko.observable(false);
this.editingFormula = ko.pureComputed({
this.editingFormula = this.autoDispose(ko.pureComputed({
read: () => _editingFormula(),
write: val => {
// Whenever any view field changes its editingFormula status, let the docModel know.
docModel.editingFormula(val);
_editingFormula(val);
}
});
}));
// CSS class to add to formula cells, incl. to show that we are editing this field's formula.
this.formulaCssClass = ko.pureComputed<string|null>(() => {
this.formulaCssClass = this.autoDispose(ko.pureComputed<string|null>(() => {
const col = this.column();
// If the current column is transforming, assign the CSS class "transform_field"
@@ -175,7 +174,7 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
else {
return null;
}
});
}));
// The fields's display column
this._displayColModel = refRecord(docModel.columns, this.displayCol);
@@ -203,10 +202,10 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
// Display col ref to use for the field, defaulting to the plain column itself.
this.displayColRef = this.autoDispose(ko.pureComputed(() => this._fieldOrColumn().displayCol() || this.colRef()));
this.visibleColRef = modelUtil.addSaveInterface(ko.pureComputed({
this.visibleColRef = modelUtil.addSaveInterface(this.autoDispose(ko.pureComputed({
read: () => this._fieldOrColumn().visibleCol(),
write: (colRef) => this._fieldOrColumn().visibleCol(colRef),
}),
})),
colRef => docModel.docData.bundleActions(null, async () => {
const col = docModel.columns.getRowModel(colRef);
await Promise.all([
@@ -222,9 +221,13 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
// Helper for Reference/ReferenceList columns, which returns a formatter according to the visibleCol
// associated with this field. If no visible column available, return formatting for the field itself.
this.visibleColFormatter = ko.pureComputed(() => formatterForRec(this, this.column(), docModel, 'vcol'));
this.visibleColFormatter = this.autoDispose(
ko.pureComputed(() => formatterForRec(this, this.column(), docModel, 'vcol'))
);
this.formatter = ko.pureComputed(() => formatterForRec(this, this.column(), docModel, 'full'));
this.formatter = this.autoDispose(
ko.pureComputed(() => formatterForRec(this, this.column(), docModel, 'full'))
);
this.createValueParser = function() {
const fieldRef = this.useColOptions.peek() ? undefined : this.id.peek();
@@ -264,8 +267,8 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
this.headerFontStrikethrough = this.widgetOptionsJson.prop('headerFontStrikethrough');
this.question = this.widgetOptionsJson.prop('question');
this.documentSettings = ko.pureComputed(() => docModel.docInfoRow.documentSettingsJson());
this.style = ko.pureComputed({
this.documentSettings = this.autoDispose(ko.pureComputed(() => docModel.docInfoRow.documentSettingsJson()));
this.style = this.autoDispose(ko.pureComputed({
read: () => ({
textColor: this.textColor(),
fillColor: this.fillColor(),
@@ -277,8 +280,8 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
write: (style: Style) => {
this.widgetOptionsJson.update(style);
},
});
this.headerStyle = ko.pureComputed({
}));
this.headerStyle = this.autoDispose(ko.pureComputed({
read: () => ({
headerTextColor: this.headerTextColor(),
headerFillColor: this.headerFillColor(),
@@ -290,19 +293,21 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
write: (headerStyle: HeaderStyle) => {
this.widgetOptionsJson.update(headerStyle);
},
});
}));
this.tableId = ko.pureComputed(() => this.column().table().tableId());
this.tableId = this.autoDispose(ko.pureComputed(() => this.column().table().tableId()));
this.rulesList = modelUtil.savingComputed({
read: () => this._fieldOrColumn().rules(),
write: (setter, val) => setter(this._fieldOrColumn().rules, val)
});
this.rulesCols = refListRecords(docModel.columns, ko.pureComputed(() => this._fieldOrColumn().rules()));
this.rulesColsIds = ko.pureComputed(() => this.rulesCols().map(c => c.colId()));
this.rulesCols = this.autoDispose(
refListRecords(docModel.columns, ko.pureComputed(() => this._fieldOrColumn().rules()))
);
this.rulesColsIds = this.autoDispose(ko.pureComputed(() => this.rulesCols().map(c => c.colId())));
this.rulesStyles = modelUtil.fieldWithDefault(
this.widgetOptionsJson.prop("rulesOptions") as modelUtil.KoSaveableObservable<Style[]>,
[]);
this.hasRules = ko.pureComputed(() => this.rulesCols().length > 0);
this.hasRules = this.autoDispose(ko.pureComputed(() => this.rulesCols().length > 0));
// Helper method to add an empty rule (either initial or additional one).
// Style options are added to widget options directly and can be briefly out of sync,

View File

@@ -10,6 +10,15 @@ const G = getBrowserGlobals('document', 'window');
let _notifier: Notifier;
/**
* Doesn't show or trigger any UI when thrown. Use it when you will handle it yourself, but
* need to stop any futher actions from the app. Currently only used in the model that tries
* to react in response of UNIQUE reference constraint validation.
*/
export class MutedError extends Error {
}
export class UserError extends Error {
public name: string = "UserError";
public key?: string;
@@ -106,6 +115,9 @@ const unhelpfulErrors = new Set<string>();
* this function might show a simple toast message.
*/
export function reportError(err: Error|string, ev?: ErrorEvent): void {
if (err instanceof MutedError) {
return;
}
log.error(`ERROR:`, err);
if (String(err).match(/GristWSConnection disposed/)) {
// This error can be emitted while a page is reloaded, and isn't worth reporting.