gristlabs_grist-core/app/client/widgets/ReferenceEditor.ts
Alex Hall d63da496a8 (core) Value parsing for refs, parsing data entry for numbers
Summary:
Handle reference columns in ViewFieldRec.valueParser.

Extracted code for reuse from ReferenceEditor to look up values in the visible column. While I was at it, also extracted a bit of common code from ReferenceEditor and ReferenceListEditor into a new class ReferenceUtils. More refactoring could be done in this area but it's out of scope.

Changed NTextEditor to use field.valueParser, which affects numeric and reference fields. In particular this means numbers are parsed on data entry, it doesn't change anything for references.

Test Plan:
Added more CopyPaste testing to test references.

Tested entering slightly formatted numbers in NumberFormatting.

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D3094
2021-11-01 19:31:52 +02:00

229 lines
7.1 KiB
TypeScript

import { ACResults, buildHighlightedDom, HighlightFunc } from 'app/client/lib/ACIndex';
import { Autocomplete } from 'app/client/lib/autocomplete';
import { ICellItem } from 'app/client/models/ColumnACIndexes';
import { reportError } from 'app/client/models/errors';
import { colors, testId, vars } from 'app/client/ui2018/cssVars';
import { icon } from 'app/client/ui2018/icons';
import { menuCssClass } from 'app/client/ui2018/menus';
import { Options } from 'app/client/widgets/NewBaseEditor';
import { NTextEditor } from 'app/client/widgets/NTextEditor';
import { nocaseEqual, ReferenceUtils } from 'app/client/lib/ReferenceUtils';
import { undef } from 'app/common/gutil';
import { styled } from 'grainjs';
/**
* A ReferenceEditor offers an autocomplete of choices from the referenced table.
*/
export class ReferenceEditor extends NTextEditor {
private _enableAddNew: boolean;
private _showAddNew: boolean = false;
private _autocomplete?: Autocomplete<ICellItem>;
private _utils: ReferenceUtils;
constructor(options: Options) {
super(options);
const docData = options.gristDoc.docData;
this._utils = new ReferenceUtils(options.field, docData);
const vcol = this._utils.visibleColModel;
this._enableAddNew = vcol && !vcol.isRealFormula() && !!vcol.colId();
// Decorate the editor to look like a reference column value (with a "link" icon).
// But not on readonly mode - here we will reuse default decoration
if (!options.readonly) {
this.cellEditorDiv.classList.add(cssRefEditor.className);
this.cellEditorDiv.appendChild(cssRefEditIcon('FieldReference'));
}
this.textInput.value = undef(options.state, options.editValue, this._idToText());
const needReload = (options.editValue === undefined && !this._utils.tableData.isLoaded);
// 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(this._utils.refTableId).then(() => {
if (this.isDisposed()) { return; }
if (needReload && this.textInput.value === '') {
this.textInput.value = undef(options.state, options.editValue, this._idToText());
this.resizeInput();
}
if (this._autocomplete) {
if (options.editValue === undefined) {
this._autocomplete.search((items) => items.findIndex((item) => item.rowId === options.cellValue));
} else {
this._autocomplete.search();
}
}
})
.catch(reportError);
}
public attach(cellElem: Element): void {
super.attach(cellElem);
// don't create autocomplete for readonly mode
if (this.options.readonly) { return; }
this._autocomplete = this.autoDispose(new Autocomplete<ICellItem>(this.textInput, {
menuCssClass: menuCssClass + ' ' + cssRefList.className,
search: this._doSearch.bind(this),
renderItem: this._renderItem.bind(this),
getItemText: (item) => item.text,
onClick: () => this.options.commands.fieldEditSaveHere(),
}));
}
/**
* If the 'new' item is saved, add it to the referenced table first. See _buildSourceList
*/
public async prepForSave() {
const selectedItem = this._autocomplete && this._autocomplete.getSelectedItem();
if (selectedItem &&
selectedItem.rowId === 'new' &&
selectedItem.text === this.textInput.value) {
const colInfo = {[this._utils.visibleColId]: this.textInput.value};
selectedItem.rowId = await this._utils.tableData.sendTableAction(["AddRecord", null, colInfo]);
}
}
public getCellValue() {
const selectedItem = this._autocomplete && this._autocomplete.getSelectedItem();
if (selectedItem) {
// Selected from the autocomplete dropdown; so we know the *value* (i.e. rowId).
return selectedItem.rowId;
} else if (nocaseEqual(this.textInput.value, this._idToText())) {
// Unchanged from what's already in the cell.
return this.options.cellValue;
}
return super.getCellValue();
}
private _idToText() {
return this._utils.idToText(this.options.cellValue);
}
/**
* If the search text does not match anything exactly, adds 'new' item to it.
*
* Also see: prepForSave.
*/
private async _doSearch(text: string): Promise<ACResults<ICellItem>> {
const result = this._utils.autocompleteSearch(text);
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({rowId: 'new', text, cleanText});
this._showAddNew = true;
return result;
}
private _renderItem(item: ICellItem, highlightFunc: HighlightFunc) {
return renderACItem(item.text, highlightFunc, item.rowId === 'new', this._showAddNew);
}
}
export function renderACItem(text: string, highlightFunc: HighlightFunc, isAddNew: boolean, withSpaceForNew: boolean) {
if (isAddNew) {
return cssRefItem(cssRefItem.cls('-new'),
cssPlusButton(cssPlusIcon('Plus')), text,
testId('ref-editor-item'), testId('ref-editor-new-item'),
);
}
return cssRefItem(cssRefItem.cls('-with-new', withSpaceForNew),
buildHighlightedDom(text, highlightFunc, cssMatchText),
testId('ref-editor-item'),
);
}
const cssRefEditor = styled('div', `
& > .celleditor_text_editor, & > .celleditor_content_measure {
padding-left: 18px;
}
`);
// Set z-index to be higher than the 1000 set for .cell_editor.
export const cssRefList = styled('div', `
z-index: 1001;
overflow-y: auto;
padding: 8px 0 0 0;
--weaseljs-menu-item-padding: 8px 16px;
`);
// We need to now the height of the sticky "+" element.
const addNewHeight = '37px';
const cssRefItem = styled('li', `
display: block;
font-family: ${vars.fontFamily};
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
outline: none;
padding: var(--weaseljs-menu-item-padding, 8px 24px);
cursor: pointer;
&.selected {
background-color: var(--weaseljs-selected-background-color, #5AC09C);
color: var(--weaseljs-selected-color, white);
}
&-with-new {
scroll-margin-bottom: ${addNewHeight};
}
&-new {
color: ${colors.slate};
position: sticky;
bottom: 0px;
height: ${addNewHeight};
background-color: white;
border-top: 1px solid ${colors.mediumGrey};
scroll-margin-bottom: initial;
}
&-new.selected {
color: ${colors.lightGrey};
}
`);
export const cssPlusButton = styled('div', `
display: inline-block;
width: 20px;
height: 20px;
border-radius: 20px;
margin-right: 8px;
text-align: center;
background-color: ${colors.lightGreen};
color: ${colors.light};
.selected > & {
background-color: ${colors.darkGreen};
}
`);
export const cssPlusIcon = styled(icon, `
background-color: ${colors.light};
`);
const cssRefEditIcon = styled(icon, `
background-color: ${colors.slate};
position: absolute;
top: 0;
left: 0;
margin: 3px 3px 0 3px;
`);
const cssMatchText = styled('span', `
color: ${colors.lightGreen};
.selected > & {
color: ${colors.lighterGreen};
}
`);