2020-10-02 15:10:00 +00:00
|
|
|
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';
|
2021-08-12 18:06:40 +00:00
|
|
|
import {getReferencedTableId} from 'app/common/gristTypes';
|
|
|
|
import {undef} from 'app/common/gutil';
|
2020-10-02 15:10:00 +00:00
|
|
|
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.
|
2021-08-12 18:06:40 +00:00
|
|
|
const refTableId = getReferencedTableId(field.column().type());
|
2020-10-02 15:10:00 +00:00
|
|
|
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();
|
2021-07-15 15:50:28 +00:00
|
|
|
this._enableAddNew = vcol && !vcol.isRealFormula() && !!vcol.colId();
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
this._visibleCol = vcol.colId() || 'id';
|
|
|
|
|
|
|
|
// Decorate the editor to look like a reference column value (with a "link" icon).
|
2021-06-17 16:41:07 +00:00
|
|
|
// 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'));
|
|
|
|
}
|
|
|
|
|
2021-05-17 14:05:49 +00:00
|
|
|
this.textInput.value = undef(options.state, options.editValue, this._idToText(options.cellValue));
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
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 === '') {
|
2021-05-17 14:05:49 +00:00
|
|
|
this.textInput.value = undef(options.state, options.editValue, this._idToText(options.cellValue));
|
2020-10-02 15:10:00 +00:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
2021-02-04 03:17:17 +00:00
|
|
|
public attach(cellElem: Element): void {
|
|
|
|
super.attach(cellElem);
|
2021-06-17 16:41:07 +00:00
|
|
|
// don't create autocomplete for readonly mode
|
|
|
|
if (this.options.readonly) { return; }
|
2020-10-02 15:10:00 +00:00
|
|
|
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 {
|
2021-07-08 16:18:46 +00:00
|
|
|
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;
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private _idToText(value: CellValue) {
|
|
|
|
if (typeof value === 'number') {
|
|
|
|
return this._formatter.formatAny(this._tableData.getValue(value, this._visibleCol));
|
|
|
|
}
|
|
|
|
return String(value || '');
|
|
|
|
}
|
|
|
|
|
2021-07-15 15:50:28 +00:00
|
|
|
/**
|
|
|
|
* If the search text does not match anything exactly, adds 'new' item to it.
|
|
|
|
*
|
|
|
|
* Also see: prepForSave.
|
|
|
|
*/
|
2020-10-02 15:10:00 +00:00
|
|
|
private async _doSearch(text: string): Promise<ACResults<ICellItem>> {
|
|
|
|
const acIndex = this._tableData.columnACIndexes.getColACIndex(this._visibleCol, this._formatter);
|
|
|
|
const result = acIndex.search(text);
|
2021-07-15 15:50:28 +00:00
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
this._showAddNew = false;
|
2021-07-15 15:50:28 +00:00
|
|
|
if (!this._enableAddNew || !text) { return result; }
|
|
|
|
|
|
|
|
const cleanText = text.trim().toLowerCase();
|
|
|
|
if (result.items.find((item) => item.cleanText === cleanText)) {
|
|
|
|
return result;
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
2021-07-15 15:50:28 +00:00
|
|
|
|
|
|
|
result.items.push({rowId: 'new', text, cleanText});
|
|
|
|
this._showAddNew = true;
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
|
|
|
private _renderItem(item: ICellItem, highlightFunc: HighlightFunc) {
|
2021-05-12 14:34:49 +00:00
|
|
|
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'),
|
2020-10-02 15:10:00 +00:00
|
|
|
);
|
|
|
|
}
|
2021-05-12 14:34:49 +00:00
|
|
|
return cssRefItem(cssRefItem.cls('-with-new', withSpaceForNew),
|
|
|
|
buildHighlightedDom(text, highlightFunc, cssMatchText),
|
|
|
|
testId('ref-editor-item'),
|
|
|
|
);
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
function nocaseEqual(a: string, b: string) {
|
|
|
|
return a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
|
|
}
|
|
|
|
|
|
|
|
const cssRefEditor = styled('div', `
|
|
|
|
& > .celleditor_text_editor, & > .celleditor_content_measure {
|
2021-02-04 03:17:17 +00:00
|
|
|
padding-left: 18px;
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
`);
|
|
|
|
|
2021-08-12 18:06:40 +00:00
|
|
|
// Set z-index to be higher than the 1000 set for .cell_editor.
|
|
|
|
export const cssRefList = styled('div', `
|
|
|
|
z-index: 1001;
|
2020-10-02 15:10:00 +00:00
|
|
|
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};
|
2021-07-15 15:50:28 +00:00
|
|
|
white-space: pre;
|
2020-10-02 15:10:00 +00:00
|
|
|
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};
|
|
|
|
}
|
|
|
|
`);
|
|
|
|
|
2021-08-12 18:06:40 +00:00
|
|
|
export const cssPlusButton = styled('div', `
|
2020-10-02 15:10:00 +00:00
|
|
|
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};
|
|
|
|
}
|
|
|
|
`);
|
|
|
|
|
2021-08-12 18:06:40 +00:00
|
|
|
export const cssPlusIcon = styled(icon, `
|
2020-10-02 15:10:00 +00:00
|
|
|
background-color: ${colors.light};
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssRefEditIcon = styled(icon, `
|
|
|
|
background-color: ${colors.slate};
|
|
|
|
position: absolute;
|
|
|
|
top: 0;
|
|
|
|
left: 0;
|
2021-02-04 03:17:17 +00:00
|
|
|
margin: 3px 3px 0 3px;
|
2020-10-02 15:10:00 +00:00
|
|
|
`);
|
|
|
|
|
|
|
|
const cssMatchText = styled('span', `
|
|
|
|
color: ${colors.lightGreen};
|
|
|
|
.selected > & {
|
|
|
|
color: ${colors.lighterGreen};
|
|
|
|
}
|
|
|
|
`);
|