(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;
}
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]).

View File

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

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

View File

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

View File

@@ -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',

View File

@@ -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'),