import {ReferenceUtils} from 'app/client/lib/ReferenceUtils'; import {ColumnRec, DocModel, IRowModel, refRecord, ViewSectionRec} from 'app/client/models/DocModel'; import * as modelUtil from 'app/client/models/modelUtil'; import * as UserType from 'app/client/widgets/UserType'; import {csvDecodeRow} from 'app/common/csvFormat'; import {DocumentSettings} from 'app/common/DocumentSettings'; import {isFullReferencingType} from 'app/common/gristTypes'; import {BaseFormatter, createFormatter} from 'app/common/ValueFormatter'; import {createParser} from 'app/common/ValueParser'; import {Computed, fromKo} from 'grainjs'; import * as ko from 'knockout'; // Represents a page entry in the tree of pages. export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field"> { viewSection: ko.Computed; widthDef: modelUtil.KoSaveableObservable; widthPx: ko.Computed; column: ko.Computed; origCol: ko.Computed; colId: ko.Computed; label: ko.Computed; // displayLabel displays label by default but switches to the more helpful colId whenever a // formula field in the view is being edited. displayLabel: modelUtil.KoSaveableObservable; // The field knows when we are editing a formula, so that all rows can reflect that. editingFormula: ko.Computed; // CSS class to add to formula cells, incl. to show that we are editing field's formula. formulaCssClass: ko.Computed; // The fields's display column _displayColModel: ko.Computed; // Whether field uses column's widgetOptions (true) or its own (false). // During transform, use the transform column's options (which should be initialized to match // field or column when the transform starts TODO). useColOptions: ko.Computed; // Helper that returns the RowModel for either field or its column, depending on // useColOptions. Field and Column have a few identical fields: // .widgetOptions() // JSON string of options // .saveDisplayFormula() // Method to save the display formula // .displayCol() // Reference to an optional associated display column. _fieldOrColumn: ko.Computed; // Display col ref to use for the field, defaulting to the plain column itself. displayColRef: ko.Computed; visibleColRef: modelUtil.KoSaveableObservable; // The display column to use for the field, or the column itself when no displayCol is set. displayColModel: ko.Computed; visibleColModel: ko.Computed; // The widgetOptions to read and write: either the column's or the field's own. _widgetOptionsStr: modelUtil.KoSaveableObservable; // Observable for the object with the current options, either for the field or for the column, // which takes into account the default options for column's type. widgetOptionsJson: modelUtil.SaveableObjObservable; // Whether lines should wrap in a cell. wrapping: ko.Computed; // Observable for the parsed filter object saved to the field. activeFilter: modelUtil.CustomComputed; // Computed boolean that's true when there's a saved filter isFiltered: Computed; disableModify: ko.Computed; disableEditData: ko.Computed; textColor: modelUtil.KoSaveableObservable; fillColor: modelUtil.KoSaveableObservable; documentSettings: ko.PureComputed; valueParser: ko.Computed<(value: string) => any>; // Helper which adds/removes/updates field's displayCol to match the formula. saveDisplayFormula(formula: string): Promise|undefined; // Helper for Reference/ReferenceList columns, which returns a formatter according // to the visibleCol associated with field. Subscribes to observables if used within a computed. createVisibleColFormatter(): BaseFormatter; // Helper for Choice/ChoiceList columns, that saves widget options and renames values in a document // in one bundle updateChoices(renameMap: Record, options: any): Promise; } export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void { this.viewSection = refRecord(docModel.viewSections, this.parentId); this.widthDef = modelUtil.fieldWithDefault(this.width, () => this.viewSection().defaultWidth()); this.widthPx = ko.pureComputed(() => this.widthDef() + 'px'); this.column = refRecord(docModel.columns, this.colRef); this.origCol = ko.pureComputed(() => this.column().origCol()); this.colId = ko.pureComputed(() => this.column().colId()); this.label = ko.pureComputed(() => this.column().label()); // 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({ read: () => docModel.editingFormula() ? '$' + this.origCol().colId() : this.origCol().label(), write: (setter, val) => setter(this.column().label, val) }); // 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({ 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(() => { const col = this.column(); return this.column().isTransforming() ? "transform_field" : (this.editingFormula() ? "formula_field_edit" : (col.isFormula() && col.formula() !== "" ? "formula_field" : null)); }); // The fields's display column this._displayColModel = refRecord(docModel.columns, this.displayCol); // Helper which adds/removes/updates this field's displayCol to match the formula. this.saveDisplayFormula = function(formula) { if (formula !== (this._displayColModel().formula() || '')) { return docModel.docData.sendAction(["SetDisplayFormula", this.column().table().tableId(), this.getRowId(), null, formula]); } }; // Whether this field uses column's widgetOptions (true) or its own (false). // During transform, use the transform column's options (which should be initialized to match // field or column when the transform starts TODO). this.useColOptions = ko.pureComputed(() => !this.widgetOptions() || this.column().isTransforming()); // Helper that returns the RowModel for either this field or its column, depending on // useColOptions. Field and Column have a few identical fields: // .widgetOptions() // JSON string of options // .saveDisplayFormula() // Method to save the display formula // .displayCol() // Reference to an optional associated display column. this._fieldOrColumn = ko.pureComputed(() => this.useColOptions() ? this.column() : this); // Display col ref to use for the field, defaulting to the plain column itself. this.displayColRef = ko.pureComputed(() => this._fieldOrColumn().displayCol() || this.colRef()); this.visibleColRef = modelUtil.addSaveInterface(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([ this._fieldOrColumn().visibleCol.saveOnly(colRef), this._fieldOrColumn().saveDisplayFormula(colRef ? `$${this.colId()}.${col.colId()}` : '') ]); }, {nestInActiveBundle: this.column.peek().isTransforming.peek()}) ); // The display column to use for the field, or the column itself when no displayCol is set. this.displayColModel = refRecord(docModel.columns, this.displayColRef); this.visibleColModel = refRecord(docModel.columns, this.visibleColRef); // 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. // Subscribes to observables if used within a computed. // TODO: It would be better to replace this with a pureComputed whose value is a formatter. this.createVisibleColFormatter = function() { const vcol = this.visibleColModel(); return (vcol.getRowId() !== 0) ? createFormatter(vcol.type(), vcol.widgetOptionsJson(), this.documentSettings()) : createFormatter(this.column().type(), this.widgetOptionsJson(), this.documentSettings()); }; this.valueParser = ko.pureComputed(() => { const docSettings = this.documentSettings(); const type = this.column().type(); if (!isFullReferencingType(type)) { return createParser(type, this.widgetOptionsJson(), docSettings); } else { const vcol = this.visibleColModel(); const vcolParser = createParser(vcol.type(), vcol.widgetOptionsJson(), docSettings); const refUtils = new ReferenceUtils(this, docModel.docData); // uses several more observables immediately if (!refUtils.isRefList) { return (s: string) => refUtils.parseReference(s, vcolParser(s)); } else { return (s: string) => { let values: any[] | null; try { values = JSON.parse(s); } catch { values = null; } if (!Array.isArray(values)) { // csvDecodeRow should never raise an exception values = csvDecodeRow(s); } values = values.map(v => typeof v === "string" ? vcolParser(v) : v); return refUtils.parseReferenceList(s, values); }; } } }); // The widgetOptions to read and write: either the column's or the field's own. this._widgetOptionsStr = modelUtil.savingComputed({ read: () => this._fieldOrColumn().widgetOptions(), write: (setter, val) => setter(this._fieldOrColumn().widgetOptions, val) }); // Observable for the object with the current options, either for the field or for the column, // which takes into account the default options for this column's type. this.widgetOptionsJson = modelUtil.jsonObservable(this._widgetOptionsStr, (opts: any) => UserType.mergeOptions(opts || {}, this.column().pureType())); this.wrapping = ko.pureComputed(() => { // When user has yet to specify a desired wrapping state, we use different defaults for // GridView (no wrap) and DetailView (wrap). // "??" is the newish "nullish coalescing" operator. How cool is that! return this.widgetOptionsJson().wrap ?? (this.viewSection().parentKey() !== 'record'); }); // Observable for the active filter that's initialized from the value saved to the server. this.activeFilter = modelUtil.customComputed({ read: () => { const f = this.filter(); return f === 'null' ? '' : f; }, // To handle old empty filters save: (val) => this.filter.saveOnly(val), }); this.isFiltered = Computed.create(this, fromKo(this.activeFilter), (_use, f) => f !== ''); this.disableModify = ko.pureComputed(() => this.column().disableModify()); this.disableEditData = ko.pureComputed(() => this.column().disableEditData()); this.textColor = this.widgetOptionsJson.prop('textColor') as modelUtil.KoSaveableObservable; const fillColorProp = modelUtil.fieldWithDefault( this.widgetOptionsJson.prop('fillColor') as modelUtil.KoSaveableObservable, "#FFFFFF00"); // Store empty string in place of the default white color, so that we can keep it transparent in // GridView, to avoid interfering with zebra stripes. this.fillColor = modelUtil.savingComputed({ read: () => fillColorProp(), write: (setter, val) => setter(fillColorProp, val.toUpperCase() === '#FFFFFF' ? '' : val), }); this.documentSettings = ko.pureComputed(() => docModel.docInfoRow.documentSettingsJson()); this.updateChoices = async (renames, widgetOptions) => { // In case this column is being transformed - using Apply Formula to Data, bundle the action // together with the transformation. const actionOptions = {nestInActiveBundle: this.column.peek().isTransforming.peek()}; const hasRenames = !!Object.entries(renames).length; const callback = async () => { await Promise.all([ this.widgetOptionsJson.setAndSave(widgetOptions), hasRenames ? docModel.docData.sendAction(["RenameChoices", this.column().table().tableId(), this.colId(), renames]) : null ]); }; return docModel.docData.bundleActions("Update choices configuration", callback, actionOptions); }; }