2021-11-01 15:48:08 +00:00
|
|
|
import { createGroup } from 'app/client/components/commands';
|
2022-12-27 18:35:03 +00:00
|
|
|
import { ACItem, ACResults, HighlightFunc, normalizeText } from 'app/client/lib/ACIndex';
|
2021-11-01 15:48:08 +00:00
|
|
|
import { IAutocompleteOptions } from 'app/client/lib/autocomplete';
|
|
|
|
import { IToken, TokenField, tokenFieldStyles } from 'app/client/lib/TokenField';
|
|
|
|
import { reportError } from 'app/client/models/errors';
|
2022-09-06 01:51:57 +00:00
|
|
|
import { colors, testId, theme } from 'app/client/ui2018/cssVars';
|
2021-11-01 15:48:08 +00:00
|
|
|
import { menuCssClass } from 'app/client/ui2018/menus';
|
2022-06-06 17:42:51 +00:00
|
|
|
import { cssChoiceToken } from 'app/client/widgets/ChoiceToken';
|
2021-11-01 15:48:08 +00:00
|
|
|
import { createMobileButtons, getButtonMargins } from 'app/client/widgets/EditorButtons';
|
|
|
|
import { EditorPlacement } from 'app/client/widgets/EditorPlacement';
|
2022-08-08 13:32:50 +00:00
|
|
|
import { FieldOptions, NewBaseEditor } from 'app/client/widgets/NewBaseEditor';
|
2021-11-01 15:48:08 +00:00
|
|
|
import { cssRefList, renderACItem } from 'app/client/widgets/ReferenceEditor';
|
|
|
|
import { ReferenceUtils } from 'app/client/lib/ReferenceUtils';
|
|
|
|
import { csvEncodeRow } from 'app/common/csvFormat';
|
|
|
|
import { CellValue } from "app/common/DocActions";
|
|
|
|
import { decodeObject, encodeObject } from 'app/plugin/objtypes';
|
|
|
|
import { dom, styled } from 'grainjs';
|
2021-07-23 15:29:35 +00:00
|
|
|
|
2021-08-12 18:06:40 +00:00
|
|
|
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;
|
2022-08-17 16:40:46 +00:00
|
|
|
public cleanText: string = normalizeText(this.text);
|
2021-08-12 18:06:40 +00:00
|
|
|
|
|
|
|
constructor(
|
|
|
|
public text: string,
|
|
|
|
public rowId: number | 'new' | 'invalid',
|
|
|
|
) {}
|
|
|
|
}
|
2021-07-23 15:29:35 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* A ReferenceListEditor offers an autocomplete of choices from the referenced table.
|
|
|
|
*/
|
2021-08-12 18:06:40 +00:00
|
|
|
export class ReferenceListEditor extends NewBaseEditor {
|
|
|
|
protected cellEditorDiv: HTMLElement;
|
|
|
|
protected commandGroup: any;
|
|
|
|
|
|
|
|
private _enableAddNew: boolean;
|
|
|
|
private _showAddNew: boolean = false;
|
|
|
|
private _tokenField: TokenField<ReferenceItem>;
|
|
|
|
private _textInput: HTMLInputElement;
|
|
|
|
private _dom: HTMLElement;
|
2022-08-08 13:32:50 +00:00
|
|
|
private _editorPlacement!: EditorPlacement;
|
2021-08-12 18:06:40 +00:00
|
|
|
private _contentSizer: HTMLElement; // Invisible element to size the editor with all the tokens
|
2022-08-08 13:32:50 +00:00
|
|
|
private _inputSizer!: HTMLElement; // Part of _contentSizer to size the text input
|
2021-08-12 18:06:40 +00:00
|
|
|
private _alignment: string;
|
2021-11-01 15:48:08 +00:00
|
|
|
private _utils: ReferenceUtils;
|
2021-08-12 18:06:40 +00:00
|
|
|
|
2022-08-08 13:32:50 +00:00
|
|
|
constructor(protected options: FieldOptions) {
|
2021-08-12 18:06:40 +00:00
|
|
|
super(options);
|
|
|
|
|
2024-05-29 21:55:21 +00:00
|
|
|
const gristDoc = options.gristDoc;
|
|
|
|
this._utils = new ReferenceUtils(options.field, gristDoc);
|
2021-08-12 18:06:40 +00:00
|
|
|
|
2021-11-01 15:48:08 +00:00
|
|
|
const vcol = this._utils.visibleColModel;
|
2024-04-26 20:34:16 +00:00
|
|
|
this._enableAddNew = (
|
|
|
|
vcol &&
|
|
|
|
!vcol.isRealFormula() &&
|
|
|
|
!!vcol.colId() &&
|
|
|
|
!this._utils.hasDropdownCondition
|
|
|
|
);
|
2021-08-12 18:06:40 +00:00
|
|
|
|
|
|
|
const acOptions: IAutocompleteOptions<ReferenceItem> = {
|
2024-04-26 20:34:16 +00:00
|
|
|
menuCssClass: `${menuCssClass} ${cssRefList.className} test-autocomplete`,
|
|
|
|
buildNoItemsMessage: () => this._utils.buildNoItemsMessage(),
|
2021-08-12 18:06:40 +00:00
|
|
|
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);
|
2023-06-01 14:24:15 +00:00
|
|
|
const startRowIds: unknown[] = options.editValue !== undefined || !Array.isArray(cellValue) ? [] : cellValue;
|
2021-08-12 18:06:40 +00:00
|
|
|
|
|
|
|
// If referenced table hasn't loaded yet, hold off on initializing tokens.
|
2021-11-01 15:48:08 +00:00
|
|
|
const needReload = (options.editValue === undefined && !this._utils.tableData.isLoaded);
|
2021-08-12 18:06:40 +00:00
|
|
|
const startTokens = needReload ?
|
2021-11-01 15:48:08 +00:00
|
|
|
[] : startRowIds.map(id => new ReferenceItem(this._utils.idToText(id), typeof id === 'number' ? id : 'invalid'));
|
2021-08-12 18:06:40 +00:00
|
|
|
|
|
|
|
this._tokenField = TokenField.ctor<ReferenceItem>().create(this, {
|
|
|
|
initialValue: startTokens,
|
|
|
|
renderToken: item => {
|
|
|
|
const isBlankReference = item.cleanText === '';
|
|
|
|
return [
|
|
|
|
isBlankReference ? '[Blank]' : item.text,
|
|
|
|
cssToken.cls('-blank', isBlankReference),
|
2022-06-06 17:42:51 +00:00
|
|
|
cssChoiceToken.cls('-invalid', item.rowId === 'invalid')
|
2021-08-12 18:06:40 +00:00
|
|
|
];
|
|
|
|
},
|
|
|
|
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.
|
2024-05-29 21:55:21 +00:00
|
|
|
gristDoc.docData.fetchTable(this._utils.refTableId).then(() => {
|
2021-08-12 18:06:40 +00:00
|
|
|
if (this.isDisposed()) { return; }
|
|
|
|
if (needReload) {
|
|
|
|
this._tokenField.setTokens(
|
2021-11-01 15:48:08 +00:00
|
|
|
startRowIds.map(id => new ReferenceItem(this._utils.idToText(id), typeof id === 'number' ? id : 'invalid'))
|
2021-08-12 18:06:40 +00:00
|
|
|
);
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2021-07-23 15:29:35 +00:00
|
|
|
public getCellValue(): CellValue {
|
2024-04-26 20:34:16 +00:00
|
|
|
const rowIds = this._tokenField.tokensObs.get()
|
|
|
|
.map(token => typeof token.rowId === 'number' ? token.rowId : token.text);
|
2021-08-12 18:06:40 +00:00
|
|
|
return encodeObject(rowIds);
|
|
|
|
}
|
|
|
|
|
|
|
|
public getTextValue(): string {
|
2024-04-26 20:34:16 +00:00
|
|
|
const rowIds = this._tokenField.tokensObs.get()
|
|
|
|
.map(token => typeof token.rowId === 'number' ? String(token.rowId) : token.text);
|
2021-08-12 18:06:40 +00:00
|
|
|
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();
|
2024-04-26 20:34:16 +00:00
|
|
|
const newValues = tokens.filter(({rowId})=> rowId === 'new');
|
2021-08-12 18:06:40 +00:00
|
|
|
if (newValues.length === 0) { return; }
|
|
|
|
|
|
|
|
// Add the new items to the referenced table.
|
2024-04-26 20:34:16 +00:00
|
|
|
const colInfo = {[this._utils.visibleColId]: newValues.map(({text}) => text)};
|
2021-11-01 15:48:08 +00:00
|
|
|
const rowIds = await this._utils.tableData.sendTableAction(
|
2021-08-12 18:06:40 +00:00
|
|
|
["BulkAddRecord", new Array(newValues.length).fill(null), colInfo]
|
|
|
|
);
|
|
|
|
|
|
|
|
// Update the TokenField tokens with the returned row ids.
|
|
|
|
let i = 0;
|
2024-04-26 20:34:16 +00:00
|
|
|
const newTokens = tokens.map(token => {
|
|
|
|
return token.rowId === 'new' ? new ReferenceItem(token.text, rowIds[i++]) : token;
|
2021-08-12 18:06:40 +00:00
|
|
|
});
|
|
|
|
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>> {
|
2024-04-26 20:34:16 +00:00
|
|
|
const {items, selectIndex, highlightFunc} = this._utils.autocompleteSearch(text, this.options.rowId);
|
2021-08-12 18:06:40 +00:00
|
|
|
const result: ACResults<ReferenceItem> = {
|
|
|
|
selectIndex,
|
|
|
|
highlightFunc,
|
2024-04-26 20:34:16 +00:00
|
|
|
items: items.map(i => new ReferenceItem(i.text, i.rowId)),
|
|
|
|
extraItems: [],
|
2021-08-12 18:06:40 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
this._showAddNew = false;
|
|
|
|
if (!this._enableAddNew || !text) { return result; }
|
|
|
|
|
2022-08-17 16:40:46 +00:00
|
|
|
const cleanText = normalizeText(text);
|
2021-08-12 18:06:40 +00:00
|
|
|
if (result.items.find((item) => item.cleanText === cleanText)) {
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
2024-04-26 20:34:16 +00:00
|
|
|
result.extraItems.push(new ReferenceItem(text, 'new'));
|
2021-08-12 18:06:40 +00:00
|
|
|
this._showAddNew = true;
|
|
|
|
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
|
|
|
private _renderItem(item: ReferenceItem, highlightFunc: HighlightFunc) {
|
|
|
|
return renderACItem(
|
|
|
|
item.text,
|
|
|
|
highlightFunc,
|
|
|
|
item.rowId === 'new',
|
|
|
|
this._showAddNew
|
|
|
|
);
|
2021-07-23 15:29:35 +00:00
|
|
|
}
|
|
|
|
}
|
2021-08-12 18:06:40 +00:00
|
|
|
|
|
|
|
const cssCellEditor = styled('div', `
|
2022-09-06 01:51:57 +00:00
|
|
|
background-color: ${theme.cellEditorBg};
|
2021-08-12 18:06:40 +00:00
|
|
|
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;
|
|
|
|
flex-wrap: wrap;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssToken = styled(tokenFieldStyles.cssToken, `
|
|
|
|
padding: 1px 4px;
|
|
|
|
margin: 2px;
|
|
|
|
line-height: 16px;
|
|
|
|
white-space: pre;
|
2023-09-21 16:57:58 +00:00
|
|
|
color: ${theme.choiceTokenFg};
|
2021-08-12 18:06:40 +00:00
|
|
|
|
|
|
|
&.selected {
|
2023-09-21 16:57:58 +00:00
|
|
|
box-shadow: inset 0 0 0 1px ${theme.choiceTokenSelectedBorder};
|
2021-08-12 18:06:40 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
&-blank {
|
2023-09-21 16:57:58 +00:00
|
|
|
color: ${theme.lightText};
|
2021-08-12 18:06:40 +00:00
|
|
|
}
|
|
|
|
`);
|
|
|
|
|
|
|
|
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;
|
|
|
|
`);
|