mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Polish and enable Reference List widget
Summary: Adds Reference List as a widget type. Reference List is similar to Choice List: multiple references can be added to each cell through a similar editor, and the individual references will always reflect their current value from the referenced table. Test Plan: Browser tests. Reviewers: dsagal Reviewed By: dsagal Subscribers: paulfitz, jarek, alexmojaki, dsagal Differential Revision: https://phab.getgrist.com/D2959
This commit is contained in:
parent
34e9ad3498
commit
79f6f605f8
@ -11,6 +11,7 @@ import * as gristTypes from 'app/common/gristTypes';
|
|||||||
import {isFullReferencingType} from 'app/common/gristTypes';
|
import {isFullReferencingType} from 'app/common/gristTypes';
|
||||||
import * as gutil from 'app/common/gutil';
|
import * as gutil from 'app/common/gutil';
|
||||||
import {TableData} from 'app/common/TableData';
|
import {TableData} from 'app/common/TableData';
|
||||||
|
import {decodeObject} from 'app/plugin/objtypes';
|
||||||
|
|
||||||
export interface ColInfo {
|
export interface ColInfo {
|
||||||
type: string;
|
type: string;
|
||||||
@ -92,9 +93,11 @@ export async function prepTransformColInfo(docModel: DocModel, origCol: ColumnRe
|
|||||||
} else {
|
} else {
|
||||||
// Set suggested choices. Limit to 100, since too many choices is more likely to cause
|
// Set suggested choices. Limit to 100, since too many choices is more likely to cause
|
||||||
// trouble than desired behavior. For many choices, recommend using a Ref to helper table.
|
// trouble than desired behavior. For many choices, recommend using a Ref to helper table.
|
||||||
const columnData = tableData.getDistinctValues(origCol.colId(), 100);
|
const colId = isReferenceCol(origCol) ? origDisplayCol.colId() : origCol.colId();
|
||||||
|
const columnData = tableData.getDistinctValues(colId, 100);
|
||||||
if (columnData) {
|
if (columnData) {
|
||||||
columnData.delete("");
|
columnData.delete("");
|
||||||
|
columnData.delete(null);
|
||||||
widgetOptions = {choices: Array.from(columnData, String)};
|
widgetOptions = {choices: Array.from(columnData, String)};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -108,8 +111,10 @@ export async function prepTransformColInfo(docModel: DocModel, origCol: ColumnRe
|
|||||||
// Set suggested choices. This happens before the conversion to ChoiceList, so we do some
|
// Set suggested choices. This happens before the conversion to ChoiceList, so we do some
|
||||||
// light guessing for likely choices to suggest.
|
// light guessing for likely choices to suggest.
|
||||||
const choices = new Set<string>();
|
const choices = new Set<string>();
|
||||||
for (let value of tableData.getColValues(origCol.colId()) || []) {
|
const colId = isReferenceCol(origCol) ? origDisplayCol.colId() : origCol.colId();
|
||||||
value = String(value).trim();
|
for (let value of tableData.getColValues(colId) || []) {
|
||||||
|
if (value === null) { continue; }
|
||||||
|
value = String(decodeObject(value)).trim();
|
||||||
const tags: string[] = (value.startsWith('[') && gutil.safeJsonParse(value, null)) || value.split(",");
|
const tags: string[] = (value.startsWith('[') && gutil.safeJsonParse(value, null)) || value.split(",");
|
||||||
for (const tag of tags) {
|
for (const tag of tags) {
|
||||||
choices.add(tag.trim());
|
choices.add(tag.trim());
|
||||||
|
@ -103,9 +103,7 @@ export class TokenField<Token extends IToken = IToken> extends Disposable {
|
|||||||
const openAutocomplete = this._openAutocomplete.bind(this);
|
const openAutocomplete = this._openAutocomplete.bind(this);
|
||||||
this._acOptions = _options.acOptions && {..._options.acOptions, onClick: addSelectedItem};
|
this._acOptions = _options.acOptions && {..._options.acOptions, onClick: addSelectedItem};
|
||||||
|
|
||||||
const initialTokens = _options.initialValue;
|
this.setTokens(_options.initialValue);
|
||||||
this._maybeTrimLabels(initialTokens);
|
|
||||||
this._tokens.set(initialTokens.map(t => new TokenWrap(t)));
|
|
||||||
this.tokensObs = this.autoDispose(computedArray(this._tokens, t => t.token));
|
this.tokensObs = this.autoDispose(computedArray(this._tokens, t => t.token));
|
||||||
this._keyBindings = {...defaultKeyBindings, ..._options.keyBindings};
|
this._keyBindings = {...defaultKeyBindings, ..._options.keyBindings};
|
||||||
|
|
||||||
@ -207,6 +205,26 @@ export class TokenField<Token extends IToken = IToken> extends Disposable {
|
|||||||
return this._hiddenInput;
|
return this._hiddenInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the Autocomplete instance used by the TokenField.
|
||||||
|
*/
|
||||||
|
public getAutocomplete(): Autocomplete<Token & ACItem> | null {
|
||||||
|
return this._acHolder.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the `tokens` that the TokenField should be populated with.
|
||||||
|
*
|
||||||
|
* Can be called after the TokenField is created to override the
|
||||||
|
* stored tokens. This is useful for delayed token initialization,
|
||||||
|
* where `tokens` may need to be set shortly after the TokenField
|
||||||
|
* is opened (e.g. ReferenceListEditor).
|
||||||
|
*/
|
||||||
|
public setTokens(tokens: Token[]): void {
|
||||||
|
const formattedTokens = this._maybeTrimTokens(tokens);
|
||||||
|
this._tokens.set(formattedTokens.map(t => new TokenWrap(t)));
|
||||||
|
}
|
||||||
|
|
||||||
// Replaces a token (if it exists).
|
// Replaces a token (if it exists).
|
||||||
public replaceToken(label: string, newToken: Token): void {
|
public replaceToken(label: string, newToken: Token): void {
|
||||||
const tokenIdx = this._tokens.get().findIndex(t => t.token.label === label);
|
const tokenIdx = this._tokens.get().findIndex(t => t.token.label === label);
|
||||||
@ -433,7 +451,7 @@ export class TokenField<Token extends IToken = IToken> extends Disposable {
|
|||||||
tokens = values.map(v => this._options.createToken(v)).filter((t): t is Token => Boolean(t));
|
tokens = values.map(v => this._options.createToken(v)).filter((t): t is Token => Boolean(t));
|
||||||
}
|
}
|
||||||
if (!tokens.length) { return; }
|
if (!tokens.length) { return; }
|
||||||
this._maybeTrimLabels(tokens);
|
tokens = this._maybeTrimTokens(tokens);
|
||||||
tokens = this._getNonEmptyTokens(tokens);
|
tokens = this._getNonEmptyTokens(tokens);
|
||||||
const wrappedTokens = tokens.map(t => new TokenWrap(t));
|
const wrappedTokens = tokens.map(t => new TokenWrap(t));
|
||||||
this._combineUndo(() => {
|
this._combineUndo(() => {
|
||||||
@ -582,15 +600,11 @@ export class TokenField<Token extends IToken = IToken> extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trims all labels in `tokens` if the option is set.
|
* Returns an array of tokens formatted according to the `trimLabels` option.
|
||||||
*
|
|
||||||
* Note: mutates `tokens`.
|
|
||||||
*/
|
*/
|
||||||
private _maybeTrimLabels(tokens: Token[]): void {
|
private _maybeTrimTokens(tokens: Token[]): Token[] {
|
||||||
if (!this._options.trimLabels) { return; }
|
if (!this._options.trimLabels) { return tokens; }
|
||||||
tokens.forEach(t => {
|
return tokens.map(t => ({...t, label: t.label.trim()}));
|
||||||
t.label = t.label.trim();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
*
|
*
|
||||||
* It is available as tableData.columnACIndexes.
|
* It is available as tableData.columnACIndexes.
|
||||||
*
|
*
|
||||||
* It is currently used for auto-complete in the ReferenceEditor widget.
|
* It is currently used for auto-complete in the ReferenceEditor and ReferenceListEditor widgets.
|
||||||
*/
|
*/
|
||||||
import {ACIndex, ACIndexImpl} from 'app/client/lib/ACIndex';
|
import {ACIndex, ACIndexImpl} from 'app/client/lib/ACIndex';
|
||||||
import {ColumnCache} from 'app/client/models/ColumnCache';
|
import {ColumnCache} from 'app/client/models/ColumnCache';
|
||||||
|
@ -76,8 +76,8 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field"> {
|
|||||||
// Helper which adds/removes/updates field's displayCol to match the formula.
|
// Helper which adds/removes/updates field's displayCol to match the formula.
|
||||||
saveDisplayFormula(formula: string): Promise<void>|undefined;
|
saveDisplayFormula(formula: string): Promise<void>|undefined;
|
||||||
|
|
||||||
// Helper for Reference columns, which returns a formatter according to the visibleCol
|
// Helper for Reference/ReferenceList columns, which returns a formatter according
|
||||||
// associated with field. Subscribes to observables if used within a computed.
|
// to the visibleCol associated with field. Subscribes to observables if used within a computed.
|
||||||
createVisibleColFormatter(): BaseFormatter;
|
createVisibleColFormatter(): BaseFormatter;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,7 +160,7 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
|
|||||||
this.displayColModel = refRecord(docModel.columns, this.displayColRef);
|
this.displayColModel = refRecord(docModel.columns, this.displayColRef);
|
||||||
this.visibleColModel = refRecord(docModel.columns, this.visibleColRef);
|
this.visibleColModel = refRecord(docModel.columns, this.visibleColRef);
|
||||||
|
|
||||||
// Helper for Reference columns, which returns a formatter according to the visibleCol
|
// 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.
|
// associated with this field. If no visible column available, return formatting for the field itself.
|
||||||
// Subscribes to observables if used within a computed.
|
// Subscribes to observables if used within a computed.
|
||||||
// TODO: It would be better to replace this with a pureComputed whose value is a formatter.
|
// TODO: It would be better to replace this with a pureComputed whose value is a formatter.
|
||||||
|
@ -27,7 +27,7 @@ import some = require('lodash/some');
|
|||||||
import tail = require('lodash/tail');
|
import tail = require('lodash/tail');
|
||||||
import {IOpenController, IPopupOptions, setPopupToCreateDom} from 'popweasel';
|
import {IOpenController, IPopupOptions, setPopupToCreateDom} from 'popweasel';
|
||||||
import {decodeObject} from 'app/plugin/objtypes';
|
import {decodeObject} from 'app/plugin/objtypes';
|
||||||
import {isList} from 'app/common/gristTypes';
|
import {isList, isRefListType} from 'app/common/gristTypes';
|
||||||
import {choiceToken} from 'app/client/widgets/ChoiceToken';
|
import {choiceToken} from 'app/client/widgets/ChoiceToken';
|
||||||
import {ChoiceOptions} from 'app/client/widgets/ChoiceTextBox';
|
import {ChoiceOptions} from 'app/client/widgets/ChoiceTextBox';
|
||||||
import {cssInvalidToken} from 'app/client/widgets/ChoiceListCell';
|
import {cssInvalidToken} from 'app/client/widgets/ChoiceListCell';
|
||||||
@ -281,12 +281,9 @@ function formatUniqueCount(values: Array<[CellValue, IFilterCount]>) {
|
|||||||
export function createFilterMenu(openCtl: IOpenController, sectionFilter: SectionFilter, field: ViewFieldRec,
|
export function createFilterMenu(openCtl: IOpenController, sectionFilter: SectionFilter, field: ViewFieldRec,
|
||||||
rowSource: RowSource, tableData: TableData, onClose: () => void = noop) {
|
rowSource: RowSource, tableData: TableData, onClose: () => void = noop) {
|
||||||
// Go through all of our shown and hidden rows, and count them up by the values in this column.
|
// Go through all of our shown and hidden rows, and count them up by the values in this column.
|
||||||
const keyMapFunc = tableData.getRowPropFunc(field.column().colId())!;
|
|
||||||
const labelGetter = tableData.getRowPropFunc(field.displayColModel().colId())!;
|
|
||||||
const formatter = field.createVisibleColFormatter();
|
|
||||||
const labelMapFunc = (rowId: number) => formatter.formatAny(labelGetter(rowId));
|
|
||||||
const activeFilterBar = field.viewSection.peek().activeFilterBar;
|
|
||||||
const columnType = field.column().type.peek();
|
const columnType = field.column().type.peek();
|
||||||
|
const {keyMapFunc, labelMapFunc} = getMapFuncs(columnType, tableData, field);
|
||||||
|
const activeFilterBar = field.viewSection.peek().activeFilterBar;
|
||||||
|
|
||||||
function getFilterFunc(f: ViewFieldRec, colFilter: ColumnFilterFunc|null) {
|
function getFilterFunc(f: ViewFieldRec, colFilter: ColumnFilterFunc|null) {
|
||||||
return f.getRowId() === field.getRowId() ? null : colFilter;
|
return f.getRowId() === field.getRowId() ? null : colFilter;
|
||||||
@ -322,6 +319,37 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns two callback functions, `keyMapFunc` and `labelMapFunc`,
|
||||||
|
* which map row ids to cell values and labels respectively.
|
||||||
|
*
|
||||||
|
* The functions vary based on the `columnType`. For example,
|
||||||
|
* Reference Lists have a unique `labelMapFunc` that returns a list
|
||||||
|
* of all labels in a given cell, rather than a single label.
|
||||||
|
*
|
||||||
|
* Used by ColumnFilterMenu to compute counts of unique cell
|
||||||
|
* values and display them with an appropriate label.
|
||||||
|
*/
|
||||||
|
function getMapFuncs(columnType: string, tableData: TableData, field: ViewFieldRec) {
|
||||||
|
const keyMapFunc = tableData.getRowPropFunc(field.column().colId())!;
|
||||||
|
const labelGetter = tableData.getRowPropFunc(field.displayColModel().colId())!;
|
||||||
|
const formatter = field.createVisibleColFormatter();
|
||||||
|
|
||||||
|
let labelMapFunc: (rowId: number) => string | string[];
|
||||||
|
if (isRefListType(columnType)) {
|
||||||
|
labelMapFunc = (rowId: number) => {
|
||||||
|
const maybeLabels = labelGetter(rowId);
|
||||||
|
if (!maybeLabels) { return ''; }
|
||||||
|
const labels = isList(maybeLabels) ? maybeLabels.slice(1) : [maybeLabels];
|
||||||
|
return labels.map(l => formatter.formatAny(l));
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
labelMapFunc = (rowId: number) => formatter.formatAny(labelGetter(rowId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {keyMapFunc, labelMapFunc};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a callback function for rendering values in a filter menu.
|
* Returns a callback function for rendering values in a filter menu.
|
||||||
*
|
*
|
||||||
@ -358,9 +386,9 @@ function getRenderFunc(columnType: string, field: ViewFieldRec) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ICountOptions {
|
interface ICountOptions {
|
||||||
|
columnType: string;
|
||||||
keyMapFunc?: (v: any) => any;
|
keyMapFunc?: (v: any) => any;
|
||||||
labelMapFunc?: (v: any) => any;
|
labelMapFunc?: (v: any) => any;
|
||||||
columnType?: string;
|
|
||||||
areHiddenRows?: boolean;
|
areHiddenRows?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -379,13 +407,23 @@ function addCountsToMap(valueMap: Map<CellValue, IFilterCount>, rowIds: RowId[],
|
|||||||
let key = keyMapFunc(rowId);
|
let key = keyMapFunc(rowId);
|
||||||
|
|
||||||
// If row contains a list and the column is a Choice List, treat each choice as a separate key
|
// If row contains a list and the column is a Choice List, treat each choice as a separate key
|
||||||
if (isList(key) && columnType === 'ChoiceList') {
|
if (isList(key) && (columnType === 'ChoiceList')) {
|
||||||
const list = decodeObject(key) as unknown[];
|
const list = decodeObject(key) as unknown[];
|
||||||
for (const item of list) {
|
for (const item of list) {
|
||||||
addSingleCountToMap(valueMap, item, () => item, areHiddenRows);
|
addSingleCountToMap(valueMap, item, () => item, areHiddenRows);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If row contains a Reference List, treat each reference as a separate key
|
||||||
|
if (isList(key) && isRefListType(columnType)) {
|
||||||
|
const refIds = decodeObject(key) as unknown[];
|
||||||
|
const refLabels = labelMapFunc(rowId);
|
||||||
|
refIds.forEach((id, i) => {
|
||||||
|
addSingleCountToMap(valueMap, id, () => refLabels[i], areHiddenRows);
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// For complex values, serialize the value to allow them to be properly stored
|
// For complex values, serialize the value to allow them to be properly stored
|
||||||
if (Array.isArray(key)) { key = JSON.stringify(key); }
|
if (Array.isArray(key)) { key = JSON.stringify(key); }
|
||||||
addSingleCountToMap(valueMap, key, () => labelMapFunc(rowId), areHiddenRows);
|
addSingleCountToMap(valueMap, key, () => labelMapFunc(rowId), areHiddenRows);
|
||||||
|
@ -103,11 +103,12 @@ export function select<T>(obs: Observable<T>, optionArray: MaybeObsArray<IOption
|
|||||||
const _menu = cssSelectMenuElem(testId('select-menu'));
|
const _menu = cssSelectMenuElem(testId('select-menu'));
|
||||||
const _btn = cssSelectBtn(testId('select-open'));
|
const _btn = cssSelectBtn(testId('select-open'));
|
||||||
|
|
||||||
|
const {menuCssClass: menuClass, ...otherOptions} = options;
|
||||||
const selectOptions = {
|
const selectOptions = {
|
||||||
buttonArrow: cssInlineCollapseIcon('Collapse'),
|
buttonArrow: cssInlineCollapseIcon('Collapse'),
|
||||||
menuCssClass: _menu.className,
|
menuCssClass: _menu.className + ' ' + (menuClass || ''),
|
||||||
buttonCssClass: _btn.className,
|
buttonCssClass: _btn.className,
|
||||||
...options,
|
...otherOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
return weasel.select(obs, optionArray, selectOptions, (op) =>
|
return weasel.select(obs, optionArray, selectOptions, (op) =>
|
||||||
|
@ -28,7 +28,7 @@ import * as gristTypes from 'app/common/gristTypes';
|
|||||||
import { getReferencedTableId, isFullReferencingType } from 'app/common/gristTypes';
|
import { getReferencedTableId, isFullReferencingType } from 'app/common/gristTypes';
|
||||||
import { CellValue } from 'app/plugin/GristData';
|
import { CellValue } from 'app/plugin/GristData';
|
||||||
import { Computed, Disposable, fromKo, dom as grainjsDom,
|
import { Computed, Disposable, fromKo, dom as grainjsDom,
|
||||||
Holder, IDisposable, makeTestId, toKo } from 'grainjs';
|
Holder, IDisposable, makeTestId, styled, toKo } from 'grainjs';
|
||||||
import * as ko from 'knockout';
|
import * as ko from 'knockout';
|
||||||
import * as _ from 'underscore';
|
import * as _ from 'underscore';
|
||||||
|
|
||||||
@ -222,7 +222,8 @@ export class FieldBuilder extends Disposable {
|
|||||||
grainjsDom.autoDispose(selectType),
|
grainjsDom.autoDispose(selectType),
|
||||||
select(selectType, this._availableTypes, {
|
select(selectType, this._availableTypes, {
|
||||||
disabled: (use) => use(this._isTransformingFormula) || use(this.origColumn.disableModifyBase) ||
|
disabled: (use) => use(this._isTransformingFormula) || use(this.origColumn.disableModifyBase) ||
|
||||||
use(this.isCallPending)
|
use(this.isCallPending),
|
||||||
|
menuCssClass: cssTypeSelectMenu.className,
|
||||||
}),
|
}),
|
||||||
testId('type-select'),
|
testId('type-select'),
|
||||||
grainjsDom.cls('tour-type-selector'),
|
grainjsDom.cls('tour-type-selector'),
|
||||||
@ -526,3 +527,7 @@ export class FieldBuilder extends Disposable {
|
|||||||
this.gristDoc.fieldEditorHolder.autoDispose(editorHolder);
|
this.gristDoc.fieldEditorHolder.autoDispose(editorHolder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cssTypeSelectMenu = styled('div', `
|
||||||
|
max-height: 500px;
|
||||||
|
`);
|
||||||
|
@ -77,32 +77,55 @@ export class Reference extends NTextBox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public buildDom(row: DataRowModel) {
|
public buildDom(row: DataRowModel) {
|
||||||
|
// Note: we require 2 observables here because changes to the cell value (reference id)
|
||||||
|
// and the display value (display column) are not bundled. This can cause `formattedValue`
|
||||||
|
// to briefly display incorrect values (e.g. [Blank] when adding a reference to an empty cell)
|
||||||
|
// because the cell value changes before the display column has a chance to update.
|
||||||
|
//
|
||||||
|
// TODO: Look into a better solution (perhaps updating the display formula to return [Blank]).
|
||||||
|
const referenceId = Computed.create(null, (use) => {
|
||||||
|
const id = row.cells[use(this.field.colId)];
|
||||||
|
return id && use(id);
|
||||||
|
});
|
||||||
const formattedValue = Computed.create(null, (use) => {
|
const formattedValue = Computed.create(null, (use) => {
|
||||||
|
let [value, hasBlankReference] = ['', false];
|
||||||
if (use(row._isAddRow) || this.isDisposed() || use(this.field.displayColModel).isDisposed()) {
|
if (use(row._isAddRow) || this.isDisposed() || use(this.field.displayColModel).isDisposed()) {
|
||||||
// Work around JS errors during certain changes (noticed when visibleCol field gets removed
|
// Work around JS errors during certain changes (noticed when visibleCol field gets removed
|
||||||
// for a column using per-field settings).
|
// for a column using per-field settings).
|
||||||
return "";
|
return {value, hasBlankReference};
|
||||||
}
|
}
|
||||||
const value = row.cells[use(use(this.field.displayColModel).colId)];
|
|
||||||
if (!value) { return ""; }
|
const displayValueObs = row.cells[use(use(this.field.displayColModel).colId)];
|
||||||
const content = use(value);
|
if (!displayValueObs) {
|
||||||
if (isVersions(content)) {
|
return {value, hasBlankReference};
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayValue = use(displayValueObs);
|
||||||
|
value = isVersions(displayValue) ?
|
||||||
// We can arrive here if the reference value is unchanged (viewed as a foreign key)
|
// 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
|
// but the content of its displayCol has changed. Postponing doing anything about
|
||||||
// this until we have three-way information for computed columns. For now,
|
// this until we have three-way information for computed columns. For now,
|
||||||
// just showing one version of the cell. TODO: elaborate.
|
// just showing one version of the cell. TODO: elaborate.
|
||||||
return use(this._formatValue)(content[1].local || content[1].parent);
|
use(this._formatValue)(displayValue[1].local || displayValue[1].parent) :
|
||||||
}
|
use(this._formatValue)(displayValue);
|
||||||
return use(this._formatValue)(content);
|
|
||||||
|
hasBlankReference = referenceId.get() !== 0 && value.trim() === '';
|
||||||
|
|
||||||
|
return {value, hasBlankReference};
|
||||||
});
|
});
|
||||||
return dom('div.field_clip',
|
|
||||||
|
return cssRef(
|
||||||
dom.autoDispose(formattedValue),
|
dom.autoDispose(formattedValue),
|
||||||
|
dom.autoDispose(referenceId),
|
||||||
|
cssRef.cls('-blank', use => use(formattedValue).hasBlankReference),
|
||||||
dom.style('text-align', this.alignment),
|
dom.style('text-align', this.alignment),
|
||||||
dom.cls('text_wrapping', this.wrapping),
|
dom.cls('text_wrapping', this.wrapping),
|
||||||
cssRefIcon('FieldReference',
|
cssRefIcon('FieldReference', testId('ref-link-icon')),
|
||||||
testId('ref-link-icon')
|
dom.text(use => {
|
||||||
),
|
if (use(referenceId) === 0) { return ''; }
|
||||||
dom.text(formattedValue)
|
if (use(formattedValue).hasBlankReference) { return '[Blank]'; }
|
||||||
|
return use(formattedValue).value;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -112,3 +135,9 @@ const cssRefIcon = styled(icon, `
|
|||||||
background-color: ${colors.slate};
|
background-color: ${colors.slate};
|
||||||
margin: -1px 2px 2px 0;
|
margin: -1px 2px 2px 0;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const cssRef = styled('div.field_clip', `
|
||||||
|
&-blank {
|
||||||
|
color: ${colors.slate}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
@ -9,7 +9,8 @@ import {menuCssClass} from 'app/client/ui2018/menus';
|
|||||||
import {Options} from 'app/client/widgets/NewBaseEditor';
|
import {Options} from 'app/client/widgets/NewBaseEditor';
|
||||||
import {NTextEditor} from 'app/client/widgets/NTextEditor';
|
import {NTextEditor} from 'app/client/widgets/NTextEditor';
|
||||||
import {CellValue} from 'app/common/DocActions';
|
import {CellValue} from 'app/common/DocActions';
|
||||||
import {removePrefix, undef} from 'app/common/gutil';
|
import {getReferencedTableId} from 'app/common/gristTypes';
|
||||||
|
import {undef} from 'app/common/gutil';
|
||||||
import {BaseFormatter} from 'app/common/ValueFormatter';
|
import {BaseFormatter} from 'app/common/ValueFormatter';
|
||||||
import {styled} from 'grainjs';
|
import {styled} from 'grainjs';
|
||||||
|
|
||||||
@ -31,7 +32,7 @@ export class ReferenceEditor extends NTextEditor {
|
|||||||
const field = options.field;
|
const field = options.field;
|
||||||
|
|
||||||
// Get the table ID to which the reference points.
|
// Get the table ID to which the reference points.
|
||||||
const refTableId = removePrefix(field.column().type(), "Ref:");
|
const refTableId = getReferencedTableId(field.column().type());
|
||||||
if (!refTableId) {
|
if (!refTableId) {
|
||||||
throw new Error("ReferenceEditor used for non-Reference column");
|
throw new Error("ReferenceEditor used for non-Reference column");
|
||||||
}
|
}
|
||||||
@ -195,7 +196,9 @@ const cssRefEditor = styled('div', `
|
|||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssRefList = styled('div', `
|
// Set z-index to be higher than the 1000 set for .cell_editor.
|
||||||
|
export const cssRefList = styled('div', `
|
||||||
|
z-index: 1001;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px 0 0 0;
|
padding: 8px 0 0 0;
|
||||||
--weaseljs-menu-item-padding: 8px 16px;
|
--weaseljs-menu-item-padding: 8px 16px;
|
||||||
@ -235,7 +238,7 @@ const cssRefItem = styled('li', `
|
|||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssPlusButton = styled('div', `
|
export const cssPlusButton = styled('div', `
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
@ -250,7 +253,7 @@ const cssPlusButton = styled('div', `
|
|||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssPlusIcon = styled(icon, `
|
export const cssPlusIcon = styled(icon, `
|
||||||
background-color: ${colors.light};
|
background-color: ${colors.light};
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||||
import {testId} from 'app/client/ui2018/cssVars';
|
import {colors, testId} from 'app/client/ui2018/cssVars';
|
||||||
import {isList} from 'app/common/gristTypes';
|
import {isList} from 'app/common/gristTypes';
|
||||||
import {dom} from 'grainjs';
|
import {dom} from 'grainjs';
|
||||||
import {cssChoiceList, cssToken} from "./ChoiceListCell";
|
import {cssChoiceList, cssToken} from "app/client/widgets/ChoiceListCell";
|
||||||
import {Reference} from "./Reference";
|
import {Reference} from "app/client/widgets/Reference";
|
||||||
import {choiceToken} from "./ChoiceToken";
|
import {choiceToken} from "app/client/widgets/ChoiceToken";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ReferenceList - The widget for displaying lists of references to another table's records.
|
* ReferenceList - The widget for displaying lists of references to another table's records.
|
||||||
@ -27,7 +27,9 @@ export class ReferenceList extends Reference {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const content = use(value);
|
const content = use(value);
|
||||||
// if (isVersions(content)) { // TODO
|
if (!content) { return null; }
|
||||||
|
// TODO: Figure out what the implications of this block are for ReferenceList.
|
||||||
|
// if (isVersions(content)) {
|
||||||
// // We can arrive here if the reference value is unchanged (viewed as a foreign key)
|
// // 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
|
// // but the content of its displayCol has changed. Postponing doing anything about
|
||||||
// // this until we have three-way information for computed columns. For now,
|
// // this until we have three-way information for computed columns. For now,
|
||||||
@ -36,18 +38,22 @@ export class ReferenceList extends Reference {
|
|||||||
// }
|
// }
|
||||||
const items = isList(content) ? content.slice(1) : [content];
|
const items = isList(content) ? content.slice(1) : [content];
|
||||||
return items.map(use(this._formatValue));
|
return items.map(use(this._formatValue));
|
||||||
}, (input) => {
|
},
|
||||||
|
(input) => {
|
||||||
if (!input) {
|
if (!input) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return input.map(token =>
|
return input.map(token => {
|
||||||
choiceToken(
|
const isBlankReference = token.trim() === '';
|
||||||
String(token),
|
return choiceToken(
|
||||||
{}, // default colors
|
isBlankReference ? '[Blank]' : token,
|
||||||
|
{
|
||||||
|
textColor: isBlankReference ? colors.slate.value : undefined
|
||||||
|
},
|
||||||
dom.cls(cssToken.className),
|
dom.cls(cssToken.className),
|
||||||
testId('ref-list-cell-token')
|
testId('ref-list-cell-token')
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,402 @@
|
|||||||
import {NTextEditor} from 'app/client/widgets/NTextEditor';
|
import {createGroup} from 'app/client/components/commands';
|
||||||
|
import {ACItem, ACResults, HighlightFunc} from 'app/client/lib/ACIndex';
|
||||||
|
import {IAutocompleteOptions} from 'app/client/lib/autocomplete';
|
||||||
|
import {IToken, TokenField, tokenFieldStyles} from 'app/client/lib/TokenField';
|
||||||
|
import {colors, testId} from 'app/client/ui2018/cssVars';
|
||||||
|
import {menuCssClass} from 'app/client/ui2018/menus';
|
||||||
|
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
|
||||||
|
import {EditorPlacement} from 'app/client/widgets/EditorPlacement';
|
||||||
|
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
|
||||||
|
import {csvEncodeRow} from 'app/common/csvFormat';
|
||||||
import {CellValue} from "app/common/DocActions";
|
import {CellValue} from "app/common/DocActions";
|
||||||
|
import {decodeObject, encodeObject} from 'app/plugin/objtypes';
|
||||||
|
import {dom, styled} from 'grainjs';
|
||||||
|
import {cssRefList, renderACItem} from 'app/client/widgets/ReferenceEditor';
|
||||||
|
import {TableData} from 'app/client/models/TableData';
|
||||||
|
import {BaseFormatter} from 'app/common/ValueFormatter';
|
||||||
|
import {reportError} from 'app/client/models/errors';
|
||||||
|
import {getReferencedTableId} from 'app/common/gristTypes';
|
||||||
|
import {cssInvalidToken} from 'app/client/widgets/ChoiceListCell';
|
||||||
|
|
||||||
|
class ReferenceItem implements IToken, ACItem {
|
||||||
|
/**
|
||||||
|
* A slight misnomer: what actually gets shown inside the TokenField
|
||||||
|
* is the `text`. Instead, `label` identifies a Token in the TokenField by either
|
||||||
|
* its row id (if it has one) or its display text.
|
||||||
|
*
|
||||||
|
* TODO: Look into removing `label` from IToken altogether, replacing it with a solution
|
||||||
|
* similar to getItemText() from IAutocompleteOptions.
|
||||||
|
*/
|
||||||
|
public label: string = typeof this.rowId === 'number' ? String(this.rowId) : this.text;
|
||||||
|
public cleanText: string = this.text.trim().toLowerCase();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public text: string,
|
||||||
|
public rowId: number | 'new' | 'invalid',
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A ReferenceListEditor offers an autocomplete of choices from the referenced table.
|
* A ReferenceListEditor offers an autocomplete of choices from the referenced table.
|
||||||
*/
|
*/
|
||||||
export class ReferenceListEditor extends NTextEditor {
|
export class ReferenceListEditor extends NewBaseEditor {
|
||||||
public getCellValue(): CellValue {
|
protected cellEditorDiv: HTMLElement;
|
||||||
try {
|
protected commandGroup: any;
|
||||||
return ['L', ...JSON.parse(this.textInput.value)];
|
|
||||||
} catch {
|
private _tableData: TableData;
|
||||||
return null; // This is the default value for a reference list column.
|
private _formatter: BaseFormatter;
|
||||||
|
private _enableAddNew: boolean;
|
||||||
|
private _showAddNew: boolean = false;
|
||||||
|
private _visibleCol: string;
|
||||||
|
private _tokenField: TokenField<ReferenceItem>;
|
||||||
|
private _textInput: HTMLInputElement;
|
||||||
|
private _dom: HTMLElement;
|
||||||
|
private _editorPlacement: EditorPlacement;
|
||||||
|
private _contentSizer: HTMLElement; // Invisible element to size the editor with all the tokens
|
||||||
|
private _inputSizer: HTMLElement; // Part of _contentSizer to size the text input
|
||||||
|
private _alignment: string;
|
||||||
|
|
||||||
|
constructor(options: Options) {
|
||||||
|
super(options);
|
||||||
|
|
||||||
|
const field = options.field;
|
||||||
|
|
||||||
|
// Get the table ID to which the reference list points.
|
||||||
|
const refTableId = getReferencedTableId(field.column().type());
|
||||||
|
if (!refTableId) {
|
||||||
|
throw new Error("ReferenceListEditor used for non-ReferenceList column");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const docData = options.gristDoc.docData;
|
||||||
|
const tableData = docData.getTable(refTableId);
|
||||||
|
if (!tableData) {
|
||||||
|
throw new Error("ReferenceListEditor: invalid referenced table");
|
||||||
|
}
|
||||||
|
this._tableData = tableData;
|
||||||
|
|
||||||
|
// Construct the formatter for the displayed values using the options from the target column.
|
||||||
|
this._formatter = field.createVisibleColFormatter();
|
||||||
|
|
||||||
|
const vcol = field.visibleColModel();
|
||||||
|
// Whether we should enable the "Add New" entry to allow adding new items to the target table.
|
||||||
|
this._enableAddNew = vcol && !vcol.isRealFormula() && !!vcol.colId();
|
||||||
|
|
||||||
|
this._visibleCol = vcol.colId() || 'id';
|
||||||
|
|
||||||
|
const acOptions: IAutocompleteOptions<ReferenceItem> = {
|
||||||
|
menuCssClass: `${menuCssClass} ${cssRefList.className}`,
|
||||||
|
search: this._doSearch.bind(this),
|
||||||
|
renderItem: this._renderItem.bind(this),
|
||||||
|
getItemText: (item) => item.text,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.commandGroup = this.autoDispose(createGroup(options.commands, null, true));
|
||||||
|
this._alignment = options.field.widgetOptionsJson.peek().alignment || 'left';
|
||||||
|
|
||||||
|
// If starting to edit by typing in a string, ignore previous tokens.
|
||||||
|
const cellValue = decodeObject(options.cellValue);
|
||||||
|
const startRowIds: unknown[] = options.editValue || !Array.isArray(cellValue) ? [] : cellValue;
|
||||||
|
|
||||||
|
// If referenced table hasn't loaded yet, hold off on initializing tokens.
|
||||||
|
const needReload = (options.editValue === undefined && !tableData.isLoaded);
|
||||||
|
const startTokens = needReload ?
|
||||||
|
[] : startRowIds.map(id => new ReferenceItem(this._idToText(id), typeof id === 'number' ? id : 'invalid'));
|
||||||
|
|
||||||
|
this._tokenField = TokenField.ctor<ReferenceItem>().create(this, {
|
||||||
|
initialValue: startTokens,
|
||||||
|
renderToken: item => {
|
||||||
|
const isBlankReference = item.cleanText === '';
|
||||||
|
return [
|
||||||
|
isBlankReference ? '[Blank]' : item.text,
|
||||||
|
cssToken.cls('-blank', isBlankReference),
|
||||||
|
cssInvalidToken.cls('-invalid', item.rowId === 'invalid')
|
||||||
|
];
|
||||||
|
},
|
||||||
|
createToken: text => new ReferenceItem(text, 'invalid'),
|
||||||
|
acOptions,
|
||||||
|
openAutocompleteOnFocus: true,
|
||||||
|
readonly : options.readonly,
|
||||||
|
trimLabels: true,
|
||||||
|
styles: {cssTokenField, cssToken, cssDeleteButton, cssDeleteIcon},
|
||||||
|
});
|
||||||
|
|
||||||
|
this._dom = dom('div.default_editor',
|
||||||
|
dom.cls("readonly_editor", options.readonly),
|
||||||
|
dom.cls(cssReadonlyStyle.className, options.readonly),
|
||||||
|
this.cellEditorDiv = cssCellEditor(testId('widget-text-editor'),
|
||||||
|
this._contentSizer = cssContentSizer(),
|
||||||
|
elem => this._tokenField.attach(elem),
|
||||||
|
),
|
||||||
|
createMobileButtons(options.commands),
|
||||||
|
);
|
||||||
|
|
||||||
|
this._textInput = this._tokenField.getTextInput();
|
||||||
|
dom.update(this._tokenField.getRootElem(),
|
||||||
|
dom.style('justify-content', this._alignment),
|
||||||
|
);
|
||||||
|
dom.update(this._tokenField.getHiddenInput(),
|
||||||
|
this.commandGroup.attach(),
|
||||||
|
);
|
||||||
|
dom.update(this._textInput,
|
||||||
|
// Resize the editor whenever user types into the textbox.
|
||||||
|
dom.on('input', () => this.resizeInput(true)),
|
||||||
|
dom.prop('value', options.editValue || ''),
|
||||||
|
this.commandGroup.attach(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The referenced table has probably already been fetched (because there must already be a
|
||||||
|
// Reference widget instantiated), but it's better to avoid this assumption.
|
||||||
|
docData.fetchTable(refTableId).then(() => {
|
||||||
|
if (this.isDisposed()) { return; }
|
||||||
|
if (needReload) {
|
||||||
|
this._tokenField.setTokens(
|
||||||
|
startRowIds.map(id => new ReferenceItem(this._idToText(id), typeof id === 'number' ? id : 'invalid'))
|
||||||
|
);
|
||||||
|
this.resizeInput();
|
||||||
|
}
|
||||||
|
const autocomplete = this._tokenField.getAutocomplete();
|
||||||
|
if (autocomplete) {
|
||||||
|
autocomplete.search();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(reportError);
|
||||||
|
}
|
||||||
|
|
||||||
|
public attach(cellElem: Element): void {
|
||||||
|
// Attach the editor dom to page DOM.
|
||||||
|
this._editorPlacement = EditorPlacement.create(this, this._dom, cellElem, {margins: getButtonMargins()});
|
||||||
|
|
||||||
|
// Reposition the editor if needed for external reasons (in practice, window resize).
|
||||||
|
this.autoDispose(this._editorPlacement.onReposition.addListener(() => this.resizeInput()));
|
||||||
|
|
||||||
|
// Update the sizing whenever the tokens change. Delay it till next tick to give a chance for
|
||||||
|
// DOM updates that happen around tokenObs changes, to complete.
|
||||||
|
this.autoDispose(this._tokenField.tokensObs.addListener(() =>
|
||||||
|
Promise.resolve().then(() => this.resizeInput())));
|
||||||
|
|
||||||
|
this.setSizerLimits();
|
||||||
|
|
||||||
|
// Once the editor is attached to DOM, resize it to content, focus, and set cursor.
|
||||||
|
this.resizeInput();
|
||||||
|
this._textInput.focus();
|
||||||
|
const pos = Math.min(this.options.cursorPos, this._textInput.value.length);
|
||||||
|
this._textInput.setSelectionRange(pos, pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDom(): HTMLElement {
|
||||||
|
return this._dom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCellValue(): CellValue {
|
||||||
|
const rowIds = this._tokenField.tokensObs.get().map(t => typeof t.rowId === 'number' ? t.rowId : t.text);
|
||||||
|
return encodeObject(rowIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTextValue(): string {
|
||||||
|
const rowIds = this._tokenField.tokensObs.get().map(t => typeof t.rowId === 'number' ? String(t.rowId) : t.text);
|
||||||
|
return csvEncodeRow(rowIds, {prettier: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCursorPos(): number {
|
||||||
|
return this._textInput.selectionStart || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If any 'new' item are saved, add them to the referenced table first.
|
||||||
|
*/
|
||||||
|
public async prepForSave() {
|
||||||
|
const tokens = this._tokenField.tokensObs.get();
|
||||||
|
const newValues = tokens.filter(t => t.rowId === 'new');
|
||||||
|
if (newValues.length === 0) { return; }
|
||||||
|
|
||||||
|
// Add the new items to the referenced table.
|
||||||
|
const colInfo = {[this._visibleCol]: newValues.map(t => t.text)};
|
||||||
|
const rowIds = await this._tableData.sendTableAction(
|
||||||
|
["BulkAddRecord", new Array(newValues.length).fill(null), colInfo]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the TokenField tokens with the returned row ids.
|
||||||
|
let i = 0;
|
||||||
|
const newTokens = tokens.map(t => {
|
||||||
|
return t.rowId === 'new' ? new ReferenceItem(t.text, rowIds[i++]) : t;
|
||||||
|
});
|
||||||
|
this._tokenField.setTokens(newTokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setSizerLimits() {
|
||||||
|
// Set the max width of the sizer to the max we could possibly grow to, so that it knows to wrap
|
||||||
|
// once we reach it.
|
||||||
|
const rootElem = this._tokenField.getRootElem();
|
||||||
|
const maxSize = this._editorPlacement.calcSizeWithPadding(rootElem,
|
||||||
|
{width: Infinity, height: Infinity}, {calcOnly: true});
|
||||||
|
this._contentSizer.style.maxWidth = Math.ceil(maxSize.width) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper which resizes the token-field to match its content.
|
||||||
|
*/
|
||||||
|
protected resizeInput(onlyTextInput: boolean = false) {
|
||||||
|
if (this.isDisposed()) { return; }
|
||||||
|
|
||||||
|
const rootElem = this._tokenField.getRootElem();
|
||||||
|
|
||||||
|
// To size the content, we need both the tokens and the text typed into _textInput. We
|
||||||
|
// re-create the tokens using cloneNode(true) copies all styles and properties, but not event
|
||||||
|
// handlers. We can skip this step when we know that only _textInput changed.
|
||||||
|
if (!onlyTextInput || !this._inputSizer) {
|
||||||
|
this._contentSizer.innerHTML = '';
|
||||||
|
|
||||||
|
dom.update(this._contentSizer,
|
||||||
|
dom.update(rootElem.cloneNode(true) as HTMLElement,
|
||||||
|
dom.style('width', ''),
|
||||||
|
dom.style('height', ''),
|
||||||
|
this._inputSizer = cssInputSizer(),
|
||||||
|
|
||||||
|
// Remove the testId('tokenfield') from the cloned element, to simplify tests (so that
|
||||||
|
// selecting .test-tokenfield only returns the actual visible tokenfield container).
|
||||||
|
dom.cls('test-tokenfield', false),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a separate sizer to size _textInput to the text inside it.
|
||||||
|
// \u200B is a zero-width space; so the sizer will have height even when empty.
|
||||||
|
this._inputSizer.textContent = this._textInput.value + '\u200B';
|
||||||
|
const rect = this._contentSizer.getBoundingClientRect();
|
||||||
|
|
||||||
|
const size = this._editorPlacement.calcSizeWithPadding(rootElem, rect);
|
||||||
|
rootElem.style.width = size.width + 'px';
|
||||||
|
rootElem.style.height = size.height + 'px';
|
||||||
|
this._textInput.style.width = this._inputSizer.getBoundingClientRect().width + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the search text does not match anything exactly, adds 'new' item to it.
|
||||||
|
*
|
||||||
|
* Also see: prepForSave.
|
||||||
|
*/
|
||||||
|
private async _doSearch(text: string): Promise<ACResults<ReferenceItem>> {
|
||||||
|
const acIndex = this._tableData.columnACIndexes.getColACIndex(this._visibleCol, this._formatter);
|
||||||
|
const {items, selectIndex, highlightFunc} = acIndex.search(text);
|
||||||
|
const result: ACResults<ReferenceItem> = {
|
||||||
|
selectIndex,
|
||||||
|
highlightFunc,
|
||||||
|
items: items.map(i => new ReferenceItem(i.text, i.rowId))
|
||||||
|
};
|
||||||
|
|
||||||
|
this._showAddNew = false;
|
||||||
|
if (!this._enableAddNew || !text) { return result; }
|
||||||
|
|
||||||
|
const cleanText = text.trim().toLowerCase();
|
||||||
|
if (result.items.find((item) => item.cleanText === cleanText)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.items.push(new ReferenceItem(text, 'new'));
|
||||||
|
this._showAddNew = true;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _idToText(value: unknown) {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return this._formatter.formatAny(this._tableData.getValue(value, this._visibleCol));
|
||||||
|
}
|
||||||
|
return String(value || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private _renderItem(item: ReferenceItem, highlightFunc: HighlightFunc) {
|
||||||
|
return renderACItem(
|
||||||
|
item.text,
|
||||||
|
highlightFunc,
|
||||||
|
item.rowId === 'new',
|
||||||
|
this._showAddNew
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cssCellEditor = styled('div', `
|
||||||
|
background-color: white;
|
||||||
|
font-family: var(--grist-font-family-data);
|
||||||
|
font-size: var(--grist-medium-font-size);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssTokenField = styled(tokenFieldStyles.cssTokenField, `
|
||||||
|
border: none;
|
||||||
|
align-items: start;
|
||||||
|
align-content: start;
|
||||||
|
padding: 0 3px;
|
||||||
|
height: min-content;
|
||||||
|
min-height: 22px;
|
||||||
|
color: black;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssToken = styled(tokenFieldStyles.cssToken, `
|
||||||
|
padding: 1px 4px;
|
||||||
|
margin: 2px;
|
||||||
|
line-height: 16px;
|
||||||
|
white-space: pre;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
box-shadow: inset 0 0 0 1px ${colors.lightGreen};
|
||||||
|
}
|
||||||
|
|
||||||
|
&-blank {
|
||||||
|
color: ${colors.slate};
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssDeleteButton = styled(tokenFieldStyles.cssDeleteButton, `
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -6px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background-color: ${colors.dark};
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.${cssToken.className}:hover & {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.${cssTokenField.className}.token-dragactive & {
|
||||||
|
cursor: unset;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssDeleteIcon = styled(tokenFieldStyles.cssDeleteIcon, `
|
||||||
|
--icon-color: ${colors.light};
|
||||||
|
&:hover {
|
||||||
|
--icon-color: ${colors.darkGrey};
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssContentSizer = styled('div', `
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: -100px;
|
||||||
|
border: none;
|
||||||
|
visibility: hidden;
|
||||||
|
overflow: visible;
|
||||||
|
width: max-content;
|
||||||
|
|
||||||
|
& .${tokenFieldStyles.cssInputWrapper.className} {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssInputSizer = styled('div', `
|
||||||
|
flex: auto;
|
||||||
|
min-width: 24px;
|
||||||
|
margin: 3px 2px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssReadonlyStyle = styled('div', `
|
||||||
|
padding-left: 16px;
|
||||||
|
background: white;
|
||||||
|
`);
|
||||||
|
@ -237,21 +237,21 @@ var typeDefs = {
|
|||||||
},
|
},
|
||||||
default: 'Reference'
|
default: 'Reference'
|
||||||
},
|
},
|
||||||
// RefList: {
|
RefList: {
|
||||||
// label: 'Reference List',
|
label: 'Reference List',
|
||||||
// icon: 'FieldReference',
|
icon: 'FieldReference',
|
||||||
// widgets: {
|
widgets: {
|
||||||
// Reference: {
|
Reference: {
|
||||||
// cons: 'ReferenceList',
|
cons: 'ReferenceList',
|
||||||
// editCons: 'ReferenceListEditor',
|
editCons: 'ReferenceListEditor',
|
||||||
// icon: 'FieldReference',
|
icon: 'FieldReference',
|
||||||
// options: {
|
options: {
|
||||||
// alignment: 'left'
|
alignment: 'left'
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
// },
|
},
|
||||||
// default: 'Reference'
|
default: 'Reference'
|
||||||
// },
|
},
|
||||||
Attachments: {
|
Attachments: {
|
||||||
label: 'Attachment',
|
label: 'Attachment',
|
||||||
icon: 'FieldAttachment',
|
icon: 'FieldAttachment',
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { CellValue } from "app/common/DocActions";
|
import { CellValue } from "app/common/DocActions";
|
||||||
import { FilterState, makeFilterState } from "app/common/FilterState";
|
import { FilterState, makeFilterState } from "app/common/FilterState";
|
||||||
import { decodeObject } from "app/plugin/objtypes";
|
import { decodeObject } from "app/plugin/objtypes";
|
||||||
import { isList } from "./gristTypes";
|
import { isList, isRefListType } from "./gristTypes";
|
||||||
|
|
||||||
export type ColumnFilterFunc = (value: CellValue) => boolean;
|
export type ColumnFilterFunc = (value: CellValue) => boolean;
|
||||||
|
|
||||||
@ -13,7 +13,7 @@ export function makeFilterFunc({ include, values }: FilterState,
|
|||||||
// For example, a TypeError in the formula column and the string '["E","TypeError"]' would be seen as the same.
|
// For example, a TypeError in the formula column and the string '["E","TypeError"]' would be seen as the same.
|
||||||
// TODO: This narrow corner case seems acceptable for now, but may be worth revisiting.
|
// TODO: This narrow corner case seems acceptable for now, but may be worth revisiting.
|
||||||
return (val: CellValue) => {
|
return (val: CellValue) => {
|
||||||
if (isList(val) && columnType === 'ChoiceList') {
|
if (isList(val) && (columnType === 'ChoiceList' || isRefListType(String(columnType)))) {
|
||||||
const list = decodeObject(val) as unknown[];
|
const list = decodeObject(val) as unknown[];
|
||||||
return list.some(item => values.has(item as any) === include);
|
return list.some(item => values.has(item as any) === include);
|
||||||
}
|
}
|
||||||
|
@ -329,6 +329,10 @@ export function getReferencedTableId(type: string) {
|
|||||||
return removePrefix(type, "Ref:") || removePrefix(type, "RefList:");
|
return removePrefix(type, "Ref:") || removePrefix(type, "RefList:");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isFullReferencingType(type: string) {
|
export function isRefListType(type: string) {
|
||||||
return type.startsWith('Ref:') || type.startsWith('RefList:');
|
return type.startsWith('RefList:');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFullReferencingType(type: string) {
|
||||||
|
return type.startsWith('Ref:') || isRefListType(type);
|
||||||
}
|
}
|
||||||
|
@ -131,7 +131,7 @@ class ReferenceRelation(Relation):
|
|||||||
self.inverse_map.setdefault(target_row_id, set()).add(referring_row_id)
|
self.inverse_map.setdefault(target_row_id, set()).add(referring_row_id)
|
||||||
|
|
||||||
def remove_reference(self, referring_row_id, target_row_id):
|
def remove_reference(self, referring_row_id, target_row_id):
|
||||||
self.inverse_map[target_row_id].remove(referring_row_id)
|
self.inverse_map[target_row_id].discard(referring_row_id)
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
self.inverse_map.clear()
|
self.inverse_map.clear()
|
||||||
|
@ -166,9 +166,15 @@ class Text(BaseColumnType):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def typeConvert(cls, value):
|
def typeConvert(cls, value):
|
||||||
|
if value is None:
|
||||||
# When converting NULLs (that typically show up as a plain empty cell for Numeric or Date
|
# When converting NULLs (that typically show up as a plain empty cell for Numeric or Date
|
||||||
# columns) to Text, it makes more sense to end up with a plain blank text cell.
|
# columns) to Text, it makes more sense to end up with a plain blank text cell.
|
||||||
return '' if value is None else value
|
return ''
|
||||||
|
elif isinstance(value, bool):
|
||||||
|
# Normalize True/False to true/false (Toggle columns use true/false).
|
||||||
|
return str(value).lower()
|
||||||
|
else:
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class Blob(BaseColumnType):
|
class Blob(BaseColumnType):
|
||||||
@ -350,6 +356,8 @@ class ChoiceList(BaseColumnType):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def typeConvert(cls, value):
|
def typeConvert(cls, value):
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
if isinstance(value, six.string_types) and not value.startswith('['):
|
if isinstance(value, six.string_types) and not value.startswith('['):
|
||||||
# Try to parse as CSV. If this doesn't work, we'll still try usual conversions later.
|
# Try to parse as CSV. If this doesn't work, we'll still try usual conversions later.
|
||||||
try:
|
try:
|
||||||
@ -357,6 +365,8 @@ class ChoiceList(BaseColumnType):
|
|||||||
return tuple(t.strip() for t in tags if t.strip())
|
return tuple(t.strip() for t in tags if t.strip())
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if not isinstance(value, (tuple, list)):
|
||||||
|
value = [Choice.typeConvert(value)]
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -434,7 +444,7 @@ class Reference(Id):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def typeConvert(cls, value, ref_table, visible_col=None): # pylint: disable=arguments-differ
|
def typeConvert(cls, value, ref_table, visible_col=None): # pylint: disable=arguments-differ
|
||||||
if ref_table and visible_col:
|
if value and ref_table and visible_col:
|
||||||
return ref_table.lookupOne(**{visible_col: value}) or six.text_type(value)
|
return ref_table.lookupOne(**{visible_col: value}) or six.text_type(value)
|
||||||
else:
|
else:
|
||||||
return value
|
return value
|
||||||
@ -478,7 +488,7 @@ class ReferenceList(BaseColumnType):
|
|||||||
def typeConvert(cls, value, ref_table, visible_col=None): # noqa # pylint: disable=arguments-differ
|
def typeConvert(cls, value, ref_table, visible_col=None): # noqa # pylint: disable=arguments-differ
|
||||||
# TODO this is based on Reference.typeConvert.
|
# TODO this is based on Reference.typeConvert.
|
||||||
# It doesn't make much sense as a conversion but I don't know what would
|
# It doesn't make much sense as a conversion but I don't know what would
|
||||||
if ref_table and visible_col:
|
if value and ref_table and visible_col:
|
||||||
return ref_table.lookupRecords(**{visible_col: value}) or six.text_type(value)
|
return ref_table.lookupRecords(**{visible_col: value}) or six.text_type(value)
|
||||||
else:
|
else:
|
||||||
return value
|
return value
|
||||||
|
Loading…
Reference in New Issue
Block a user