Summary: - Adds a new ChoiceList type, and widgets to view and edit it. - Store in SQLite as a JSON string - Support conversions between ChoiceList and other types Test Plan: Added browser tests, and a test for how these values are stored Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D2803pull/23/head
parent
e55fba24e7
commit
8d62a857e1
@ -0,0 +1,72 @@
|
||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||
import {colors} from 'app/client/ui2018/cssVars';
|
||||
import {ChoiceTextBox} from 'app/client/widgets/ChoiceTextBox';
|
||||
import {decodeObject} from 'app/plugin/objtypes';
|
||||
import {Computed, dom, styled} from 'grainjs';
|
||||
|
||||
/**
|
||||
* ChoiceListCell - A cell that renders a list of choice tokens.
|
||||
*/
|
||||
export class ChoiceListCell extends ChoiceTextBox {
|
||||
private _choiceSet = Computed.create(this, this.getChoiceValues(), (use, values) => new Set(values));
|
||||
|
||||
public buildDom(row: DataRowModel) {
|
||||
const value = row.cells[this.field.colId.peek()];
|
||||
|
||||
return cssChoiceList(
|
||||
dom.cls('field_clip'),
|
||||
cssChoiceList.cls('-wrap', this.wrapping),
|
||||
dom.style('justify-content', this.alignment),
|
||||
dom.domComputed((use) => use(row._isAddRow) ? null : [use(value), use(this._choiceSet)], (input) => {
|
||||
if (!input) { return null; }
|
||||
const [rawValue, choiceSet] = input;
|
||||
const val = decodeObject(rawValue);
|
||||
if (!val) { return null; }
|
||||
// Handle any unexpected values we might get (non-array, or array with non-strings).
|
||||
const tokens: unknown[] = Array.isArray(val) ? val : [val];
|
||||
return tokens.map(token =>
|
||||
cssToken(
|
||||
String(token),
|
||||
cssInvalidToken.cls('-invalid', !choiceSet.has(token))
|
||||
)
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cssChoiceList = styled('div', `
|
||||
display: flex;
|
||||
align-items: start;
|
||||
padding: 0 3px;
|
||||
|
||||
position: relative;
|
||||
height: min-content;
|
||||
min-height: 22px;
|
||||
|
||||
&-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssToken = styled('div', `
|
||||
flex: 0 1 auto;
|
||||
min-width: 0px;
|
||||
overflow: hidden;
|
||||
border-radius: 3px;
|
||||
background-color: ${colors.mediumGreyOpaque};
|
||||
padding: 1px 4px;
|
||||
margin: 2px;
|
||||
line-height: 16px;
|
||||
`);
|
||||
|
||||
export const cssInvalidToken = styled('div', `
|
||||
&-invalid {
|
||||
background-color: white !important;
|
||||
box-shadow: inset 0 0 0 1px var(--grist-color-error);
|
||||
color: ${colors.slate};
|
||||
}
|
||||
&-invalid.selected {
|
||||
background-color: ${colors.lightGrey} !important;
|
||||
}
|
||||
`);
|
@ -0,0 +1,283 @@
|
||||
import {createGroup} from 'app/client/components/commands';
|
||||
import {ACIndexImpl, ACItem, ACResults} 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 {cssInvalidToken} from 'app/client/widgets/ChoiceListCell';
|
||||
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
|
||||
import {EditorPlacement} from 'app/client/widgets/EditorPlacement';
|
||||
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
|
||||
import {cssRefList, renderACItem} from 'app/client/widgets/ReferenceEditor';
|
||||
import {csvEncodeRow} from 'app/common/csvFormat';
|
||||
import {CellValue} from "app/common/DocActions";
|
||||
import {decodeObject, encodeObject} from 'app/plugin/objtypes';
|
||||
import {dom, styled} from 'grainjs';
|
||||
|
||||
class ChoiceItem implements ACItem, IToken {
|
||||
public cleanText: string = this.label.toLowerCase().trim();
|
||||
constructor(
|
||||
public label: string,
|
||||
public isInvalid: boolean, // If set, this token is not one of the valid choices.
|
||||
public isNew?: boolean, // If set, this is a choice to be added to the config.
|
||||
) {}
|
||||
}
|
||||
|
||||
export class ChoiceListEditor extends NewBaseEditor {
|
||||
protected cellEditorDiv: HTMLElement;
|
||||
protected commandGroup: any;
|
||||
|
||||
private _tokenField: TokenField;
|
||||
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;
|
||||
|
||||
// Whether to include a button to show a new choice. (It would make sense to disable it when
|
||||
// user cannot change the column configuration.)
|
||||
private _enableAddNew: boolean = true;
|
||||
private _showAddNew: boolean = false;
|
||||
|
||||
constructor(options: Options) {
|
||||
super(options);
|
||||
|
||||
const choices: string[] = options.field.widgetOptionsJson.peek().choices || [];
|
||||
const acItems = choices.map(c => new ChoiceItem(c, false));
|
||||
const choiceSet = new Set(choices);
|
||||
|
||||
const acIndex = new ACIndexImpl<ChoiceItem>(acItems);
|
||||
const acOptions: IAutocompleteOptions<ChoiceItem> = {
|
||||
menuCssClass: menuCssClass + ' ' + cssRefList.className + ' ' + cssChoiceList.className + ' test-autocomplete',
|
||||
search: async (term: string) => this._maybeShowAddNew(acIndex.search(term), term),
|
||||
renderItem: (item: ChoiceItem, highlightFunc) =>
|
||||
renderACItem(item.label, highlightFunc, item.isNew || false, this._showAddNew),
|
||||
getItemText: (item) => item.label,
|
||||
};
|
||||
|
||||
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 startLabels: unknown[] = options.editValue || !Array.isArray(cellValue) ? [] : cellValue;
|
||||
const startTokens = startLabels.map(label => new ChoiceItem(String(label), !choiceSet.has(String(label))));
|
||||
|
||||
this._tokenField = TokenField.create(this, {
|
||||
initialValue: startTokens,
|
||||
renderToken: item => [item.label, cssInvalidToken.cls('-invalid', (item as ChoiceItem).isInvalid)],
|
||||
createToken: label => new ChoiceItem(label, !choiceSet.has(label)),
|
||||
acOptions,
|
||||
openAutocompleteOnFocus: true,
|
||||
styles: {cssTokenField, cssToken, cssDeleteButton, cssDeleteIcon},
|
||||
});
|
||||
|
||||
this._dom = dom('div.default_editor',
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
return encodeObject(this._tokenField.tokensObs.get().map(item => item.label));
|
||||
}
|
||||
|
||||
public getTextValue() {
|
||||
const values = this._tokenField.tokensObs.get().map(t => t.label);
|
||||
return csvEncodeRow(values, {prettier: true});
|
||||
}
|
||||
|
||||
public getCursorPos(): number {
|
||||
return this._textInput.selectionStart || 0;
|
||||
}
|
||||
|
||||
public async prepForSave() {
|
||||
const tokens = this._tokenField.tokensObs.get() as ChoiceItem[];
|
||||
const newChoices = tokens.filter(t => t.isNew).map(t => t.label);
|
||||
if (newChoices.length > 0) {
|
||||
const choices = this.options.field.widgetOptionsJson.prop('choices');
|
||||
await choices.saveOnly([...choices.peek(), ...new Set(newChoices)]);
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
private _maybeShowAddNew(result: ACResults<ChoiceItem>, text: string): ACResults<ChoiceItem> {
|
||||
// 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 addNewItem = new ChoiceItem(text, false, true);
|
||||
if (!result.items.find((item) => item.cleanText === addNewItem.cleanText)) {
|
||||
result.items.push(addNewItem);
|
||||
this._showAddNew = true;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
`);
|
||||
|
||||
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;
|
||||
`);
|
||||
|
||||
// Set z-index to be higher than the 1000 set for .cell_editor.
|
||||
const cssChoiceList = styled('div', `
|
||||
z-index: 1001;
|
||||
box-shadow: 0 0px 8px 0 rgba(38,38,51,0.6)
|
||||
`);
|
Loading…
Reference in new issue