(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
pull/23/head
Dmitry S 3 years ago
parent e55fba24e7
commit 8d62a857e1

@ -91,6 +91,22 @@ export async function prepTransformColInfo(docModel: DocModel, origCol: ColumnRe
}
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': {
// Set suggested destination table and visible column.
// 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) ?
getVisibleColName(docModel, origCol.visibleCol()) : undefined;
const origValFormula = oldVisibleColName ?
let origValFormula = oldVisibleColName ?
// The `str()` below converts AltText to plain text.
`$${colId}.${oldVisibleColName} if ISREF($${colId}) else str($${colId})` :
`$${colId}`;
if (origCol.type.peek() === 'ChoiceList') {
origValFormula = `grist.ChoiceList.toString($${colId})`
}
const toTypePure: string = gristTypes.extractTypeFromColType(newType);
// The args are used to construct the call to grist.TYPE.typeConvert(value, [params]).

@ -22,7 +22,7 @@ import { colors, testId } from 'app/client/ui2018/cssVars';
import { icon } from 'app/client/ui2018/icons';
import { csvDecodeRow, csvEncodeRow } from 'app/common/csvFormat';
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 {
label: string;
@ -30,10 +30,11 @@ export interface IToken {
export interface ITokenFieldOptions {
initialValue: IToken[];
renderToken: (token: IToken) => DomContents;
renderToken: (token: IToken) => DomElementArg;
createToken: (inputText: string) => IToken|undefined;
acOptions?: IAutocompleteOptions<IToken & ACItem>;
openAutocompleteOnFocus?: boolean;
styles?: ITokenFieldStyles;
// 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
@ -87,11 +88,20 @@ export class TokenField extends Disposable {
// obsArray interface, by listening to the splice events.
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(
{tabIndex: '-1'},
dom.forEach(this._tokens, (t) =>
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.on('click', (ev) => this._onTokenClick(ev, t)),
dom.on('mousedown', (ev) => this._onMouseDown(ev, t)),
@ -102,18 +112,16 @@ export class TokenField extends Disposable {
this._textInput = cssTokenInput(
dom.on('focus', this._onInputFocus.bind(this)),
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({
Escape: () => { this._acHolder.clear(); },
Enter: addSelectedItem,
Escape$: (ev) => { this._acHolder.clear(); },
Enter$: (ev) => addSelectedItem() && stop(ev),
ArrowDown$: openAutocomplete,
Tab$: (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();
}
},
Tab$: (ev) => addSelectedItem() && stop(ev),
}),
dom.on('input', openAutocomplete),
testId('tokenfield-input'),
@ -149,6 +157,21 @@ export class TokenField extends Disposable {
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.
private _openAutocomplete() {
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
// 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();
if (!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._textInput.value = '';
this._acHolder.clear();
return true;
}
return false;
}
// 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-radius: 3px;
padding: 0 4px;
line-height: 16px;
&.token-dragactive {
cursor: grabbing;
@ -527,7 +553,6 @@ const cssToken = styled('div', `
background-color: ${colors.mediumGreyOpaque};
padding: 4px;
margin: 3px 2px;
line-height: 16px;
user-select: none;
cursor: grab;
@ -558,6 +583,7 @@ const cssTokenInput = styled('input', `
padding: 0;
border: 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
@ -594,15 +620,31 @@ const cssHiddenInput = styled('input', `
position: absolute;
`);
const cssDeleteIcon = styled(icon, `
vertical-align: bottom;
const cssDeleteButton = styled('div', `
display: inline;
margin-left: 4px;
vertical-align: bottom;
line-height: 1;
cursor: pointer;
.${cssTokenField.className}.token-dragactive & {
cursor: unset;
}
`);
const cssDeleteIcon = styled(icon, `
--icon-color: ${colors.slate};
&:hover {
--icon-color: ${colors.dark};
}
.${cssTokenField.className}.token-dragactive & {
cursor: unset;
}
`);
export const tokenFieldStyles = {
cssTokenField,
cssToken,
cssInputWrapper,
cssTokenInput,
cssDeleteButton,
cssDeleteIcon,
};
export type ITokenFieldStyles = Partial<typeof tokenFieldStyles>;

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

@ -4,7 +4,6 @@ import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {KoSaveableObservable} from 'app/client/models/modelUtil';
import {cssLabel, cssRow} from 'app/client/ui/RightPanel';
import {alignmentSelect} from 'app/client/ui2018/buttonSelect';
import {colors, testId} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menu, menuItem} from 'app/client/ui2018/menus';
@ -31,28 +30,13 @@ export class ChoiceTextBox extends NTextBox {
dom.style('text-align', this.alignment),
dom.text((use) => use(row._isAddRow) ? '' : use(this.valueFormatter).format(use(value))),
),
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')
)
this.buildDropdownMenu(),
);
}
public buildConfigDom() {
return [
cssRow(
alignmentSelect(this.alignment)
),
super.buildConfigDom(),
cssLabel('OPTIONS'),
cssRow(
dom.create(ListEntry, this._choiceValues, (values) => this._choices.saveOnly(values))
@ -64,6 +48,27 @@ export class ChoiceTextBox extends NTextBox {
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() {
return this._choiceValues.get().length > 0;
}
@ -71,7 +76,6 @@ export class ChoiceTextBox extends NTextBox {
const cssChoiceField = styled('div.field_clip', `
display: flex;
justify-content: space-between;
`);
const cssChoiceText = styled('div', `
@ -86,4 +90,5 @@ const cssDropdownIcon = styled(icon, `
min-width: 16px;
width: 16px;
height: 16px;
margin-left: auto;
`);

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

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

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

@ -3,7 +3,8 @@ import isString = require('lodash/isString');
// 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';
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': [ false, "0" ],
'Choice': [ '', "''" ],
'ChoiceList': [ null, "NULL" ],
'Date': [ null, "NULL" ],
'DateTime': [ null, "NULL" ],
'Id': [ 0, "0" ],
@ -187,7 +189,8 @@ const rightType: {[key in GristType]: (value: CellValue) => boolean} = {
} else {
return false;
}
}
},
ChoiceList: isListOrNull,
};
export function isRightType(type: string): undefined | ((value: CellValue, options?: any) => boolean) {

@ -377,7 +377,7 @@ export class DocStorage implements ISQLiteDB {
* be used within main Grist application.
*/
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);
for (const row of rows) {
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;
@ -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.
*/
private static _encodeValue(
marshaller: marshal.Marshaller, sqlType: string, val: any
marshaller: marshal.Marshaller, gristType: string, sqlType: string, val: any
): Uint8Array|string|number|boolean {
const marshalled = () => {
marshaller.marshal(val);
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.
if (Array.isArray(val) || val instanceof Uint8Array || Buffer.isBuffer(val)) {
return marshalled();
@ -494,19 +501,30 @@ export class DocStorage implements ISQLiteDB {
/**
* 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)) {
val = marshal.loads(val);
}
if ((type === 'Bool' || type === 'BOOLEAN') && (val === 0 || val === 1)) {
// Boolean values come in as 0/1. If the column is of type "Bool", interpret those as
// true/false (note that the data engine does this too).
return Boolean(val);
} else {
return val;
if (gristType === 'Bool') {
if (val === 0 || val === 1) {
// Boolean values come in as 0/1. If the column is of type "Bool", interpret those as
// true/false (note that the data engine does this too).
return Boolean(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 'Text':
return 'TEXT';
case 'ChoiceList':
return 'TEXT'; // To be encoded as a JSON array of strings.
case 'Date':
return 'DATE';
case 'DateTime':
@ -842,7 +862,7 @@ export class DocStorage implements ISQLiteDB {
const type = this._getGristType(tableId, col);
const column = columnValues[col];
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;
@ -1348,6 +1368,7 @@ export class DocStorage implements ISQLiteDB {
if (!colInfo) {
return null; // Column not found.
}
const oldGristType = this._getGristType(tableId, colId);
const oldSqlType = colInfo.type || 'BLOB';
const oldDefault = colInfo.dflt_value;
const newSqlType = newColType ? DocStorage._getSqlType(newColType) : oldSqlType;
@ -1361,6 +1382,8 @@ export class DocStorage implements ISQLiteDB {
const colSpecSql = DocStorage._prefixJoin(', ', infoRows.map(DocStorage._sqlColSpecFromDBInfo));
return {
sql: `CREATE TABLE ${quoteIdent(tableId)} (id INTEGER PRIMARY KEY${colSpecSql})`,
oldGristType,
newGristType: newColType || oldGristType,
oldDefault,
newDefault,
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
// 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)} ` +
`WHERE typeof(${q(colId)}) = 'blob'`);
const marshaller = new marshal.Marshaller({version: 2});
@ -1419,8 +1442,8 @@ export class DocStorage implements ISQLiteDB {
for (const cell of cells) {
const id: number = cell.id;
const value: any = cell.value;
const decodedValue = DocStorage._decodeValue(value, result.oldSqlType);
const newValue = DocStorage._encodeValue(marshaller, result.newSqlType, decodedValue);
const decodedValue = DocStorage._decodeValue(value, result.oldGristType, result.oldSqlType);
const newValue = DocStorage._encodeValue(marshaller, result.newGristType, result.newSqlType, decodedValue);
if (!(newValue instanceof Uint8Array)) {
sqlParams.push([newValue, id]);
}
@ -1505,6 +1528,8 @@ export class DocStorage implements ISQLiteDB {
interface RebuildResult {
sql: string;
oldGristType: string;
newGristType: string;
oldDefault: string;
newDefault: string;
oldSqlType: string;

@ -1,3 +1,4 @@
import json
import types
from collections import namedtuple
@ -294,6 +295,26 @@ class PositionColumn(NumericColumn):
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):
"""
Base class for ReferenceColumn and ReferenceListColumn.
@ -386,6 +407,7 @@ class ReferenceListColumn(BaseReferenceColumn):
usertypes.BaseColumnType.ColType = DataColumn
usertypes.Reference.ColType = ReferenceColumn
usertypes.ReferenceList.ColType = ReferenceListColumn
usertypes.ChoiceList.ColType = ChoiceListColumn
usertypes.DateTime.ColType = DateTimeColumn
usertypes.Date.ColType = DateColumn
usertypes.PositionNumber.ColType = PositionColumn

@ -6,7 +6,7 @@ a consistent API accessible with only "import grist".
# These imports are used in processing generated usercode.
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 table import UserTable
from records import Record, RecordSet

@ -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
the extra complexity.
"""
import csv
import cStringIO
import datetime
import json
import six
import objtypes
from objtypes import AltText
@ -29,6 +32,7 @@ _type_defaults = {
'Blob': None,
'Bool': False,
'Choice': '',
'ChoiceList': None,
'Date': None,
'DateTime': None,
'Id': 0,
@ -319,6 +323,53 @@ class Choice(Text):
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):
"""
PositionNumber is the type for a position field used to order records in record lists.

Loading…
Cancel
Save