diff --git a/app/client/lib/listEntry.ts b/app/client/lib/listEntry.ts index cd2ffbf2..d531a987 100644 --- a/app/client/lib/listEntry.ts +++ b/app/client/lib/listEntry.ts @@ -1,7 +1,7 @@ import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; import {colors, testId} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; -import {Computed, Disposable, dom, DomElementArg, Observable, styled} from 'grainjs'; +import {Computed, Disposable, dom, DomContents, DomElementArg, Observable, styled} from 'grainjs'; import isEqual = require('lodash/isEqual'); import uniq = require('lodash/uniq'); @@ -32,7 +32,7 @@ export class ListEntry extends Disposable { } // Arg maxRows indicates the number of rows to display when the textarea is inactive. - public buildDom(maxRows: number = 6): DomElementArg { + public buildDom(maxRows: number = 6): DomContents { return dom.domComputed(this._isEditing, (editMode) => { if (editMode) { // Edit mode dom. diff --git a/app/client/models/entities/ViewFieldRec.ts b/app/client/models/entities/ViewFieldRec.ts index 534e6227..e901defd 100644 --- a/app/client/models/entities/ViewFieldRec.ts +++ b/app/client/models/entities/ViewFieldRec.ts @@ -58,6 +58,9 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field"> { 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; @@ -180,6 +183,13 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void 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 diff --git a/app/client/widgets/ChoiceTextBox.js b/app/client/widgets/ChoiceTextBox.js deleted file mode 100644 index bc3d2e35..00000000 --- a/app/client/widgets/ChoiceTextBox.js +++ /dev/null @@ -1,96 +0,0 @@ -var commands = require('../components/commands'); -var dispose = require('../lib/dispose'); -var kd = require('../lib/koDom'); -var _ = require('underscore'); -var TextBox = require('./TextBox'); - -const {fromKoSave} = require('app/client/lib/fromKoSave'); -const {ListEntry} = require('app/client/lib/listEntry'); -const {alignmentSelect} = require('app/client/ui2018/buttonSelect'); -const {colors, testId} = require('app/client/ui2018/cssVars'); -const {icon} = require('app/client/ui2018/icons'); -const {menu, menuItem} = require('app/client/ui2018/menus'); -const {cssLabel, cssRow} = require('app/client/ui/RightPanel'); -const {dom, Computed, styled} = require('grainjs'); - -/** - * ChoiceTextBox - A textbox for choice values. - */ -function ChoiceTextBox(field) { - TextBox.call(this, field); - - this.docData = field._table.docModel.docData; - this.colId = field.column().colId; - this.tableId = field.column().table().tableId; - - this.choices = this.options.prop('choices'); - this.choiceValues = Computed.create(this, (use) => use(this.choices) || []) -} -dispose.makeDisposable(ChoiceTextBox); -_.extend(ChoiceTextBox.prototype, TextBox.prototype); - - -ChoiceTextBox.prototype.buildDom = function(row) { - let value = row[this.field.colId()]; - return cssChoiceField( - cssChoiceText( - kd.style('text-align', this.alignment), - kd.text(() => row._isAddRow() ? '' : this.valueFormatter().format(value())), - ), - cssDropdownIcon('Dropdown', - // When choices exist, click dropdown icon to open edit autocomplete. - dom.on('click', () => this._hasChoices() && commands.allCommands.editField.run()), - // When choices do not exist, open a single-item menu to open the sidepane choice option editor. - menu(() => [ - menuItem(commands.allCommands.fieldTabOpen.run, 'Add Choice Options') - ], { - trigger: [(elem, ctl) => { - // Only open this menu if there are no choices. - dom.onElem(elem, 'click', () => this._hasChoices() || ctl.open()); - }] - }), - testId('choice-dropdown') - ) - ); -}; - -ChoiceTextBox.prototype.buildConfigDom = function() { - return [ - cssRow( - alignmentSelect(fromKoSave(this.alignment)) - ), - cssLabel('OPTIONS'), - cssRow( - dom.create(ListEntry, this.choiceValues, (values) => this.choices.saveOnly(values)) - ) - ]; -}; - -ChoiceTextBox.prototype.buildTransformConfigDom = function() { - return this.buildConfigDom(); -}; - -ChoiceTextBox.prototype._hasChoices = function() { - return this.choiceValues.get().length > 0; -}; - -const cssChoiceField = styled('div.field_clip', ` - display: flex; - justify-content: space-between; -`); - -const cssChoiceText = styled('div', ` - width: 100%; - overflow: hidden; - text-overflow: ellipsis; -`); - -const cssDropdownIcon = styled(icon, ` - cursor: pointer; - background-color: ${colors.lightGreen}; - min-width: 16px; - width: 16px; - height: 16px; -`); - -module.exports = ChoiceTextBox; diff --git a/app/client/widgets/ChoiceTextBox.ts b/app/client/widgets/ChoiceTextBox.ts new file mode 100644 index 00000000..0f203382 --- /dev/null +++ b/app/client/widgets/ChoiceTextBox.ts @@ -0,0 +1,89 @@ +import * as commands from 'app/client/components/commands'; +import {ListEntry} from 'app/client/lib/listEntry'; +import {DataRowModel} from 'app/client/models/DataRowModel'; +import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; +import {KoSaveableObservable} from 'app/client/models/modelUtil'; +import {cssLabel, cssRow} from 'app/client/ui/RightPanel'; +import {alignmentSelect} from 'app/client/ui2018/buttonSelect'; +import {colors, testId} from 'app/client/ui2018/cssVars'; +import {icon} from 'app/client/ui2018/icons'; +import {menu, menuItem} from 'app/client/ui2018/menus'; +import {NTextBox} from 'app/client/widgets/NTextBox'; +import {Computed, dom, styled} from 'grainjs'; + +/** + * ChoiceTextBox - A textbox for choice values. + */ +export class ChoiceTextBox extends NTextBox { + private _choices: KoSaveableObservable; + private _choiceValues: Computed; + + constructor(field: ViewFieldRec) { + super(field); + this._choices = this.options.prop('choices'); + this._choiceValues = Computed.create(this, (use) => use(this._choices) || []); + } + + public buildDom(row: DataRowModel) { + const value = row.cells[this.field.colId()]; + return cssChoiceField( + cssChoiceText( + dom.style('text-align', this.alignment), + dom.text((use) => use(row._isAddRow) ? '' : use(this.valueFormatter).format(use(value))), + ), + cssDropdownIcon('Dropdown', + // When choices exist, click dropdown icon to open edit autocomplete. + dom.on('click', () => this._hasChoices() && commands.allCommands.editField.run()), + // When choices do not exist, open a single-item menu to open the sidepane choice option editor. + menu(() => [ + menuItem(commands.allCommands.fieldTabOpen.run, 'Add Choice Options') + ], { + trigger: [(elem, ctl) => { + // Only open this menu if there are no choices. + dom.onElem(elem, 'click', () => this._hasChoices() || ctl.open()); + }] + }), + testId('choice-dropdown') + ) + ); + } + + public buildConfigDom() { + return [ + cssRow( + alignmentSelect(this.alignment) + ), + cssLabel('OPTIONS'), + cssRow( + dom.create(ListEntry, this._choiceValues, (values) => this._choices.saveOnly(values)) + ) + ]; + } + + public buildTransformConfigDom() { + return this.buildConfigDom(); + } + + private _hasChoices() { + return this._choiceValues.get().length > 0; + } +} + +const cssChoiceField = styled('div.field_clip', ` + display: flex; + justify-content: space-between; +`); + +const cssChoiceText = styled('div', ` + width: 100%; + overflow: hidden; + text-overflow: ellipsis; +`); + +const cssDropdownIcon = styled(icon, ` + cursor: pointer; + background-color: ${colors.lightGreen}; + min-width: 16px; + width: 16px; + height: 16px; +`); diff --git a/app/client/widgets/HyperLinkTextBox.js b/app/client/widgets/HyperLinkTextBox.js deleted file mode 100644 index 169d5cc4..00000000 --- a/app/client/widgets/HyperLinkTextBox.js +++ /dev/null @@ -1,78 +0,0 @@ -var _ = require('underscore'); -var dom = require('../lib/dom'); -var dispose = require('../lib/dispose'); -var kd = require('../lib/koDom'); -var TextBox = require('./TextBox'); -const G = require('../lib/browserGlobals').get('URL'); - -const {colors, testId} = require('app/client/ui2018/cssVars'); -const {icon} = require('app/client/ui2018/icons'); -const {styled} = require('grainjs'); - -/** - * Creates a widget for displaying links. Links can entered directly or following a title. - * The last entry following a space is used as the url. - * ie 'google https://www.google.com' would apears as 'google' to the user but link to the url. - */ -function HyperLinkTextBox(field) { - TextBox.call(this, field); -} -dispose.makeDisposable(HyperLinkTextBox); -_.extend(HyperLinkTextBox.prototype, TextBox.prototype); - -HyperLinkTextBox.prototype.buildDom = function(row) { - var value = row[this.field.colId()]; - return cssFieldClip( - kd.style('text-align', this.alignment), - kd.toggleClass('text_wrapping', this.wrapping), - kd.maybe(value, () => - dom('a', - kd.attr('href', () => _constructUrl(value())), - kd.attr('target', '_blank'), - // As per Google and Mozilla recommendations to prevent opened links - // from running on the same process as Grist: - // https://developers.google.com/web/tools/lighthouse/audits/noopener - kd.attr('rel', 'noopener noreferrer'), - cssLinkIcon('FieldLink'), - testId('tb-link') - ) - ), - kd.text(() => _formatValue(value())) - ); -}; - -/** - * Formats value like `foo bar baz` by discarding `baz` and returning `foo bar`. - */ -function _formatValue(value) { - // value might be null, at least transiently. Handle it to avoid an exception. - if (typeof value !== 'string') { return value; } - const index = value.lastIndexOf(' '); - return index >= 0 ? value.slice(0, index) : value; -} - -/** - * Given value like `foo bar baz`, constructs URL by checking if `baz` is a valid URL and, - * if not, prepending `http://`. - */ -function _constructUrl(value) { - const url = value.slice(value.lastIndexOf(' ') + 1); - try { - // Try to construct a valid URL - return (new G.URL(url)).toString(); - } catch (e) { - // Not a valid URL, so try to prefix it with http - return 'http://' + url; - } -} - -const cssFieldClip = styled('div.field_clip', ` - color: ${colors.lightGreen}; -`); - -const cssLinkIcon = styled(icon, ` - background-color: ${colors.lightGreen}; - margin: -1px 2px 2px 0; -`); - -module.exports = HyperLinkTextBox; diff --git a/app/client/widgets/HyperLinkTextBox.ts b/app/client/widgets/HyperLinkTextBox.ts new file mode 100644 index 00000000..5045ba2f --- /dev/null +++ b/app/client/widgets/HyperLinkTextBox.ts @@ -0,0 +1,71 @@ +import {DataRowModel} from 'app/client/models/DataRowModel'; +import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; +import {colors, testId} from 'app/client/ui2018/cssVars'; +import {icon} from 'app/client/ui2018/icons'; +import {NTextBox} from 'app/client/widgets/NTextBox'; +import {dom, styled} from 'grainjs'; + +/** + * Creates a widget for displaying links. Links can entered directly or following a title. + * The last entry following a space is used as the url. + * ie 'google https://www.google.com' would apears as 'google' to the user but link to the url. + */ +export class HyperLinkTextBox extends NTextBox { + constructor(field: ViewFieldRec) { + super(field); + } + + public buildDom(row: DataRowModel) { + const value = row.cells[this.field.colId()]; + return cssFieldClip( + dom.style('text-align', this.alignment), + dom.cls('text_wrapping', this.wrapping), + dom.maybe(value, () => + dom('a', + dom.attr('href', (use) => _constructUrl(use(value))), + dom.attr('target', '_blank'), + // As per Google and Mozilla recommendations to prevent opened links + // from running on the same process as Grist: + // https://developers.google.com/web/tools/lighthouse/audits/noopener + dom.attr('rel', 'noopener noreferrer'), + cssLinkIcon('FieldLink'), + testId('tb-link') + ) + ), + dom.text((use) => _formatValue(use(value))), + ); + } +} + +/** + * Formats value like `foo bar baz` by discarding `baz` and returning `foo bar`. + */ +function _formatValue(value: string|null|undefined): string { + if (typeof value !== 'string') { return ''; } + const index = value.lastIndexOf(' '); + return index >= 0 ? value.slice(0, index) : value; +} + +/** + * Given value like `foo bar baz`, constructs URL by checking if `baz` is a valid URL and, + * if not, prepending `http://`. + */ +function _constructUrl(value: string): string { + const url = value.slice(value.lastIndexOf(' ') + 1); + try { + // Try to construct a valid URL + return (new URL(url)).toString(); + } catch (e) { + // Not a valid URL, so try to prefix it with http + return 'http://' + url; + } +} + +const cssFieldClip = styled('div.field_clip', ` + color: ${colors.lightGreen}; +`); + +const cssLinkIcon = styled(icon, ` + background-color: ${colors.lightGreen}; + margin: -1px 2px 2px 0; +`); diff --git a/app/client/widgets/NTextBox.ts b/app/client/widgets/NTextBox.ts index 506b1b4b..9435b7e2 100644 --- a/app/client/widgets/NTextBox.ts +++ b/app/client/widgets/NTextBox.ts @@ -5,7 +5,7 @@ import {cssRow} from 'app/client/ui/RightPanel'; import {alignmentSelect, makeButtonSelect} from 'app/client/ui2018/buttonSelect'; import {testId} from 'app/client/ui2018/cssVars'; import {NewAbstractWidget} from 'app/client/widgets/NewAbstractWidget'; -import {Computed, dom, Observable} from 'grainjs'; +import {dom, DomContents, fromKo, Observable} from 'grainjs'; /** * TextBox - The most basic widget for displaying text information. @@ -18,27 +18,15 @@ export class NTextBox extends NewAbstractWidget { super(field); this.alignment = fromKoSave(this.options.prop('alignment')); - const wrap = this.options.prop('wrap'); - - this.wrapping = Computed.create(this, (use) => { - const w = use(wrap); - if (w === null || w === undefined) { - // When user has yet to specify a desired wrapping state, GridView and DetailView have - // different default states. GridView defaults to wrapping disabled, while DetailView - // defaults to wrapping enabled. - return (this.field.viewSection().parentKey() === 'record') ? false : true; - } else { - return w; - } - }); + this.wrapping = fromKo(this.field.wrapping); this.autoDispose(this.wrapping.addListener(() => { this.field.viewSection().events.trigger('rowHeightChange'); })); } - public buildConfigDom() { - return dom('div', + public buildConfigDom(): DomContents { + return [ cssRow( alignmentSelect(this.alignment), dom('div', {style: 'margin-left: 8px;'}, @@ -46,7 +34,7 @@ export class NTextBox extends NewAbstractWidget { testId('tb-wrap-text') ) ) - ); + ]; } public buildDom(row: DataRowModel) { diff --git a/app/client/widgets/NewAbstractWidget.ts b/app/client/widgets/NewAbstractWidget.ts index b68b1e90..29e5c534 100644 --- a/app/client/widgets/NewAbstractWidget.ts +++ b/app/client/widgets/NewAbstractWidget.ts @@ -10,7 +10,7 @@ import {cssLabel, cssRow} from 'app/client/ui/RightPanel'; import {colorSelect} from 'app/client/ui2018/buttonSelect'; import {colors, testId} from 'app/client/ui2018/cssVars'; import {BaseFormatter, createFormatter} from 'app/common/ValueFormatter'; -import {Disposable, fromKo, Observable, styled} from 'grainjs'; +import {Disposable, DomContents, fromKo, Observable, styled} from 'grainjs'; import * as ko from 'knockout'; @@ -43,13 +43,13 @@ export abstract class NewAbstractWidget extends Disposable { /** * Builds the DOM showing configuration buttons and fields in the sidebar. */ - public buildConfigDom(): Element|null { return null; } + public buildConfigDom(): DomContents { return null; } /** * Builds the transform prompt config DOM in the few cases where it is necessary. * Child classes need not override this function if they do not require transform config options. */ - public buildTransformConfigDom(): Element|null { return null; } + public buildTransformConfigDom(): DomContents { return null; } public buildColorConfigDom(): Element[] { return [ diff --git a/app/client/widgets/NumericTextBox.ts b/app/client/widgets/NumericTextBox.ts index 49e3fffa..3599131f 100644 --- a/app/client/widgets/NumericTextBox.ts +++ b/app/client/widgets/NumericTextBox.ts @@ -10,7 +10,7 @@ import {icon} from 'app/client/ui2018/icons'; import {NTextBox} from 'app/client/widgets/NTextBox'; import {clamp} from 'app/common/gutil'; import {buildNumberFormat, NumberFormatOptions, NumMode, NumSign} from 'app/common/NumberFormat'; -import {Computed, dom, DomElementArg, fromKo, MultiHolder, Observable, styled} from 'grainjs'; +import {Computed, dom, DomContents, DomElementArg, fromKo, MultiHolder, Observable, styled} from 'grainjs'; const modeOptions: Array> = [ @@ -32,7 +32,7 @@ export class NumericTextBox extends NTextBox { super(field); } - public buildConfigDom() { + public buildConfigDom(): DomContents { // Holder for all computeds created here. It gets disposed with the returned DOM element. const holder = new MultiHolder(); @@ -67,10 +67,11 @@ export class NumericTextBox extends NTextBox { const setMode = (val: NumMode) => setSave('numMode', val !== numMode.get() ? val : undefined); const setSign = (val: NumSign) => setSave('numSign', val !== numSign.get() ? val : undefined); - return dom.update(super.buildConfigDom(), - dom.autoDispose(holder), + return [ + super.buildConfigDom(), cssLabel('Number Format'), cssRow( + dom.autoDispose(holder), makeButtonSelect(numMode, modeOptions, setMode, {}, cssModeSelect.cls(''), testId('numeric-mode')), makeButtonSelect(numSign, signOptions, setSign, {}, cssSignSelect.cls(''), testId('numeric-sign')), ), @@ -79,7 +80,7 @@ export class NumericTextBox extends NTextBox { decimals('min', minDecimals, defaultMin, setMinDecimals, testId('numeric-min-decimals')), decimals('max', maxDecimals, defaultMax, setMaxDecimals, testId('numeric-max-decimals')), ), - ); + ]; } } diff --git a/app/client/widgets/Reference.js b/app/client/widgets/Reference.js deleted file mode 100644 index e3caa052..00000000 --- a/app/client/widgets/Reference.js +++ /dev/null @@ -1,112 +0,0 @@ -var _ = require('underscore'); -var ko = require('knockout'); - -var gutil = require('app/common/gutil'); - -var dispose = require('../lib/dispose'); -var dom = require('../lib/dom'); -var kd = require('../lib/koDom'); - -var TextBox = require('./TextBox'); - -const {colors, testId} = require('app/client/ui2018/cssVars'); -const {icon} = require('app/client/ui2018/icons'); -const {select} = require('app/client/ui2018/menus'); -const {cssLabel, cssRow} = require('app/client/ui/RightPanel'); -const {isVersions} = require('app/common/gristTypes'); -const {Computed, styled} = require('grainjs'); - -/** - * Reference - The widget for displaying references to another table's records. - */ -function Reference(field) { - TextBox.call(this, field); - var col = field.column(); - this.field = field; - this.colId = col.colId; - this.docModel = this.field._table.docModel; - - this._refValueFormatter = this.autoDispose(ko.computed(() => - field.createVisibleColFormatter())); - - this._visibleColRef = Computed.create(this, (use) => use(this.field.visibleColRef)); - // Note that saveOnly is used here to prevent display value flickering on visible col change. - this._visibleColRef.onWrite((val) => this.field.visibleColRef.saveOnly(val)); - - this._validCols = Computed.create(this, (use) => { - const refTable = use(use(this.field.column).refTable); - if (!refTable) { return []; } - return use(use(refTable.columns)) - .filter(col => !use(col.isHiddenCol)) - .map(col => ({ - label: use(col.label), - value: col.getRowId(), - icon: 'FieldColumn', - disabled: gutil.startsWith(use(col.type), 'Ref:') || use(col.isTransforming) - })) - .concat([{label: 'Row ID', value: 0, icon: 'FieldColumn'}]); - }); - - // Computed returns a function that formats cell values. - this._formatValue = this.autoDispose(ko.computed(() => { - // If the field is pulling values from a display column, use a general-purpose formatter. - if (this.field.displayColRef() !== this.field.colRef()) { - const fmt = this._refValueFormatter(); - return (val) => fmt.formatAny(val); - } else { - const refTable = this.field.column().refTable(); - const refTableId = refTable ? refTable.tableId() : ""; - return (val) => val > 0 ? `${refTableId}[${val}]` : ""; - } - })); -} -dispose.makeDisposable(Reference); -_.extend(Reference.prototype, TextBox.prototype); - -Reference.prototype.buildConfigDom = function() { - return [ - cssLabel('SHOW COLUMN'), - cssRow( - select(this._visibleColRef, this._validCols), - testId('fbuilder-ref-col-select') - ) - ]; -}; - -Reference.prototype.buildTransformConfigDom = function() { - return this.buildConfigDom(); -}; - -Reference.prototype.buildDom = function(row, options) { - const formattedValue = ko.computed(() => { - if (row._isAddRow() || this.isDisposed() || this.field.displayColModel().isDisposed()) { - // Work around JS errors during certain changes (noticed when visibleCol field gets removed - // for a column using per-field settings). - return ""; - } - const value = row.cells[this.field.displayColModel().colId()]; - if (!value) { return ""; } - const content = value(); - if (isVersions(content)) { - // We can arrive here if the reference value is unchanged (viewed as a foreign key) - // but the content of its displayCol has changed. Postponing doing anything about - // this until we have three-way information for computed columns. For now, - // just showing one version of the cell. TODO: elaborate. - return this._formatValue()(content[1].local || content[1].parent); - } - return this._formatValue()(content); - }); - return dom('div.field_clip', - dom.autoDispose(formattedValue), - cssRefIcon('FieldReference', - testId('ref-link-icon') - ), - kd.text(formattedValue)); -}; - -const cssRefIcon = styled(icon, ` - background-color: ${colors.slate}; - margin: -1px 2px 2px 0; -`); - -module.exports = Reference; diff --git a/app/client/widgets/Reference.ts b/app/client/widgets/Reference.ts new file mode 100644 index 00000000..9b212f0b --- /dev/null +++ b/app/client/widgets/Reference.ts @@ -0,0 +1,106 @@ +import {DataRowModel} from 'app/client/models/DataRowModel'; +import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; +import {cssLabel, cssRow} from 'app/client/ui/RightPanel'; +import {colors, testId} from 'app/client/ui2018/cssVars'; +import {icon} from 'app/client/ui2018/icons'; +import {IOptionFull, select} from 'app/client/ui2018/menus'; +import {NTextBox} from 'app/client/widgets/NTextBox'; +import {isVersions} from 'app/common/gristTypes'; +import {BaseFormatter} from 'app/common/ValueFormatter'; +import {Computed, dom, styled} from 'grainjs'; +import * as ko from 'knockout'; + +/** + * Reference - The widget for displaying references to another table's records. + */ +export class Reference extends NTextBox { + private _refValueFormatter: ko.Computed; + private _visibleColRef: Computed; + private _validCols: Computed>>; + private _formatValue: Computed<(val: any) => string>; + + constructor(field: ViewFieldRec) { + super(field); + + this._refValueFormatter = this.autoDispose(ko.computed(() => + field.createVisibleColFormatter())); + + this._visibleColRef = Computed.create(this, (use) => use(this.field.visibleColRef)); + // Note that saveOnly is used here to prevent display value flickering on visible col change. + this._visibleColRef.onWrite((val) => this.field.visibleColRef.saveOnly(val)); + + this._validCols = Computed.create(this, (use) => { + const refTable = use(use(this.field.column).refTable); + if (!refTable) { return []; } + return use(use(refTable.columns).getObservable()) + .filter(col => !use(col.isHiddenCol)) + .map>(col => ({ + label: use(col.label), + value: col.getRowId(), + icon: 'FieldColumn', + disabled: use(col.type).startsWith('Ref:') || use(col.isTransforming) + })) + .concat([{label: 'Row ID', value: 0, icon: 'FieldColumn'}]); + }); + + // Computed returns a function that formats cell values. + this._formatValue = Computed.create(this, (use) => { + // If the field is pulling values from a display column, use a general-purpose formatter. + if (use(this.field.displayColRef) !== use(this.field.colRef)) { + const fmt = use(this._refValueFormatter); + return (val: any) => fmt.formatAny(val); + } else { + const refTable = use(use(this.field.column).refTable); + const refTableId = refTable ? use(refTable.tableId) : ""; + return (val: any) => val > 0 ? `${refTableId}[${val}]` : ""; + } + }); + } + + public buildConfigDom() { + return [ + cssLabel('SHOW COLUMN'), + cssRow( + select(this._visibleColRef, this._validCols), + testId('fbuilder-ref-col-select') + ) + ]; + } + + public buildTransformConfigDom() { + return this.buildConfigDom(); + } + + public buildDom(row: DataRowModel) { + const formattedValue = Computed.create(null, (use) => { + if (use(row._isAddRow) || this.isDisposed() || use(this.field.displayColModel).isDisposed()) { + // Work around JS errors during certain changes (noticed when visibleCol field gets removed + // for a column using per-field settings). + return ""; + } + const value = row.cells[use(use(this.field.displayColModel).colId)]; + if (!value) { return ""; } + const content = use(value); + if (isVersions(content)) { + // We can arrive here if the reference value is unchanged (viewed as a foreign key) + // but the content of its displayCol has changed. Postponing doing anything about + // this until we have three-way information for computed columns. For now, + // just showing one version of the cell. TODO: elaborate. + return use(this._formatValue)(content[1].local || content[1].parent); + } + return use(this._formatValue)(content); + }); + return dom('div.field_clip', + dom.autoDispose(formattedValue), + cssRefIcon('FieldReference', + testId('ref-link-icon') + ), + dom.text(formattedValue) + ); + } +} + +const cssRefIcon = styled(icon, ` + background-color: ${colors.slate}; + margin: -1px 2px 2px 0; +`); diff --git a/app/client/widgets/TextBox.js b/app/client/widgets/TextBox.js deleted file mode 100644 index 78cec301..00000000 --- a/app/client/widgets/TextBox.js +++ /dev/null @@ -1,71 +0,0 @@ -var _ = require('underscore'); -var ko = require('knockout'); -var dom = require('../lib/dom'); -var dispose = require('../lib/dispose'); -var kd = require('../lib/koDom'); -var AbstractWidget = require('./AbstractWidget'); -var modelUtil = require('../models/modelUtil'); - -const {fromKoSave} = require('app/client/lib/fromKoSave'); -const {alignmentSelect, buttonToggleSelect} = require('app/client/ui2018/buttonSelect'); -const {testId} = require('app/client/ui2018/cssVars'); -const {cssRow} = require('app/client/ui/RightPanel'); -const {Computed} = require('grainjs'); - -/** - * TextBox - The most basic widget for displaying text information. - */ -function TextBox(field) { - AbstractWidget.call(this, field); - this.alignment = this.options.prop('alignment'); - let wrap = this.options.prop('wrap'); - this.wrapping = this.autoDispose(ko.computed({ - read: () => { - let w = wrap(); - if (w === null || w === undefined) { - // When user has yet to specify a desired wrapping state, GridView and DetailView have - // different default states. GridView defaults to wrapping disabled, while DetailView - // defaults to wrapping enabled. - return (this.field.viewSection().parentKey() === 'record') ? false : true; - } else { - return w; - } - }, - write: val => wrap(val) - })); - modelUtil.addSaveInterface(this.wrapping, val => wrap.saveOnly(val)); - - this.autoDispose(this.wrapping.subscribe(() => - this.field.viewSection().events.trigger('rowHeightChange') - )); - -} -dispose.makeDisposable(TextBox); -_.extend(TextBox.prototype, AbstractWidget.prototype); - -TextBox.prototype.buildConfigDom = function() { - const wrapping = Computed.create(null, use => use(this.wrapping)); - wrapping.onWrite((val) => modelUtil.setSaveValue(this.wrapping, Boolean(val))); - - return dom('div', - cssRow( - dom.autoDispose(wrapping), - alignmentSelect(fromKoSave(this.alignment)), - dom('div', {style: 'margin-left: 8px;'}, - buttonToggleSelect(wrapping, [{value: true, icon: 'Wrap'}]), - testId('tb-wrap-text') - ) - ) - ); -}; - -TextBox.prototype.buildDom = function(row) { - let value = row[this.field.colId()]; - return dom('div.field_clip', - kd.style('text-align', this.alignment), - kd.toggleClass('text_wrapping', this.wrapping), - kd.text(() => row._isAddRow() ? '' : this.valueFormatter().format(value())) - ); -}; - -module.exports = TextBox; diff --git a/app/client/widgets/UserTypeImpl.js b/app/client/widgets/UserTypeImpl.js index 9732badc..751bed20 100644 --- a/app/client/widgets/UserTypeImpl.js +++ b/app/client/widgets/UserTypeImpl.js @@ -7,6 +7,9 @@ const UserType = require('./UserType'); const {HyperLinkEditor} = require('./HyperLinkEditor'); const {NTextEditor} = require('./NTextEditor'); const {ReferenceEditor} = require('./ReferenceEditor'); +const {HyperLinkTextBox} = require('./HyperLinkTextBox'); +const {ChoiceTextBox } = require('./ChoiceTextBox'); +const {Reference} = require('./Reference'); /** * Convert the name of a widget to its implementation. @@ -15,15 +18,15 @@ const nameToWidget = { 'TextBox': NTextBox, 'TextEditor': NTextEditor, 'NumericTextBox': NumericTextBox, - 'HyperLinkTextBox': require('./HyperLinkTextBox'), + 'HyperLinkTextBox': HyperLinkTextBox, 'HyperLinkEditor': HyperLinkEditor, 'Spinner': Spinner, 'CheckBox': require('./CheckBox'), 'CheckBoxEditor': require('./CheckBoxEditor'), - 'Reference': require('./Reference'), + 'Reference': Reference, 'Switch': require('./Switch'), 'ReferenceEditor': ReferenceEditor, - 'ChoiceTextBox': require('./ChoiceTextBox'), + 'ChoiceTextBox': ChoiceTextBox, 'ChoiceEditor': require('./ChoiceEditor'), 'DateTimeTextBox': require('./DateTimeTextBox'), 'DateTextBox': require('./DateTextBox'),