mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
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;
|
||||
--icon-color: ${colors.slate};
|
||||
&:hover {
|
||||
--icon-color: ${colors.dark};
|
||||
}
|
||||
.${cssTokenField.className}.token-dragactive & {
|
||||
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>;
|
||||
|
72
app/client/widgets/ChoiceListCell.ts
Normal file
72
app/client/widgets/ChoiceListCell.ts
Normal 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;
|
||||
}
|
||||
`);
|
283
app/client/widgets/ChoiceListEditor.ts
Normal file
283
app/client/widgets/ChoiceListEditor.ts
Normal 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)
|
||||
`);
|
@ -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…
Reference in New Issue
Block a user