(core) Convert a few widgets to typescript and grainjs.

Summary:
No behavior changes.
Diff includes an intermediate commit with only renames, for easier review.

Test Plan: Existing tests should pass.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2669
This commit is contained in:
Dmitry S 2020-10-07 17:58:43 -04:00
parent f24a82e8d4
commit 4539521dff
13 changed files with 298 additions and 387 deletions

View File

@ -1,7 +1,7 @@
import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {colors, testId} from 'app/client/ui2018/cssVars'; import {colors, testId} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; 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 isEqual = require('lodash/isEqual');
import uniq = require('lodash/uniq'); 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. // 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) => { return dom.domComputed(this._isEditing, (editMode) => {
if (editMode) { if (editMode) {
// Edit mode dom. // Edit mode dom.

View File

@ -58,6 +58,9 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field"> {
widgetOptionsJson: modelUtil.SaveableObjObservable<any>; widgetOptionsJson: modelUtil.SaveableObjObservable<any>;
// Whether lines should wrap in a cell.
wrapping: ko.Computed<boolean>;
// Observable for the parsed filter object saved to the field. // Observable for the parsed filter object saved to the field.
activeFilter: modelUtil.CustomComputed<string>; activeFilter: modelUtil.CustomComputed<string>;
@ -180,6 +183,13 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
this.widgetOptionsJson = modelUtil.jsonObservable(this._widgetOptionsStr, this.widgetOptionsJson = modelUtil.jsonObservable(this._widgetOptionsStr,
(opts: any) => UserType.mergeOptions(opts || {}, this.column().pureType())); (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. // Observable for the active filter that's initialized from the value saved to the server.
this.activeFilter = modelUtil.customComputed({ this.activeFilter = modelUtil.customComputed({
read: () => { const f = this.filter(); return f === 'null' ? '' : f; }, // To handle old empty filters read: () => { const f = this.filter(); return f === 'null' ? '' : f; }, // To handle old empty filters

View File

@ -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;

View File

@ -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<string[]>;
private _choiceValues: Computed<string[]>;
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;
`);

View File

@ -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;

View File

@ -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;
`);

View File

@ -5,7 +5,7 @@ import {cssRow} from 'app/client/ui/RightPanel';
import {alignmentSelect, makeButtonSelect} from 'app/client/ui2018/buttonSelect'; import {alignmentSelect, makeButtonSelect} from 'app/client/ui2018/buttonSelect';
import {testId} from 'app/client/ui2018/cssVars'; import {testId} from 'app/client/ui2018/cssVars';
import {NewAbstractWidget} from 'app/client/widgets/NewAbstractWidget'; 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. * TextBox - The most basic widget for displaying text information.
@ -18,27 +18,15 @@ export class NTextBox extends NewAbstractWidget {
super(field); super(field);
this.alignment = fromKoSave<string>(this.options.prop('alignment')); this.alignment = fromKoSave<string>(this.options.prop('alignment'));
const wrap = this.options.prop('wrap'); this.wrapping = fromKo(this.field.wrapping);
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.autoDispose(this.wrapping.addListener(() => { this.autoDispose(this.wrapping.addListener(() => {
this.field.viewSection().events.trigger('rowHeightChange'); this.field.viewSection().events.trigger('rowHeightChange');
})); }));
} }
public buildConfigDom() { public buildConfigDom(): DomContents {
return dom('div', return [
cssRow( cssRow(
alignmentSelect(this.alignment), alignmentSelect(this.alignment),
dom('div', {style: 'margin-left: 8px;'}, dom('div', {style: 'margin-left: 8px;'},
@ -46,7 +34,7 @@ export class NTextBox extends NewAbstractWidget {
testId('tb-wrap-text') testId('tb-wrap-text')
) )
) )
); ];
} }
public buildDom(row: DataRowModel) { public buildDom(row: DataRowModel) {

View File

@ -10,7 +10,7 @@ import {cssLabel, cssRow} from 'app/client/ui/RightPanel';
import {colorSelect} from 'app/client/ui2018/buttonSelect'; import {colorSelect} from 'app/client/ui2018/buttonSelect';
import {colors, testId} from 'app/client/ui2018/cssVars'; import {colors, testId} from 'app/client/ui2018/cssVars';
import {BaseFormatter, createFormatter} from 'app/common/ValueFormatter'; 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'; 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. * 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. * 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. * 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[] { public buildColorConfigDom(): Element[] {
return [ return [

View File

@ -10,7 +10,7 @@ import {icon} from 'app/client/ui2018/icons';
import {NTextBox} from 'app/client/widgets/NTextBox'; import {NTextBox} from 'app/client/widgets/NTextBox';
import {clamp} from 'app/common/gutil'; import {clamp} from 'app/common/gutil';
import {buildNumberFormat, NumberFormatOptions, NumMode, NumSign} from 'app/common/NumberFormat'; 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<ISelectorOption<NumMode>> = [ const modeOptions: Array<ISelectorOption<NumMode>> = [
@ -32,7 +32,7 @@ export class NumericTextBox extends NTextBox {
super(field); super(field);
} }
public buildConfigDom() { public buildConfigDom(): DomContents {
// Holder for all computeds created here. It gets disposed with the returned DOM element. // Holder for all computeds created here. It gets disposed with the returned DOM element.
const holder = new MultiHolder(); 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 setMode = (val: NumMode) => setSave('numMode', val !== numMode.get() ? val : undefined);
const setSign = (val: NumSign) => setSave('numSign', val !== numSign.get() ? val : undefined); const setSign = (val: NumSign) => setSave('numSign', val !== numSign.get() ? val : undefined);
return dom.update(super.buildConfigDom(), return [
dom.autoDispose(holder), super.buildConfigDom(),
cssLabel('Number Format'), cssLabel('Number Format'),
cssRow( cssRow(
dom.autoDispose(holder),
makeButtonSelect(numMode, modeOptions, setMode, {}, cssModeSelect.cls(''), testId('numeric-mode')), makeButtonSelect(numMode, modeOptions, setMode, {}, cssModeSelect.cls(''), testId('numeric-mode')),
makeButtonSelect(numSign, signOptions, setSign, {}, cssSignSelect.cls(''), testId('numeric-sign')), 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('min', minDecimals, defaultMin, setMinDecimals, testId('numeric-min-decimals')),
decimals('max', maxDecimals, defaultMax, setMaxDecimals, testId('numeric-max-decimals')), decimals('max', maxDecimals, defaultMax, setMaxDecimals, testId('numeric-max-decimals')),
), ),
); ];
} }
} }

View File

@ -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;

View File

@ -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<BaseFormatter>;
private _visibleColRef: Computed<number>;
private _validCols: Computed<Array<IOptionFull<number>>>;
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<IOptionFull<number>>(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;
`);

View File

@ -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;

View File

@ -7,6 +7,9 @@ const UserType = require('./UserType');
const {HyperLinkEditor} = require('./HyperLinkEditor'); const {HyperLinkEditor} = require('./HyperLinkEditor');
const {NTextEditor} = require('./NTextEditor'); const {NTextEditor} = require('./NTextEditor');
const {ReferenceEditor} = require('./ReferenceEditor'); 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. * Convert the name of a widget to its implementation.
@ -15,15 +18,15 @@ const nameToWidget = {
'TextBox': NTextBox, 'TextBox': NTextBox,
'TextEditor': NTextEditor, 'TextEditor': NTextEditor,
'NumericTextBox': NumericTextBox, 'NumericTextBox': NumericTextBox,
'HyperLinkTextBox': require('./HyperLinkTextBox'), 'HyperLinkTextBox': HyperLinkTextBox,
'HyperLinkEditor': HyperLinkEditor, 'HyperLinkEditor': HyperLinkEditor,
'Spinner': Spinner, 'Spinner': Spinner,
'CheckBox': require('./CheckBox'), 'CheckBox': require('./CheckBox'),
'CheckBoxEditor': require('./CheckBoxEditor'), 'CheckBoxEditor': require('./CheckBoxEditor'),
'Reference': require('./Reference'), 'Reference': Reference,
'Switch': require('./Switch'), 'Switch': require('./Switch'),
'ReferenceEditor': ReferenceEditor, 'ReferenceEditor': ReferenceEditor,
'ChoiceTextBox': require('./ChoiceTextBox'), 'ChoiceTextBox': ChoiceTextBox,
'ChoiceEditor': require('./ChoiceEditor'), 'ChoiceEditor': require('./ChoiceEditor'),
'DateTimeTextBox': require('./DateTimeTextBox'), 'DateTimeTextBox': require('./DateTimeTextBox'),
'DateTextBox': require('./DateTextBox'), 'DateTextBox': require('./DateTextBox'),