diff --git a/app/client/components/RefSelect.js b/app/client/components/RefSelect.js deleted file mode 100644 index ee4a5fb7..00000000 --- a/app/client/components/RefSelect.js +++ /dev/null @@ -1,232 +0,0 @@ -var _ = require('underscore'); -var ko = require('knockout'); -var Promise = require('bluebird'); -var koArray = require('../lib/koArray'); -var dispose = require('../lib/dispose'); -var tableUtil = require('../lib/tableUtil'); -var gutil = require('app/common/gutil'); -const {colors, testId} = require('app/client/ui2018/cssVars'); -const {cssFieldEntry, cssFieldLabel} = require('app/client/ui/VisibleFieldsConfig'); -const {dom, fromKo, styled} = require('grainjs'); -const {icon} = require('app/client/ui2018/icons'); -const {menu, menuItem, menuText} = require('app/client/ui2018/menus'); - -/** - * Builder for the reference display multiselect. - */ -function RefSelect(options) { - this.docModel = options.docModel; - this.origColumn = options.origColumn; - this.colId = this.origColumn.colId; - - // Indicates whether this is a ref col that references a different table. - // (That's the only time when RefSelect is offered.) - this.isForeignRefCol = this.autoDispose(ko.computed(() => { - const t = this.origColumn.refTable(); - return Boolean(t && t.getRowId() !== this.origColumn.parentId()); - })); - - // Computed for the current fieldBuilder's field, if it exists. - this.fieldObs = this.autoDispose(ko.computed(() => { - let builder = options.fieldBuilder(); - return builder ? builder.field : null; - })); - - // List of valid cols in the currently referenced table. - this._validCols = this.autoDispose(ko.computed(() => { - var refTable = this.origColumn.refTable(); - if (refTable) { - return refTable.columns().all().filter(col => !col.isHiddenCol() && - !gutil.startsWith(col.type(), 'Ref:')); - } - return []; - })); - - // Returns the array of columns added to the multiselect. Used as a helper to create a synced KoArray. - var _addedObs = this.autoDispose(ko.computed(() => { - return this.isForeignRefCol() && this.fieldObs() ? - this._getReferencedCols().map(c => ({ label: c.label(), value: c.colId() })) : []; - })); - - // KoArray of columns displaying data from the referenced table in the current section. - this._added = this.autoDispose(koArray.syncedKoArray(_addedObs)); - - // Set of added colIds. - this._addedSet = this.autoDispose(ko.computed(() => new Set(this._added.all().map(item => item.value)))); -} -dispose.makeDisposable(RefSelect); - - -/** - * Builds the multiselect dom to select columns to added to the table to show data from the - * referenced table. - */ -RefSelect.prototype.buildDom = function() { - return cssFieldList( - testId('ref-select'), - dom.forEach(fromKo(this._added), (col) => - cssFieldEntry( - cssFieldLabel(dom.text(col.label)), - cssRemoveIcon('Remove', - dom.on('click', () => this._removeFormulaField(col)), - testId('ref-select-remove'), - ), - testId('ref-select-item'), - ) - ), - cssAddLink(cssAddIcon('Plus'), 'Add Column', - menu(() => [ - ...this._validCols.peek() - .filter((col) => !this._addedSet.peek().has(col.colId.peek())) - .map((col) => - menuItem(() => this._addFormulaField({label: col.label(), value: col.colId()}), - col.label.peek()) - ), - cssEmptyMenuText("No columns to add"), - testId('ref-select-menu'), - ]), - testId('ref-select-add'), - ), - ); -}; - -const cssFieldList = styled('div', ` - display: flex; - flex-direction: column; - width: 100%; - - & > .${cssFieldEntry.className} { - margin: 2px 0; - } -`); - -const cssEmptyMenuText = styled(menuText, ` - font-size: inherit; - &:not(:first-child) { - display: none; - } -`); - -const cssAddLink = styled('div', ` - display: flex; - cursor: pointer; - color: ${colors.lightGreen}; - --icon-color: ${colors.lightGreen}; - - &:not(:first-child) { - margin-top: 8px; - } - &:hover, &:focus, &:active { - color: ${colors.darkGreen}; - --icon-color: ${colors.darkGreen}; - } -`); - -const cssAddIcon = styled(icon, ` - margin-right: 4px; -`); - -const cssRemoveIcon = styled(icon, ` - display: none; - cursor: pointer; - flex: none; - margin-left: 8px; - .${cssFieldEntry.className}:hover & { - display: block; - } -`); - -/** - * Adds the column item to the multiselect. If the visibleCol is 'id', sets the visibleCol. - * Otherwise, adds a field which refers to the column to the table. If a column with the - * necessary formula exists, only adds a field to this section, otherwise adds the necessary - * column and field. - */ -RefSelect.prototype._addFormulaField = function(item) { - var field = this.fieldObs(); - var tableData = this.docModel.dataTables[this.origColumn.table().tableId()].tableData; - // Check if column already exists in the table - var cols = this.origColumn.table().columns().all(); - var colMatch = cols.find(c => c.formula() === `$${this.colId()}.${item.value}` && !c.isHiddenCol()); - // Get field position, so that the new field is inserted just after the current field. - var fields = field.viewSection().viewFields(); - var index = fields.all() - .sort((a, b) => a.parentPos() > b.parentPos() ? a : b) - .findIndex(f => f.getRowId() === field.getRowId()); - var pos = tableUtil.fieldInsertPositions(fields, index + 1)[0]; - var colAction; - if (colMatch) { - // If column exists, use it. - colAction = Promise.resolve({ colRef: colMatch.getRowId(), colId: colMatch.colId() }); - } else { - // If column doesn't exist, add it (without fields). - colAction = tableData.sendTableAction(['AddHiddenColumn', `${this.colId()}_${item.value}`, { - type: 'Any', - isFormula: true, - formula: `$${this.colId()}.${item.value}`, - _position: pos - }]); - } - return colAction.then(colInfo => { - // Add field to the current section. - var fieldInfo = { - colRef: colInfo.colRef, - parentId: field.viewSection().getRowId(), - parentPos: pos - }; - return this.docModel.viewFields.sendTableAction(['AddRecord', null, fieldInfo]); - }); -}; - -/** - * Removes the column item from the multiselect. If the item is the visibleCol, clears to show - * row id. Otherwise, removes all fields which refer to the column from the table. - */ -RefSelect.prototype._removeFormulaField = function(item) { - var tableData = this.docModel.dataTables[this.origColumn.table().tableId()].tableData; - // Iterate through all display fields in the current section. - this._getReferrerFields(item.value).forEach(refField => { - var sectionId = this.fieldObs().viewSection().getRowId(); - if (_.any(refField.column().viewFields().all(), field => field.parentId() !== sectionId)) { - // The col has fields in other sections, remove only the fields in this section. - this.docModel.viewFields.sendTableAction(['RemoveRecord', refField.getRowId()]); - } else { - // The col is only displayed in this section, remove the column. - tableData.sendTableAction(['RemoveColumn', refField.column().colId()]); - } - }); -}; - -/** - * Returns a list of fields in the current section whose formulas refer to 'colId' in the table this - * reference column refers to. - */ -RefSelect.prototype._getReferrerFields = function(colId) { - var re = new RegExp("^\\$" + this.colId() + "\\." + colId + "$"); - return this.fieldObs().viewSection().viewFields().all() - .filter(field => re.exec(field.column().formula())); -}; - -/** - * Returns a non-repeating list of columns in the referenced table referred to by fields in - * the current section. - */ -RefSelect.prototype._getReferencedCols = function() { - var matchesSet = this._getFormulaMatchSet(); - return this._validCols().filter(c => matchesSet.has(c.colId())); -}; - -/** - * Helper function for getReferencedCols. Iterates through fields in - * the current section, returning a set of colIds which those fields' formulas refer to. - */ -RefSelect.prototype._getFormulaMatchSet = function() { - var fields = this.fieldObs().viewSection().viewFields().all(); - var re = new RegExp("^\\$" + this.colId() + "\\.(\\w+)$"); - return new Set(fields.map(field => { - var found = re.exec(field.column().formula()); - return found ? found[1] : null; - })); -}; - -module.exports = RefSelect; diff --git a/app/client/components/RefSelect.ts b/app/client/components/RefSelect.ts new file mode 100644 index 00000000..4b1f14fa --- /dev/null +++ b/app/client/components/RefSelect.ts @@ -0,0 +1,257 @@ +import {KoArray} from 'app/client/lib/koArray'; +import * as koArray from 'app/client/lib/koArray'; +import * as tableUtil from 'app/client/lib/tableUtil'; +import {ColumnRec, DocModel, ViewFieldRec} from 'app/client/models/DocModel'; +import {KoSaveableObservable} from 'app/client/models/modelUtil'; +import {cssFieldEntry, cssFieldLabel} from 'app/client/ui/VisibleFieldsConfig'; +import {colors, testId} from 'app/client/ui2018/cssVars'; +import {icon} from 'app/client/ui2018/icons'; +import {menuText} from 'app/client/ui2018/menus'; +import {FieldBuilder} from 'app/client/widgets/FieldBuilder'; +import * as gutil from 'app/common/gutil'; +import {Disposable, dom, fromKo, styled} from 'grainjs'; +import ko from 'knockout'; +import {menu, menuItem} from 'popweasel'; + +interface Item { + label: string; + value: string; +} + +/** + * Builder for the reference display multiselect. + */ +export class RefSelect extends Disposable { + public isForeignRefCol: ko.Computed; + private _docModel: DocModel; + private _origColumn: ColumnRec; + private _colId: KoSaveableObservable; + private _fieldObs: ko.Computed; + private _validCols: ko.Computed; + private _added: KoArray; + private _addedSet: ko.Computed>; + + constructor(options: { + docModel: DocModel, + origColumn: ColumnRec, + fieldBuilder: ko.Computed, + }) { + super(); + this._docModel = options.docModel; + this._origColumn = options.origColumn; + this._colId = this._origColumn.colId; + + // Indicates whether this is a ref col that references a different table. + // (That's the only time when RefSelect is offered.) + this.isForeignRefCol = this.autoDispose(ko.computed(() => { + const t = this._origColumn.refTable(); + return Boolean(t && t.getRowId() !== this._origColumn.parentId()); + })); + + // Computed for the current fieldBuilder's field, if it exists. + this._fieldObs = this.autoDispose(ko.computed(() => { + const builder = options.fieldBuilder(); + return builder ? builder.field : null; + })); + + // List of valid cols in the currently referenced table. + this._validCols = this.autoDispose(ko.computed(() => { + const refTable = this._origColumn.refTable(); + if (refTable) { + return refTable.columns().all().filter(col => !col.isHiddenCol() && + !gutil.startsWith(col.type(), 'Ref:')); + } + return []; + })); + + // Returns the array of columns added to the multiselect. Used as a helper to create a synced KoArray. + const _addedObs = this.autoDispose(ko.computed(() => { + return this.isForeignRefCol() && this._fieldObs() ? + this._getReferencedCols(this._fieldObs()!).map(c => ({ label: c.label(), value: c.colId() })) : []; + })); + + // KoArray of columns displaying data from the referenced table in the current section. + this._added = this.autoDispose(koArray.syncedKoArray(_addedObs)); + + // Set of added colIds. + this._addedSet = this.autoDispose(ko.computed(() => new Set(this._added.all().map(item => item.value)))); + } + + /** + * Builds the multiselect dom to select columns to added to the table to show data from the + * referenced table. + */ + public buildDom() { + return cssFieldList( + testId('ref-select'), + dom.forEach(fromKo(this._added.getObservable()), (col) => + cssFieldEntry( + cssFieldLabel(dom.text(col.label)), + cssRemoveIcon('Remove', + dom.on('click', () => this._removeFormulaField(col)), + testId('ref-select-remove'), + ), + testId('ref-select-item'), + ) + ), + cssAddLink(cssAddIcon('Plus'), 'Add Column', + menu(() => [ + ...this._validCols.peek() + .filter((col) => !this._addedSet.peek().has(col.colId.peek())) + .map((col) => + menuItem(() => this._addFormulaField({ label: col.label(), value: col.colId() }), + col.label.peek()) + ), + cssEmptyMenuText("No columns to add"), + testId('ref-select-menu'), + ]), + testId('ref-select-add'), + ), + ); + } + + /** + * Adds the column item to the multiselect. If the visibleCol is 'id', sets the visibleCol. + * Otherwise, adds a field which refers to the column to the table. If a column with the + * necessary formula exists, only adds a field to this section, otherwise adds the necessary + * column and field. + */ + private async _addFormulaField(item: Item) { + const field = this._fieldObs(); + if (!field) { + return; + } + const tableData = this._docModel.dataTables[this._origColumn.table().tableId()].tableData; + // Check if column already exists in the table + const cols = this._origColumn.table().columns().all(); + const colMatch = cols.find(c => c.formula() === `$${this._colId()}.${item.value}` && !c.isHiddenCol()); + // Get field position, so that the new field is inserted just after the current field. + const fields = field.viewSection().viewFields(); + const index = fields.all() + .sort((a, b) => a.parentPos() > b.parentPos() ? 1 : -1) + .findIndex(f => f.getRowId() === field.getRowId()); + const pos = tableUtil.fieldInsertPositions(fields, index + 1)[0]; + let colAction: Promise|undefined; + if (colMatch) { + // If column exists, use it. + colAction = Promise.resolve({ colRef: colMatch.getRowId(), colId: colMatch.colId() }); + } else { + // If column doesn't exist, add it (without fields). + colAction = tableData.sendTableAction(['AddColumn', `${this._colId()}_${item.value}`, { + type: 'Any', + isFormula: true, + formula: `$${this._colId()}.${item.value}`, + _position: pos + }])!; + } + const colInfo = await colAction; + // Add field to the current section (if it isn't a raw data section - as this one will have + // this field already) + if (field.viewSection().isRaw()) { return; } + const fieldInfo = { + colRef: colInfo.colRef, + parentId: field.viewSection().getRowId(), + parentPos: pos + }; + return this._docModel.viewFields.sendTableAction(['AddRecord', null, fieldInfo]); + } + + /** + * Removes the column item from the multiselect. If the item is the visibleCol, clears to show + * row id. Otherwise, removes all fields which refer to the column from the table. + */ + private _removeFormulaField(item: Item) { + const tableData = this._docModel.dataTables[this._origColumn.table().tableId()].tableData; + // Iterate through all display fields in the current section. + this._getReferrerFields(item.value).forEach(refField => { + const sectionId = this._fieldObs()!.viewSection().getRowId(); + if (refField.column().viewFields().all() + .filter(field => !field.viewSection().isRaw()) + .some(field => field.parentId() !== sectionId)) { + // The col has fields in other sections, remove only the fields in this section. + return this._docModel.viewFields.sendTableAction(['RemoveRecord', refField.getRowId()]); + } else { + // The col is only displayed in this section, remove the column. + return tableData.sendTableAction(['RemoveColumn', refField.column().colId()]); + } + }); + } + + /** + * Returns a list of fields in the current section whose formulas refer to 'colId' in the table this + * reference column refers to. + */ + private _getReferrerFields(colId: string) { + const re = new RegExp("^\\$" + this._colId() + "\\." + colId + "$"); + return this._fieldObs()!.viewSection().viewFields().all() + .filter(field => re.exec(field.column().formula())); + } + + /** + * Returns a non-repeating list of columns in the referenced table referred to by fields in + * the current section. + */ + private _getReferencedCols(field: ViewFieldRec) { + const matchesSet = this._getFormulaMatchSet(field); + return this._validCols().filter(c => matchesSet.has(c.colId())); + } + + /** + * Helper function for getReferencedCols. Iterates through fields in + * the current section, returning a set of colIds which those fields' formulas refer to. + */ + private _getFormulaMatchSet(field: ViewFieldRec) { + const fields = field.viewSection().viewFields().all(); + const re = new RegExp("^\\$" + this._colId() + "\\.(\\w+)$"); + return new Set(fields.map(f => { + const found = re.exec(f.column().formula()); + return found ? found[1] : null; + })); + } +} + +const cssFieldList = styled('div', ` + display: flex; + flex-direction: column; + width: 100%; + + & > .${cssFieldEntry.className} { + margin: 2px 0; + } +`); + +const cssEmptyMenuText = styled(menuText, ` + font-size: inherit; + &:not(:first-child) { + display: none; + } +`); + +const cssAddLink = styled('div', ` + display: flex; + cursor: pointer; + color: ${colors.lightGreen}; + --icon-color: ${colors.lightGreen}; + + &:not(:first-child) { + margin-top: 8px; + } + &:hover, &:focus, &:active { + color: ${colors.darkGreen}; + --icon-color: ${colors.darkGreen}; + } +`); + +const cssAddIcon = styled(icon, ` + margin-right: 4px; +`); + +const cssRemoveIcon = styled(icon, ` + display: none; + cursor: pointer; + flex: none; + margin-left: 8px; + .${cssFieldEntry.className}:hover & { + display: block; + } +`); diff --git a/app/client/declarations.d.ts b/app/client/declarations.d.ts index 2d88624a..8c7ac1a8 100644 --- a/app/client/declarations.d.ts +++ b/app/client/declarations.d.ts @@ -76,26 +76,6 @@ declare module "app/client/components/BaseView" { export = BaseView; } -declare module "app/client/components/RefSelect" { - import {Disposable} from 'app/client/lib/dispose'; - import {ColumnRec} from "app/client/models/DocModel"; - import {DocModel} from "app/client/models/DocModel"; - import {FieldBuilder} from "app/client/widgets/FieldBuilder"; - - namespace RefSelect {} - class RefSelect extends Disposable { - public isForeignRefCol: ko.Computed; - - constructor(options: { - docModel: DocModel, - origColumn: ColumnRec, - fieldBuilder: ko.Computed, - }); - public buildDom(): HTMLElement; - } - export = RefSelect; -} - declare module "app/client/components/ViewConfigTab" { import {GristDoc} from 'app/client/components/GristDoc'; import {Disposable} from 'app/client/lib/dispose'; diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index 8479d650..5c76a055 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -16,7 +16,7 @@ import * as commands from 'app/client/components/commands'; import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc'; -import RefSelect from 'app/client/components/RefSelect'; +import {RefSelect} from 'app/client/components/RefSelect'; import ViewConfigTab from 'app/client/components/ViewConfigTab'; import {domAsync} from 'app/client/lib/domAsync'; import * as imports from 'app/client/lib/imports'; @@ -194,7 +194,7 @@ export class RightPanel extends Disposable { const isColumnValid = owner.autoDispose(ko.computed(() => Boolean(origColRef()))); // Builder for the reference display column multiselect. - const refSelect = owner.autoDispose(RefSelect.create({docModel, origColumn, fieldBuilder})); + const refSelect = RefSelect.create(owner, {docModel, origColumn, fieldBuilder}); // build cursor position observable const cursor = owner.autoDispose(ko.computed(() => {