mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
2f900f68f8
Summary: Includes overhauled choice configuration UI for choice and choice list columns based on the TokenField library. Features include rich copy and paste support, keyboard shortcuts for token manipulation, and drag-and-drop support for arrangement. Configured choice colors are visible throughout the application, such as in the autocomplete window for both choice and choice list cells, and in table cells directly. Choice cells in particular are now styled closer to choice list cells, and render their contents as colored tokens. Choice cells now also use the improved autocomplete component that choice lists use, with some room for future improvement (e.g. allowing new choice items to be added inline like in choice list's autocomplete). Also includes a minor fix for choice list cells where right align was not working. Test Plan: Browser tests updated. Reviewers: jarek, dsagal Reviewed By: jarek, dsagal Subscribers: jarek Differential Revision: https://phab.getgrist.com/D2890
263 lines
8.7 KiB
TypeScript
263 lines
8.7 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 {TableData} from 'app/client/models/TableData';
|
|
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 {CellValue} from 'app/common/DocActions';
|
|
import {removePrefix, undef} from 'app/common/gutil';
|
|
import {BaseFormatter} from 'app/common/ValueFormatter';
|
|
import {styled} from 'grainjs';
|
|
|
|
|
|
/**
|
|
* A ReferenceEditor offers an autocomplete of choices from the referenced table.
|
|
*/
|
|
export class ReferenceEditor extends NTextEditor {
|
|
private _tableData: TableData;
|
|
private _formatter: BaseFormatter;
|
|
private _enableAddNew: boolean;
|
|
private _showAddNew: boolean = false;
|
|
private _visibleCol: string;
|
|
private _autocomplete?: Autocomplete<ICellItem>;
|
|
|
|
constructor(options: Options) {
|
|
super(options);
|
|
|
|
const field = options.field;
|
|
|
|
// Get the table ID to which the reference points.
|
|
const refTableId = removePrefix(field.column().type(), "Ref:");
|
|
if (!refTableId) {
|
|
throw new Error("ReferenceEditor used for non-Reference column");
|
|
}
|
|
|
|
const docData = options.gristDoc.docData;
|
|
const tableData = docData.getTable(refTableId);
|
|
if (!tableData) {
|
|
throw new Error("ReferenceEditor: invalid referenced table");
|
|
}
|
|
this._tableData = tableData;
|
|
|
|
// Construct the formatter for the displayed values using the options from the target column.
|
|
this._formatter = field.createVisibleColFormatter();
|
|
|
|
// Whether we should enable the "Add New" entry to allow adding new items to the target table.
|
|
const vcol = field.visibleColModel();
|
|
this._enableAddNew = vcol && !vcol.isRealFormula();
|
|
|
|
this._visibleCol = vcol.colId() || 'id';
|
|
|
|
// 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(options.cellValue));
|
|
|
|
const needReload = (options.editValue === undefined && !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(refTableId).then(() => {
|
|
if (this.isDisposed()) { return; }
|
|
if (needReload && this.textInput.value === '') {
|
|
this.textInput.value = undef(options.state, options.editValue, this._idToText(options.cellValue));
|
|
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._visibleCol]: this.textInput.value};
|
|
selectedItem.rowId = await this._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(this.options.cellValue))) {
|
|
// Unchanged from what's already in the cell.
|
|
return this.options.cellValue;
|
|
}
|
|
|
|
// Search for textInput's value, or else use the typed value itself (as alttext).
|
|
if (this.textInput.value === '') {
|
|
return 0; // This is the default value for a reference column.
|
|
}
|
|
const searchFunc = (value: any) => nocaseEqual(value, this.textInput.value);
|
|
const matches = this._tableData.columnSearch(this._visibleCol, this._formatter, searchFunc, 1);
|
|
if (matches.length > 0) {
|
|
return matches[0].value;
|
|
} else {
|
|
const value = this.textInput.value;
|
|
if (this._visibleCol === 'id') {
|
|
// If the value is a valid number (non-NaN), save as a numeric rowId; else as text.
|
|
return +value || value;
|
|
}
|
|
return value;
|
|
}
|
|
}
|
|
|
|
private _idToText(value: CellValue) {
|
|
if (typeof value === 'number') {
|
|
return this._formatter.formatAny(this._tableData.getValue(value, this._visibleCol));
|
|
}
|
|
return String(value || '');
|
|
}
|
|
|
|
private async _doSearch(text: string): Promise<ACResults<ICellItem>> {
|
|
const acIndex = this._tableData.columnACIndexes.getColACIndex(this._visibleCol, this._formatter);
|
|
const result = acIndex.search(text);
|
|
// If the search text does not match anything exactly, add 'new' item for it. See also prepForSave.
|
|
this._showAddNew = false;
|
|
if (this._enableAddNew && text) {
|
|
const cleanText = text.trim().toLowerCase();
|
|
if (!result.items.find((item) => item.cleanText === cleanText)) {
|
|
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'),
|
|
);
|
|
}
|
|
|
|
function nocaseEqual(a: string, b: string) {
|
|
return a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
}
|
|
|
|
const cssRefEditor = styled('div', `
|
|
& > .celleditor_text_editor, & > .celleditor_content_measure {
|
|
padding-left: 18px;
|
|
}
|
|
`);
|
|
|
|
export const cssRefList = styled('div', `
|
|
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: nowrap;
|
|
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};
|
|
}
|
|
`);
|