(core) Add ChoiceList type, cell widget, and editor widget.

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/D2803
This commit is contained in:
Dmitry S 2021-05-12 10:34:49 -04:00
parent e55fba24e7
commit 8d62a857e1
13 changed files with 615 additions and 69 deletions

View File

@ -91,6 +91,22 @@ export async function prepTransformColInfo(docModel: DocModel, origCol: ColumnRe
} }
break; break;
} }
case 'ChoiceList': {
// Set suggested choices. This happens before the conversion to ChoiceList, so we do some
// light guessing for likely choices to suggest.
const choices = new Set<string>();
for (let value of tableData.getColValues(origCol.colId()) || []) {
value = String(value).trim();
const tags: string[] = (value.startsWith('[') && gutil.safeJsonParse(value, null)) || value.split(",");
for (const tag of tags) {
choices.add(tag.trim());
if (choices.size > 100) { break; } // Don't suggest excessively many choices.
}
}
choices.delete("");
widgetOptions = {choices: Array.from(choices)};
break;
}
case 'Ref': { case 'Ref': {
// Set suggested destination table and visible column. // Set suggested destination table and visible column.
// Null if toTypeMaybeFull is a pure type (e.g. converting to Ref before a table is chosen). // Null if toTypeMaybeFull is a pure type (e.g. converting to Ref before a table is chosen).
@ -148,10 +164,15 @@ export function getDefaultFormula(
const oldVisibleColName = isReferenceCol(origCol) ? const oldVisibleColName = isReferenceCol(origCol) ?
getVisibleColName(docModel, origCol.visibleCol()) : undefined; getVisibleColName(docModel, origCol.visibleCol()) : undefined;
const origValFormula = oldVisibleColName ? let origValFormula = oldVisibleColName ?
// The `str()` below converts AltText to plain text. // The `str()` below converts AltText to plain text.
`$${colId}.${oldVisibleColName} if ISREF($${colId}) else str($${colId})` : `$${colId}.${oldVisibleColName} if ISREF($${colId}) else str($${colId})` :
`$${colId}`; `$${colId}`;
if (origCol.type.peek() === 'ChoiceList') {
origValFormula = `grist.ChoiceList.toString($${colId})`
}
const toTypePure: string = gristTypes.extractTypeFromColType(newType); const toTypePure: string = gristTypes.extractTypeFromColType(newType);
// The args are used to construct the call to grist.TYPE.typeConvert(value, [params]). // The args are used to construct the call to grist.TYPE.typeConvert(value, [params]).

View File

@ -22,7 +22,7 @@ import { colors, testId } from 'app/client/ui2018/cssVars';
import { icon } from 'app/client/ui2018/icons'; import { icon } from 'app/client/ui2018/icons';
import { csvDecodeRow, csvEncodeRow } from 'app/common/csvFormat'; import { csvDecodeRow, csvEncodeRow } from 'app/common/csvFormat';
import { computedArray, IObsArraySplice, ObsArray, obsArray, Observable } from 'grainjs'; import { computedArray, IObsArraySplice, ObsArray, obsArray, Observable } from 'grainjs';
import { Disposable, dom, DomContents, Holder, styled } from 'grainjs'; import { Disposable, dom, DomElementArg, Holder, styled } from 'grainjs';
export interface IToken { export interface IToken {
label: string; label: string;
@ -30,10 +30,11 @@ export interface IToken {
export interface ITokenFieldOptions { export interface ITokenFieldOptions {
initialValue: IToken[]; initialValue: IToken[];
renderToken: (token: IToken) => DomContents; renderToken: (token: IToken) => DomElementArg;
createToken: (inputText: string) => IToken|undefined; createToken: (inputText: string) => IToken|undefined;
acOptions?: IAutocompleteOptions<IToken & ACItem>; acOptions?: IAutocompleteOptions<IToken & ACItem>;
openAutocompleteOnFocus?: boolean; openAutocompleteOnFocus?: boolean;
styles?: ITokenFieldStyles;
// Allows overriding how tokens are copied to the clipboard, or retrieved from it. // Allows overriding how tokens are copied to the clipboard, or retrieved from it.
// By default, tokens are placed into clipboard as text/plain comma-separated token labels, with // By default, tokens are placed into clipboard as text/plain comma-separated token labels, with
@ -87,11 +88,20 @@ export class TokenField extends Disposable {
// obsArray interface, by listening to the splice events. // obsArray interface, by listening to the splice events.
this.autoDispose(this._tokens.addListener(this._recordUndo.bind(this))); this.autoDispose(this._tokens.addListener(this._recordUndo.bind(this)));
// Use overridden styles if any were provided.
const {cssTokenField, cssToken, cssInputWrapper, cssTokenInput, cssDeleteButton, cssDeleteIcon} =
{...tokenFieldStyles, ..._options.styles};
function stop(ev: Event) {
ev.stopPropagation();
ev.preventDefault();
}
this._rootElem = cssTokenField( this._rootElem = cssTokenField(
{tabIndex: '-1'}, {tabIndex: '-1'},
dom.forEach(this._tokens, (t) => dom.forEach(this._tokens, (t) =>
cssToken(this._options.renderToken(t.token), cssToken(this._options.renderToken(t.token),
cssDeleteIcon('CrossSmall', testId('tokenfield-delete')), cssDeleteButton(cssDeleteIcon('CrossSmall'), testId('tokenfield-delete')),
dom.cls('selected', (use) => use(this._selection).has(t)), dom.cls('selected', (use) => use(this._selection).has(t)),
dom.on('click', (ev) => this._onTokenClick(ev, t)), dom.on('click', (ev) => this._onTokenClick(ev, t)),
dom.on('mousedown', (ev) => this._onMouseDown(ev, t)), dom.on('mousedown', (ev) => this._onMouseDown(ev, t)),
@ -102,18 +112,16 @@ export class TokenField extends Disposable {
this._textInput = cssTokenInput( this._textInput = cssTokenInput(
dom.on('focus', this._onInputFocus.bind(this)), dom.on('focus', this._onInputFocus.bind(this)),
dom.on('blur', () => { this._acHolder.clear(); }), dom.on('blur', () => { this._acHolder.clear(); }),
(this._acOptions ?
// Toggle the autocomplete on clicking the input box.
dom.on('click', () => this._acHolder.isEmpty() ? openAutocomplete() : this._acHolder.clear()) :
null
),
dom.onKeyDown({ dom.onKeyDown({
Escape: () => { this._acHolder.clear(); }, Escape$: (ev) => { this._acHolder.clear(); },
Enter: addSelectedItem, Enter$: (ev) => addSelectedItem() && stop(ev),
ArrowDown$: openAutocomplete, ArrowDown$: openAutocomplete,
Tab$: (ev) => { Tab$: (ev) => addSelectedItem() && stop(ev),
// Only treat tab specially if there is some token-adding in progress.
if (this._textInput.value !== '' || !this._acHolder.isEmpty()) {
ev.stopPropagation();
ev.preventDefault();
addSelectedItem();
}
},
}), }),
dom.on('input', openAutocomplete), dom.on('input', openAutocomplete),
testId('tokenfield-input'), testId('tokenfield-input'),
@ -149,6 +157,21 @@ export class TokenField extends Disposable {
elem.appendChild(this._rootElem); elem.appendChild(this._rootElem);
} }
// Outer container for the tokens and new-entry input field.
public getRootElem(): HTMLElement {
return this._rootElem;
}
// The new-entry input field.
public getTextInput(): HTMLInputElement {
return this._textInput;
}
// The invisible input that has focus while we have some tokens selected.
public getHiddenInput(): HTMLInputElement {
return this._hiddenInput;
}
// Open the autocomplete dropdown, if autocomplete was configured in the options. // Open the autocomplete dropdown, if autocomplete was configured in the options.
private _openAutocomplete() { private _openAutocomplete() {
if (this._acOptions && this._acHolder.isEmpty()) { if (this._acOptions && this._acHolder.isEmpty()) {
@ -158,7 +181,7 @@ export class TokenField extends Disposable {
// Adds the typed-in or selected item. If an item is selected in autocomplete dropdown, adds // Adds the typed-in or selected item. If an item is selected in autocomplete dropdown, adds
// that; otherwise if options.createToken is present, creates a token from text input value. // that; otherwise if options.createToken is present, creates a token from text input value.
private _addSelectedItem() { private _addSelectedItem(): boolean {
let item: IToken|undefined = this._acHolder.get()?.getSelectedItem(); let item: IToken|undefined = this._acHolder.get()?.getSelectedItem();
if (!item && this._options.createToken && this._textInput.value) { if (!item && this._options.createToken && this._textInput.value) {
item = this._options.createToken(this._textInput.value); item = this._options.createToken(this._textInput.value);
@ -167,7 +190,9 @@ export class TokenField extends Disposable {
this._tokens.push(new TokenWrap(item)); this._tokens.push(new TokenWrap(item));
this._textInput.value = ''; this._textInput.value = '';
this._acHolder.clear(); this._acHolder.clear();
return true;
} }
return false;
} }
// Handler for when text input is focused: clears selection, optionally opens dropdown. // Handler for when text input is focused: clears selection, optionally opens dropdown.
@ -514,6 +539,7 @@ const cssTokenField = styled('div', `
border: 1px solid ${colors.darkGrey}; border: 1px solid ${colors.darkGrey};
border-radius: 3px; border-radius: 3px;
padding: 0 4px; padding: 0 4px;
line-height: 16px;
&.token-dragactive { &.token-dragactive {
cursor: grabbing; cursor: grabbing;
@ -527,7 +553,6 @@ const cssToken = styled('div', `
background-color: ${colors.mediumGreyOpaque}; background-color: ${colors.mediumGreyOpaque};
padding: 4px; padding: 4px;
margin: 3px 2px; margin: 3px 2px;
line-height: 16px;
user-select: none; user-select: none;
cursor: grab; cursor: grab;
@ -558,6 +583,7 @@ const cssTokenInput = styled('input', `
padding: 0; padding: 0;
border: none; border: none;
outline: none; outline: none;
line-height: inherit;
`); `);
// This class is applied to tokens and the input box on start of dragging, to use them as drag // This class is applied to tokens and the input box on start of dragging, to use them as drag
@ -594,15 +620,31 @@ const cssHiddenInput = styled('input', `
position: absolute; position: absolute;
`); `);
const cssDeleteIcon = styled(icon, ` const cssDeleteButton = styled('div', `
vertical-align: bottom; display: inline;
margin-left: 4px; margin-left: 4px;
vertical-align: bottom;
line-height: 1;
cursor: pointer; cursor: pointer;
--icon-color: ${colors.slate};
&:hover {
--icon-color: ${colors.dark};
}
.${cssTokenField.className}.token-dragactive & { .${cssTokenField.className}.token-dragactive & {
cursor: unset; cursor: unset;
} }
`); `);
const cssDeleteIcon = styled(icon, `
--icon-color: ${colors.slate};
&:hover {
--icon-color: ${colors.dark};
}
`);
export const tokenFieldStyles = {
cssTokenField,
cssToken,
cssInputWrapper,
cssTokenInput,
cssDeleteButton,
cssDeleteIcon,
};
export type ITokenFieldStyles = Partial<typeof tokenFieldStyles>;

View File

@ -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;
}
`);

View File

@ -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)
`);

View File

@ -4,7 +4,6 @@ import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {KoSaveableObservable} from 'app/client/models/modelUtil'; import {KoSaveableObservable} from 'app/client/models/modelUtil';
import {cssLabel, cssRow} from 'app/client/ui/RightPanel'; import {cssLabel, cssRow} from 'app/client/ui/RightPanel';
import {alignmentSelect} from 'app/client/ui2018/buttonSelect';
import {colors, testId} from 'app/client/ui2018/cssVars'; import {colors, testId} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {menu, menuItem} from 'app/client/ui2018/menus'; import {menu, menuItem} from 'app/client/ui2018/menus';
@ -31,28 +30,13 @@ export class ChoiceTextBox extends NTextBox {
dom.style('text-align', this.alignment), dom.style('text-align', this.alignment),
dom.text((use) => use(row._isAddRow) ? '' : use(this.valueFormatter).format(use(value))), dom.text((use) => use(row._isAddRow) ? '' : use(this.valueFormatter).format(use(value))),
), ),
cssDropdownIcon('Dropdown', this.buildDropdownMenu(),
// When choices exist, click dropdown icon to open edit autocomplete.
dom.on('click', () => this._hasChoices() && commands.allCommands.editField.run()),
// When choices do not exist, open a single-item menu to open the sidepane choice option editor.
menu(() => [
menuItem(commands.allCommands.fieldTabOpen.run, 'Add Choice Options')
], {
trigger: [(elem, ctl) => {
// Only open this menu if there are no choices.
dom.onElem(elem, 'click', () => this._hasChoices() || ctl.open());
}]
}),
testId('choice-dropdown')
)
); );
} }
public buildConfigDom() { public buildConfigDom() {
return [ return [
cssRow( super.buildConfigDom(),
alignmentSelect(this.alignment)
),
cssLabel('OPTIONS'), cssLabel('OPTIONS'),
cssRow( cssRow(
dom.create(ListEntry, this._choiceValues, (values) => this._choices.saveOnly(values)) dom.create(ListEntry, this._choiceValues, (values) => this._choices.saveOnly(values))
@ -64,6 +48,27 @@ export class ChoiceTextBox extends NTextBox {
return this.buildConfigDom(); return this.buildConfigDom();
} }
protected getChoiceValues(): Computed<string[]> {
return this._choiceValues;
}
protected buildDropdownMenu() {
return cssDropdownIcon('Dropdown',
// When choices exist, click dropdown icon to open edit autocomplete.
dom.on('click', () => this._hasChoices() && commands.allCommands.editField.run()),
// When choices do not exist, open a single-item menu to open the sidepane choice option editor.
menu(() => [
menuItem(commands.allCommands.fieldTabOpen.run, 'Add Choice Options')
], {
trigger: [(elem, ctl) => {
// Only open this menu if there are no choices.
dom.onElem(elem, 'click', () => this._hasChoices() || ctl.open());
}]
}),
testId('choice-dropdown')
);
}
private _hasChoices() { private _hasChoices() {
return this._choiceValues.get().length > 0; return this._choiceValues.get().length > 0;
} }
@ -71,7 +76,6 @@ export class ChoiceTextBox extends NTextBox {
const cssChoiceField = styled('div.field_clip', ` const cssChoiceField = styled('div.field_clip', `
display: flex; display: flex;
justify-content: space-between;
`); `);
const cssChoiceText = styled('div', ` const cssChoiceText = styled('div', `
@ -86,4 +90,5 @@ const cssDropdownIcon = styled(icon, `
min-width: 16px; min-width: 16px;
width: 16px; width: 16px;
height: 16px; height: 16px;
margin-left: auto;
`); `);

View File

@ -149,17 +149,21 @@ export class ReferenceEditor extends NTextEditor {
} }
private _renderItem(item: ICellItem, highlightFunc: HighlightFunc) { private _renderItem(item: ICellItem, highlightFunc: HighlightFunc) {
if (item.rowId === 'new') { return renderACItem(item.text, highlightFunc, item.rowId === 'new', this._showAddNew);
return cssRefItem(cssRefItem.cls('-new'), }
cssPlusButton(cssPlusIcon('Plus')), item.text, }
testId('ref-editor-item'), testId('ref-editor-new-item'),
); export function renderACItem(text: string, highlightFunc: HighlightFunc, isAddNew: boolean, withSpaceForNew: boolean) {
} if (isAddNew) {
return cssRefItem(cssRefItem.cls('-with-new', this._showAddNew), return cssRefItem(cssRefItem.cls('-new'),
buildHighlightedDom(item.text, highlightFunc, cssMatchText), cssPlusButton(cssPlusIcon('Plus')), text,
testId('ref-editor-item'), 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) { function nocaseEqual(a: string, b: string) {
@ -172,7 +176,7 @@ const cssRefEditor = styled('div', `
} }
`); `);
const cssRefList = styled('div', ` export const cssRefList = styled('div', `
overflow-y: auto; overflow-y: auto;
padding: 8px 0 0 0; padding: 8px 0 0 0;
--weaseljs-menu-item-padding: 8px 16px; --weaseljs-menu-item-padding: 8px 16px;

View File

@ -204,6 +204,22 @@ var typeDefs = {
}, },
default: 'TextBox' default: 'TextBox'
}, },
ChoiceList: {
label: 'Choice List',
icon: 'FieldChoice',
widgets: {
TextBox: {
cons: 'ChoiceListCell',
editCons: 'ChoiceListEditor',
icon: 'FieldTextbox',
options: {
alignment: 'left',
choices: null
}
}
},
default: 'TextBox'
},
Ref: { Ref: {
label: 'Reference', label: 'Reference',
icon: 'FieldReference', icon: 'FieldReference',

View File

@ -28,6 +28,8 @@ const nameToWidget = {
'ReferenceEditor': ReferenceEditor, 'ReferenceEditor': ReferenceEditor,
'ChoiceTextBox': ChoiceTextBox, 'ChoiceTextBox': ChoiceTextBox,
'ChoiceEditor': require('./ChoiceEditor'), 'ChoiceEditor': require('./ChoiceEditor'),
'ChoiceListCell': require('./ChoiceListCell').ChoiceListCell,
'ChoiceListEditor': require('./ChoiceListEditor').ChoiceListEditor,
'DateTimeTextBox': require('./DateTimeTextBox'), 'DateTimeTextBox': require('./DateTimeTextBox'),
'DateTextBox': require('./DateTextBox'), 'DateTextBox': require('./DateTextBox'),
'DateEditor': require('./DateEditor'), 'DateEditor': require('./DateEditor'),

View File

@ -3,7 +3,8 @@ import isString = require('lodash/isString');
// tslint:disable:object-literal-key-quotes // tslint:disable:object-literal-key-quotes
export type GristType = 'Any' | 'Attachments' | 'Blob' | 'Bool' | 'Choice' | 'Date' | 'DateTime' | export type GristType = 'Any' | 'Attachments' | 'Blob' | 'Bool' | 'Choice' | 'ChoiceList' |
'Date' | 'DateTime' |
'Id' | 'Int' | 'ManualSortPos' | 'Numeric' | 'PositionNumber' | 'Ref' | 'RefList' | 'Text'; 'Id' | 'Int' | 'ManualSortPos' | 'Numeric' | 'PositionNumber' | 'Ref' | 'RefList' | 'Text';
export type GristTypeInfo = export type GristTypeInfo =
@ -41,6 +42,7 @@ const _defaultValues: {[key in GristType]: [CellValue, string]} = {
// Bool is only supported by SQLite as 0 and 1 values. // Bool is only supported by SQLite as 0 and 1 values.
'Bool': [ false, "0" ], 'Bool': [ false, "0" ],
'Choice': [ '', "''" ], 'Choice': [ '', "''" ],
'ChoiceList': [ null, "NULL" ],
'Date': [ null, "NULL" ], 'Date': [ null, "NULL" ],
'DateTime': [ null, "NULL" ], 'DateTime': [ null, "NULL" ],
'Id': [ 0, "0" ], 'Id': [ 0, "0" ],
@ -187,7 +189,8 @@ const rightType: {[key in GristType]: (value: CellValue) => boolean} = {
} else { } else {
return false; return false;
} }
} },
ChoiceList: isListOrNull,
}; };
export function isRightType(type: string): undefined | ((value: CellValue, options?: any) => boolean) { export function isRightType(type: string): undefined | ((value: CellValue, options?: any) => boolean) {

View File

@ -377,7 +377,7 @@ export class DocStorage implements ISQLiteDB {
* be used within main Grist application. * be used within main Grist application.
*/ */
public static decodeRowValues(dbRow: ResultRow): any { public static decodeRowValues(dbRow: ResultRow): any {
return _.mapObject(dbRow, val => DocStorage._decodeValue(val, 'Any')); return _.mapObject(dbRow, val => DocStorage._decodeValue(val, 'Any', 'BLOB'));
} }
/** /**
@ -425,7 +425,7 @@ export class DocStorage implements ISQLiteDB {
const rows = _.unzip(valueColumns); const rows = _.unzip(valueColumns);
for (const row of rows) { for (const row of rows) {
for (let i = 0; i < row.length; i++) { for (let i = 0; i < row.length; i++) {
row[i] = DocStorage._encodeValue(marshaller, this._getSqlType(types[i]), row[i]); row[i] = DocStorage._encodeValue(marshaller, types[i], this._getSqlType(types[i]), row[i]);
} }
} }
return rows; return rows;
@ -440,12 +440,19 @@ export class DocStorage implements ISQLiteDB {
* which such encoding/marshalling is not used, and e.g. binary data is stored to BLOBs directly. * which such encoding/marshalling is not used, and e.g. binary data is stored to BLOBs directly.
*/ */
private static _encodeValue( private static _encodeValue(
marshaller: marshal.Marshaller, sqlType: string, val: any marshaller: marshal.Marshaller, gristType: string, sqlType: string, val: any
): Uint8Array|string|number|boolean { ): Uint8Array|string|number|boolean {
const marshalled = () => { const marshalled = () => {
marshaller.marshal(val); marshaller.marshal(val);
return marshaller.dump(); return marshaller.dump();
}; };
if (gristType == 'ChoiceList') {
// See also app/plugin/objtype.ts for decodeObject(). Here we manually check and decode
// the "List" object type.
if (Array.isArray(val) && val[0] === 'L' && val.every(tok => (typeof(tok) === 'string'))) {
return JSON.stringify(val.slice(1));
}
}
// Marshall anything non-primitive. // Marshall anything non-primitive.
if (Array.isArray(val) || val instanceof Uint8Array || Buffer.isBuffer(val)) { if (Array.isArray(val) || val instanceof Uint8Array || Buffer.isBuffer(val)) {
return marshalled(); return marshalled();
@ -494,19 +501,30 @@ export class DocStorage implements ISQLiteDB {
/** /**
* Decodes Grist data received from SQLite; the inverse of _encodeValue(). * Decodes Grist data received from SQLite; the inverse of _encodeValue().
* Type may be either grist or sql type. Only used for a Bool/BOOLEAN check. * Both Grist and SQL types are expected. Used to interpret Bool/BOOLEANs, and to parse
* ChoiceList values.
*/ */
private static _decodeValue(val: any, type: string): any { private static _decodeValue(val: any, gristType: string, sqlType: string): any {
if (val instanceof Uint8Array || Buffer.isBuffer(val)) { if (val instanceof Uint8Array || Buffer.isBuffer(val)) {
val = marshal.loads(val); val = marshal.loads(val);
} }
if ((type === 'Bool' || type === 'BOOLEAN') && (val === 0 || val === 1)) { if (gristType === 'Bool') {
// Boolean values come in as 0/1. If the column is of type "Bool", interpret those as if (val === 0 || val === 1) {
// true/false (note that the data engine does this too). // Boolean values come in as 0/1. If the column is of type "Bool", interpret those as
return Boolean(val); // true/false (note that the data engine does this too).
} else { return Boolean(val);
return val; }
} }
if (gristType === 'ChoiceList') {
if (typeof val === 'string' && val.startsWith('[')) {
try {
return ['L', ...JSON.parse(val)];
} catch (e) {
// Fall through without parsing
}
}
}
return val;
} }
/** /**
@ -538,6 +556,8 @@ export class DocStorage implements ISQLiteDB {
case 'Choice': case 'Choice':
case 'Text': case 'Text':
return 'TEXT'; return 'TEXT';
case 'ChoiceList':
return 'TEXT'; // To be encoded as a JSON array of strings.
case 'Date': case 'Date':
return 'DATE'; return 'DATE';
case 'DateTime': case 'DateTime':
@ -842,7 +862,7 @@ export class DocStorage implements ISQLiteDB {
const type = this._getGristType(tableId, col); const type = this._getGristType(tableId, col);
const column = columnValues[col]; const column = columnValues[col];
for (let i = 0; i < column.length; i++) { for (let i = 0; i < column.length; i++) {
column[i] = DocStorage._decodeValue(column[i], type); column[i] = DocStorage._decodeValue(column[i], type, DocStorage._getSqlType(type));
} }
} }
return columnValues; return columnValues;
@ -1348,6 +1368,7 @@ export class DocStorage implements ISQLiteDB {
if (!colInfo) { if (!colInfo) {
return null; // Column not found. return null; // Column not found.
} }
const oldGristType = this._getGristType(tableId, colId);
const oldSqlType = colInfo.type || 'BLOB'; const oldSqlType = colInfo.type || 'BLOB';
const oldDefault = colInfo.dflt_value; const oldDefault = colInfo.dflt_value;
const newSqlType = newColType ? DocStorage._getSqlType(newColType) : oldSqlType; const newSqlType = newColType ? DocStorage._getSqlType(newColType) : oldSqlType;
@ -1361,6 +1382,8 @@ export class DocStorage implements ISQLiteDB {
const colSpecSql = DocStorage._prefixJoin(', ', infoRows.map(DocStorage._sqlColSpecFromDBInfo)); const colSpecSql = DocStorage._prefixJoin(', ', infoRows.map(DocStorage._sqlColSpecFromDBInfo));
return { return {
sql: `CREATE TABLE ${quoteIdent(tableId)} (id INTEGER PRIMARY KEY${colSpecSql})`, sql: `CREATE TABLE ${quoteIdent(tableId)} (id INTEGER PRIMARY KEY${colSpecSql})`,
oldGristType,
newGristType: newColType || oldGristType,
oldDefault, oldDefault,
newDefault, newDefault,
oldSqlType, oldSqlType,
@ -1411,7 +1434,7 @@ export class DocStorage implements ISQLiteDB {
// For any marshalled objects, check if we can now unmarshall them if they are the // For any marshalled objects, check if we can now unmarshall them if they are the
// native type. // native type.
if (result.newSqlType !== result.oldSqlType) { if (result.newGristType !== result.oldGristType) {
const cells = await this.all(`SELECT id, ${q(colId)} as value FROM ${q(tableId)} ` + const cells = await this.all(`SELECT id, ${q(colId)} as value FROM ${q(tableId)} ` +
`WHERE typeof(${q(colId)}) = 'blob'`); `WHERE typeof(${q(colId)}) = 'blob'`);
const marshaller = new marshal.Marshaller({version: 2}); const marshaller = new marshal.Marshaller({version: 2});
@ -1419,8 +1442,8 @@ export class DocStorage implements ISQLiteDB {
for (const cell of cells) { for (const cell of cells) {
const id: number = cell.id; const id: number = cell.id;
const value: any = cell.value; const value: any = cell.value;
const decodedValue = DocStorage._decodeValue(value, result.oldSqlType); const decodedValue = DocStorage._decodeValue(value, result.oldGristType, result.oldSqlType);
const newValue = DocStorage._encodeValue(marshaller, result.newSqlType, decodedValue); const newValue = DocStorage._encodeValue(marshaller, result.newGristType, result.newSqlType, decodedValue);
if (!(newValue instanceof Uint8Array)) { if (!(newValue instanceof Uint8Array)) {
sqlParams.push([newValue, id]); sqlParams.push([newValue, id]);
} }
@ -1505,6 +1528,8 @@ export class DocStorage implements ISQLiteDB {
interface RebuildResult { interface RebuildResult {
sql: string; sql: string;
oldGristType: string;
newGristType: string;
oldDefault: string; oldDefault: string;
newDefault: string; newDefault: string;
oldSqlType: string; oldSqlType: string;

View File

@ -1,3 +1,4 @@
import json
import types import types
from collections import namedtuple from collections import namedtuple
@ -294,6 +295,26 @@ class PositionColumn(NumericColumn):
return new_values, [(self._sorted_rows[i], pos) for (i, pos) in adjustments] return new_values, [(self._sorted_rows[i], pos) for (i, pos) in adjustments]
class ChoiceListColumn(BaseColumn):
"""
ChoiceListColumn's default value is None, but is presented to formulas as the empty list.
"""
def set(self, row_id, value):
# When a JSON string is loaded, set it to a tuple parsed from it. When a list is loaded,
# convert to a tuple to keep values immutable.
if isinstance(value, basestring) and value.startswith('['):
try:
value = tuple(json.loads(value))
except Exception:
pass
elif isinstance(value, list):
value = tuple(value)
super(ChoiceListColumn, self).set(row_id, value)
def _make_rich_value(self, typed_value):
return () if typed_value is None else typed_value
class BaseReferenceColumn(BaseColumn): class BaseReferenceColumn(BaseColumn):
""" """
Base class for ReferenceColumn and ReferenceListColumn. Base class for ReferenceColumn and ReferenceListColumn.
@ -386,6 +407,7 @@ class ReferenceListColumn(BaseReferenceColumn):
usertypes.BaseColumnType.ColType = DataColumn usertypes.BaseColumnType.ColType = DataColumn
usertypes.Reference.ColType = ReferenceColumn usertypes.Reference.ColType = ReferenceColumn
usertypes.ReferenceList.ColType = ReferenceListColumn usertypes.ReferenceList.ColType = ReferenceListColumn
usertypes.ChoiceList.ColType = ChoiceListColumn
usertypes.DateTime.ColType = DateTimeColumn usertypes.DateTime.ColType = DateTimeColumn
usertypes.Date.ColType = DateColumn usertypes.Date.ColType = DateColumn
usertypes.PositionNumber.ColType = PositionColumn usertypes.PositionNumber.ColType = PositionColumn

View File

@ -6,7 +6,7 @@ a consistent API accessible with only "import grist".
# These imports are used in processing generated usercode. # These imports are used in processing generated usercode.
from usertypes import Any, Text, Blob, Int, Bool, Date, DateTime, \ from usertypes import Any, Text, Blob, Int, Bool, Date, DateTime, \
Numeric, Choice, Id, Attachments, AltText, ifError Numeric, Choice, ChoiceList, Id, Attachments, AltText, ifError
from usertypes import PositionNumber, ManualSortPos, Reference, ReferenceList, formulaType from usertypes import PositionNumber, ManualSortPos, Reference, ReferenceList, formulaType
from table import UserTable from table import UserTable
from records import Record, RecordSet from records import Record, RecordSet

View File

@ -11,7 +11,10 @@ Python's array.array. However, at least on the Python side, it means that we nee
data structure for values of the wrong type, and the memory savings aren't that great to be worth data structure for values of the wrong type, and the memory savings aren't that great to be worth
the extra complexity. the extra complexity.
""" """
import csv
import cStringIO
import datetime import datetime
import json
import six import six
import objtypes import objtypes
from objtypes import AltText from objtypes import AltText
@ -29,6 +32,7 @@ _type_defaults = {
'Blob': None, 'Blob': None,
'Bool': False, 'Bool': False,
'Choice': '', 'Choice': '',
'ChoiceList': None,
'Date': None, 'Date': None,
'DateTime': None, 'DateTime': None,
'Id': 0, 'Id': 0,
@ -319,6 +323,53 @@ class Choice(Text):
pass pass
class ChoiceList(BaseColumnType):
"""
ChoiceList is the type for a field holding a list of strings from a set of acceptable choices.
"""
def do_convert(self, value):
if not value:
return None
elif isinstance(value, basestring):
# If it's a string that looks like JSON, try to parse it as such.
if value.startswith('['):
try:
return tuple(str(item) for item in json.loads(value))
except Exception:
pass
return value
else:
# Accepts other kinds of iterables; if that doesn't work, fail the conversion too.
return tuple(str(item) for item in value)
@classmethod
def is_right_type(cls, value):
return value is None or (isinstance(value, (tuple, list)) and
all(isinstance(item, basestring) for item in value))
@classmethod
def typeConvert(cls, value):
if isinstance(value, basestring) and not value.startswith('['):
# Try to parse as CSV. If this doesn't work, we'll still try usual conversions later.
try:
tags = next(csv.reader([value]))
return tuple(t.strip() for t in tags if t.strip())
except Exception:
pass
return value
@classmethod
def toString(cls, value):
if isinstance(value, (tuple, list)):
try:
buf = cStringIO.StringIO()
csv.writer(buf).writerow(value)
return buf.getvalue().strip()
except Exception:
pass
return value
class PositionNumber(BaseColumnType): class PositionNumber(BaseColumnType):
""" """
PositionNumber is the type for a position field used to order records in record lists. PositionNumber is the type for a position field used to order records in record lists.