mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user