(core) New Grist Forms styling and field options

Summary:
 - New styling for forms.
 - New field options for various field types (spinner, checkbox, radio buttons, alignment, sort).
 - Improved alignment of form fields in columns.
 - Support for additional select input keyboard shortcuts (Enter and Backspace).
 - Prevent submitting form on Enter if an input has focus.
 - Fix for changing form field type causing the field to disappear.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4223
This commit is contained in:
George Gevoian
2024-04-10 23:50:30 -07:00
parent 661f1c1804
commit 86062a8c28
35 changed files with 2037 additions and 716 deletions

View File

@@ -1,13 +1,18 @@
import {
FormFieldRulesConfig,
FormOptionsAlignmentConfig,
FormOptionsSortConfig,
} from 'app/client/components/Forms/FormConfig';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {testId} from 'app/client/ui2018/cssVars';
import {
ChoiceOptionsByName,
ChoiceTextBox,
} from 'app/client/widgets/ChoiceTextBox';
import {choiceToken} from 'app/client/widgets/ChoiceToken';
import {CellValue} from 'app/common/DocActions';
import {decodeObject} from 'app/plugin/objtypes';
import {dom, styled} from 'grainjs';
import {choiceToken} from 'app/client/widgets/ChoiceToken';
/**
* ChoiceListCell - A cell that renders a list of choice tokens.
@@ -49,6 +54,15 @@ export class ChoiceListCell extends ChoiceTextBox {
}),
);
}
public buildFormConfigDom() {
return [
this.buildChoicesConfigDom(),
dom.create(FormOptionsAlignmentConfig, this.field),
dom.create(FormOptionsSortConfig, this.field),
dom.create(FormFieldRulesConfig, this.field),
];
}
}
export const cssChoiceList = styled('div', `

View File

@@ -1,3 +1,8 @@
import {
FormFieldRulesConfig,
FormOptionsSortConfig,
FormSelectConfig,
} from 'app/client/components/Forms/FormConfig';
import {makeT} from 'app/client/lib/localization';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
@@ -76,7 +81,7 @@ export class ChoiceTextBox extends NTextBox {
public buildConfigDom() {
return [
super.buildConfigDom(),
this._buildChoicesConfigDom(),
this.buildChoicesConfigDom(),
];
}
@@ -86,14 +91,16 @@ export class ChoiceTextBox extends NTextBox {
public buildFormConfigDom() {
return [
this._buildChoicesConfigDom(),
super.buildFormConfigDom(),
this.buildChoicesConfigDom(),
dom.create(FormSelectConfig, this.field),
dom.create(FormOptionsSortConfig, this.field),
dom.create(FormFieldRulesConfig, this.field),
];
}
public buildFormTransformConfigDom() {
return [
this._buildChoicesConfigDom(),
this.buildChoicesConfigDom(),
];
}
@@ -113,7 +120,7 @@ export class ChoiceTextBox extends NTextBox {
return this.field.config.updateChoices(renames, options);
}
private _buildChoicesConfigDom() {
protected buildChoicesConfigDom() {
const disabled = Computed.create(null,
use => use(this.field.disableModify)
|| use(use(this.field.column).disableEditData)

View File

@@ -6,7 +6,7 @@ var kd = require('../lib/koDom');
var kf = require('../lib/koForm');
var AbstractWidget = require('./AbstractWidget');
const {FieldRulesConfig} = require('app/client/components/Forms/FormConfig');
const {FormFieldRulesConfig} = require('app/client/components/Forms/FormConfig');
const {fromKoSave} = require('app/client/lib/fromKoSave');
const {alignmentSelect, cssButtonSelect} = require('app/client/ui2018/buttonSelect');
const {cssLabel, cssRow} = require('app/client/ui/RightPanelStyles');
@@ -82,7 +82,7 @@ DateTextBox.prototype.buildTransformConfigDom = function() {
DateTextBox.prototype.buildFormConfigDom = function() {
return [
gdom.create(FieldRulesConfig, this.field),
gdom.create(FormFieldRulesConfig, this.field),
];
};

View File

@@ -108,12 +108,11 @@ export class FieldBuilder extends Disposable {
private readonly _widgetCons: ko.Computed<{create: (...args: any[]) => NewAbstractWidget}>;
private readonly _docModel: DocModel;
private readonly _readonly: Computed<boolean>;
private readonly _isForm: ko.Computed<boolean>;
private readonly _comments: ko.Computed<boolean>;
private readonly _showRefConfigPopup: ko.Observable<boolean>;
private readonly _isEditorActive = Observable.create(this, false);
public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec,
private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) {
super();
@@ -128,9 +127,13 @@ export class FieldBuilder extends Disposable {
this._readonly = Computed.create(this, (use) =>
use(gristDoc.isReadonly) || use(field.disableEditData) || Boolean(this._options.isPreview));
this._isForm = this.autoDispose(ko.computed(() => {
return this.field.viewSection().widgetType() === WidgetType.Form;
}));
// Observable with a list of available types.
this._availableTypes = Computed.create(this, (use) => {
const isForm = use(use(this.field.viewSection).widgetType) === WidgetType.Form;
const isForm = use(this._isForm);
const isFormula = use(this.origColumn.isFormula);
const types: Array<IOptionFull<string>> = [];
_.each(UserType.typeDefs, (def: any, key: string|number) => {
@@ -201,8 +204,11 @@ export class FieldBuilder extends Disposable {
// Returns the constructor for the widget, and only notifies subscribers on changes.
this._widgetCons = this.autoDispose(koUtil.withKoUtils(ko.computed(() => {
return UserTypeImpl.getWidgetConstructor(this.options().widget,
this._readOnlyPureType());
if (this._isForm()) {
return UserTypeImpl.getFormWidgetConstructor(this.options().widget, this._readOnlyPureType());
} else {
return UserTypeImpl.getWidgetConstructor(this.options().widget, this._readOnlyPureType());
}
})).onlyNotifyUnequal());
// Computed builder for the widget.

View File

@@ -1,14 +1,16 @@
import { FieldRulesConfig } from 'app/client/components/Forms/FormConfig';
import { FormFieldRulesConfig } from 'app/client/components/Forms/FormConfig';
import { fromKoSave } from 'app/client/lib/fromKoSave';
import { makeT } from 'app/client/lib/localization';
import { DataRowModel } from 'app/client/models/DataRowModel';
import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
import { cssRow } from 'app/client/ui/RightPanelStyles';
import { alignmentSelect, cssButtonSelect, makeButtonSelect } from 'app/client/ui2018/buttonSelect';
import { fieldWithDefault } from 'app/client/models/modelUtil';
import { FormTextFormat } from 'app/client/ui/FormAPI';
import { cssLabel, cssNumericSpinner, cssRow } from 'app/client/ui/RightPanelStyles';
import { alignmentSelect, buttonSelect, cssButtonSelect, makeButtonSelect } from 'app/client/ui2018/buttonSelect';
import { testId } from 'app/client/ui2018/cssVars';
import { makeLinks } from 'app/client/ui2018/links';
import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget';
import { Computed, dom, DomContents, fromKo, Observable } from 'grainjs';
import { makeT } from 'app/client/lib/localization';
const t = makeT('NTextBox');
@@ -60,8 +62,42 @@ export class NTextBox extends NewAbstractWidget {
}
public buildFormConfigDom(): DomContents {
const format = fieldWithDefault<FormTextFormat>(
this.field.widgetOptionsJson.prop('formTextFormat'),
'singleline'
);
const lineCount = fieldWithDefault<number|"">(
this.field.widgetOptionsJson.prop('formTextLineCount'),
''
);
return [
dom.create(FieldRulesConfig, this.field),
cssLabel(t('Field Format')),
cssRow(
buttonSelect(
fromKoSave(format),
[
{value: 'singleline', label: t('Single line')},
{value: 'multiline', label: t('Multi line')},
],
testId('tb-form-field-format'),
),
),
dom.maybe(use => use(format) === 'multiline', () =>
cssRow(
cssNumericSpinner(
fromKo(lineCount),
{
label: t('Lines'),
defaultValue: 3,
minValue: 1,
maxValue: 99,
save: async (val) => lineCount.setAndSave((val && Math.floor(val)) ?? ''),
},
),
),
),
dom.create(FormFieldRulesConfig, this.field),
];
}

View File

@@ -0,0 +1,172 @@
import {theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {clamp, numberOrDefault} from 'app/common/gutil';
import {MaybePromise} from 'app/plugin/gutil';
import {BindableValue, dom, DomElementArg, IDomArgs, makeTestId, Observable, styled} from 'grainjs';
const testId = makeTestId('test-numeric-spinner-');
export interface NumericSpinnerOptions {
/** Defaults to `false`. */
setValueOnInput?: boolean;
label?: string;
defaultValue?: number | Observable<number>;
/** No minimum if unset. */
minValue?: number;
/** No maximum if unset. */
maxValue?: number;
disabled?: BindableValue<boolean>;
inputArgs?: IDomArgs<HTMLInputElement>;
/** Called on blur and spinner button click. */
save?: (val?: number) => MaybePromise<void>,
}
export function numericSpinner(
value: Observable<number | ''>,
options: NumericSpinnerOptions = {},
...args: DomElementArg[]
) {
const {
setValueOnInput = false,
label,
defaultValue,
minValue = Number.NEGATIVE_INFINITY,
maxValue = Number.POSITIVE_INFINITY,
disabled,
inputArgs = [],
save,
} = options;
const getDefaultValue = () => {
if (defaultValue === undefined) {
return 0;
} else if (typeof defaultValue === 'number') {
return defaultValue;
} else {
return defaultValue.get();
}
};
let inputElement: HTMLInputElement;
const shiftValue = async (delta: 1 | -1, opts: {saveValue?: boolean} = {}) => {
const {saveValue} = opts;
const currentValue = numberOrDefault(inputElement.value, getDefaultValue());
const newValue = clamp(Math.floor(currentValue + delta), minValue, maxValue);
if (setValueOnInput) { value.set(newValue); }
if (saveValue) { await save?.(newValue); }
return newValue;
};
const incrementValue = (opts: {saveValue?: boolean} = {}) => shiftValue(1, opts);
const decrementValue = (opts: {saveValue?: boolean} = {}) => shiftValue(-1, opts);
return cssNumericSpinner(
disabled ? cssNumericSpinner.cls('-disabled', disabled) : null,
label ? cssNumLabel(label) : null,
inputElement = cssNumInput(
{type: 'number'},
dom.prop('value', value),
defaultValue !== undefined ? dom.prop('placeholder', defaultValue) : null,
dom.onKeyDown({
ArrowUp: async (_ev, elem) => { elem.value = String(await incrementValue()); },
ArrowDown: async (_ev, elem) => { elem.value = String(await decrementValue()); },
Enter$: async (_ev, elem) => save && elem.blur(),
}),
!setValueOnInput ? null : dom.on('input', (_ev, elem) => {
value.set(Number.parseFloat(elem.value));
}),
!save ? null : dom.on('blur', async () => {
let newValue = numberOrDefault(inputElement.value, undefined);
if (newValue !== undefined) { newValue = clamp(newValue, minValue, maxValue); }
await save(newValue);
}),
dom.on('focus', (_ev, elem) => elem.select()),
...inputArgs,
),
cssSpinner(
cssSpinnerBtn(
cssSpinnerTop('DropdownUp'),
dom.on('click', async () => incrementValue({saveValue: true})),
testId('increment'),
),
cssSpinnerBtn(
cssSpinnerBottom('Dropdown'),
dom.on('click', async () => decrementValue({saveValue: true})),
testId('decrement'),
),
),
...args
);
}
const cssNumericSpinner = styled('div', `
position: relative;
flex: auto;
font-weight: normal;
display: flex;
align-items: center;
outline: 1px solid ${theme.inputBorder};
background-color: ${theme.inputBg};
border-radius: 3px;
&-disabled {
opacity: 0.4;
pointer-events: none;
}
`);
const cssNumLabel = styled('div', `
color: ${theme.lightText};
flex-shrink: 0;
padding-left: 8px;
pointer-events: none;
`);
const cssNumInput = styled('input', `
flex-grow: 1;
padding: 4px 32px 4px 8px;
width: 100%;
text-align: right;
appearance: none;
color: ${theme.inputFg};
background-color: transparent;
border: none;
outline: none;
-moz-appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
`);
const cssSpinner = styled('div', `
position: absolute;
right: 8px;
width: 16px;
height: 100%;
display: flex;
flex-direction: column;
`);
const cssSpinnerBtn = styled('div', `
--icon-color: ${theme.controlSecondaryFg};
flex: 1 1 0px;
min-height: 0px;
position: relative;
cursor: pointer;
overflow: hidden;
&:hover {
--icon-color: ${theme.controlSecondaryHoverFg};
}
`);
const cssSpinnerTop = styled(icon, `
position: absolute;
top: 0px;
`);
const cssSpinnerBottom = styled(icon, `
position: absolute;
bottom: 0px;
`);

View File

@@ -1,23 +1,25 @@
/**
* See app/common/NumberFormat for description of options we support.
*/
import {FormFieldRulesConfig} from 'app/client/components/Forms/FormConfig';
import {fromKoSave} from 'app/client/lib/fromKoSave';
import {makeT} from 'app/client/lib/localization';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {reportError} from 'app/client/models/errors';
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {cssButtonSelect, ISelectorOption, makeButtonSelect} from 'app/client/ui2018/buttonSelect';
import {fieldWithDefault} from 'app/client/models/modelUtil';
import {FormNumberFormat} from 'app/client/ui/FormAPI';
import {cssLabel, cssNumericSpinner, cssRow} from 'app/client/ui/RightPanelStyles';
import {buttonSelect, cssButtonSelect, ISelectorOption, makeButtonSelect} from 'app/client/ui2018/buttonSelect';
import {testId, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker';
import {NTextBox} from 'app/client/widgets/NTextBox';
import {clamp} from 'app/common/gutil';
import {numberOrDefault} from 'app/common/gutil';
import {buildNumberFormat, NumberFormatOptions, NumMode, NumSign} from 'app/common/NumberFormat';
import {BindableValue, Computed, dom, DomContents, DomElementArg,
fromKo, MultiHolder, Observable, styled} from 'grainjs';
import {Computed, dom, DomContents, fromKo, MultiHolder, styled} from 'grainjs';
import * as LocaleCurrency from 'locale-currency';
const t = makeT('NumericTextBox');
const modeOptions: Array<ISelectorOption<NumMode>> = [
{value: 'currency', label: '$'},
{value: 'decimal', label: ','},
@@ -75,9 +77,10 @@ export class NumericTextBox extends NTextBox {
};
// Prepare setters for the UI elements.
// Min/max fraction digits may range from 0 to 20; other values are invalid.
const setMinDecimals = (val?: number) => setSave('decimals', val && clamp(val, 0, 20));
const setMaxDecimals = (val?: number) => setSave('maxDecimals', val && clamp(val, 0, 20));
// If defined, `val` will be a floating point number between 0 and 20; make sure it's
// saved as an integer.
const setMinDecimals = (val?: number) => setSave('decimals', val && Math.floor(val));
const setMaxDecimals = (val?: number) => setSave('maxDecimals', val && Math.floor(val));
// Mode and Sign behave as toggles: clicking a selected on deselects it.
const setMode = (val: NumMode) => setSave('numMode', val !== numMode.get() ? val : undefined);
const setSign = (val: NumSign) => setSave('numSign', val !== numSign.get() ? val : undefined);
@@ -105,16 +108,56 @@ export class NumericTextBox extends NTextBox {
]),
cssLabel(t('Decimals')),
cssRow(
decimals('min', minDecimals, defaultMin, setMinDecimals, disabled, testId('numeric-min-decimals')),
decimals('max', maxDecimals, defaultMax, setMaxDecimals, disabled, testId('numeric-max-decimals')),
cssNumericSpinner(
minDecimals,
{
label: t('min'),
minValue: 0,
maxValue: 20,
defaultValue: defaultMin,
disabled,
save: setMinDecimals,
},
testId('numeric-min-decimals'),
),
cssNumericSpinner(
maxDecimals,
{
label: t('max'),
minValue: 0,
maxValue: 20,
defaultValue: defaultMax,
disabled,
save: setMaxDecimals,
},
testId('numeric-max-decimals'),
),
),
];
}
}
function numberOrDefault<T>(value: unknown, def: T): number | T {
return value !== null && value !== undefined ? Number(value) : def;
public buildFormConfigDom(): DomContents {
const format = fieldWithDefault<FormNumberFormat>(
this.field.widgetOptionsJson.prop('formNumberFormat'),
'text'
);
return [
cssLabel(t('Field Format')),
cssRow(
buttonSelect(
fromKoSave(format),
[
{value: 'text', label: t('Text')},
{value: 'spinner', label: t('Spinner')},
],
testId('numeric-form-field-format'),
),
),
dom.create(FormFieldRulesConfig, this.field),
];
}
}
// Helper used by setSave() above to reset some properties when switching modes.
@@ -126,107 +169,6 @@ function updateOptions(prop: keyof NumberFormatOptions, value: unknown): Partial
return {};
}
function decimals(
label: string,
value: Observable<number | ''>,
defaultValue: Observable<number>,
setFunc: (val?: number) => void,
disabled: BindableValue<boolean>,
...args: DomElementArg[]
) {
return cssDecimalsBox(
cssDecimalsBox.cls('-disabled', disabled),
cssNumLabel(label),
cssNumInput({type: 'text', size: '2', min: '0'},
dom.prop('value', value),
dom.prop('placeholder', defaultValue),
dom.on('change', (ev, elem) => {
const newVal = parseInt(elem.value, 10);
// Set value explicitly before its updated via setFunc; this way the value reflects the
// observable in the case the observable is left unchanged (e.g. because of clamping).
elem.value = String(value.get());
setFunc(Number.isNaN(newVal) ? undefined : newVal);
elem.blur();
}),
dom.on('focus', (ev, elem) => elem.select()),
),
cssSpinner(
cssSpinnerBtn(cssSpinnerTop('DropdownUp'),
dom.on('click', () => setFunc(numberOrDefault(value.get(), defaultValue.get()) + 1))),
cssSpinnerBtn(cssSpinnerBottom('Dropdown'),
dom.on('click', () => setFunc(numberOrDefault(value.get(), defaultValue.get()) - 1))),
),
...args
);
}
const cssDecimalsBox = styled('div', `
position: relative;
flex: auto;
--icon-color: ${theme.lightText};
color: ${theme.lightText};
font-weight: normal;
display: flex;
align-items: center;
&:first-child {
margin-right: 16px;
}
&-disabled {
opacity: 0.4;
pointer-events: none;
}
`);
const cssNumLabel = styled('div', `
position: absolute;
padding-left: 8px;
pointer-events: none;
`);
const cssNumInput = styled('input', `
padding: 4px 32px 4px 40px;
border: 1px solid ${theme.inputBorder};
border-radius: 3px;
background-color: ${theme.inputBg};
color: ${theme.inputFg};
width: 100%;
text-align: right;
appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
`);
const cssSpinner = styled('div', `
position: absolute;
right: 8px;
width: 16px;
height: 100%;
display: flex;
flex-direction: column;
`);
const cssSpinnerBtn = styled('div', `
--icon-color: ${theme.controlSecondaryFg};
flex: 1 1 0px;
min-height: 0px;
position: relative;
cursor: pointer;
overflow: hidden;
&:hover {
--icon-color: ${theme.controlSecondaryHoverFg};
}
`);
const cssSpinnerTop = styled(icon, `
position: absolute;
top: 0px;
`);
const cssSpinnerBottom = styled(icon, `
position: absolute;
bottom: 0px;
`);
const cssModeSelect = styled(makeButtonSelect, `
flex: 4 4 0px;
background-color: ${theme.inputBg};

View File

@@ -1,3 +1,8 @@
import {
FormFieldRulesConfig,
FormOptionsSortConfig,
FormSelectConfig
} from 'app/client/components/Forms/FormConfig';
import {makeT} from 'app/client/lib/localization';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {TableRec} from 'app/client/models/DocModel';
@@ -72,7 +77,9 @@ export class Reference extends NTextBox {
public buildFormConfigDom() {
return [
this.buildTransformConfigDom(),
super.buildFormConfigDom(),
dom.create(FormSelectConfig, this.field),
dom.create(FormOptionsSortConfig, this.field),
dom.create(FormFieldRulesConfig, this.field),
];
}

View File

@@ -1,3 +1,8 @@
import {
FormFieldRulesConfig,
FormOptionsAlignmentConfig,
FormOptionsSortConfig,
} from 'app/client/components/Forms/FormConfig';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {urlState} from 'app/client/models/gristUrlState';
import {testId, theme} from 'app/client/ui2018/cssVars';
@@ -103,6 +108,15 @@ export class ReferenceList extends Reference {
}),
);
}
public buildFormConfigDom() {
return [
this.buildTransformConfigDom(),
dom.create(FormOptionsAlignmentConfig, this.field),
dom.create(FormOptionsSortConfig, this.field),
dom.create(FormFieldRulesConfig, this.field),
];
}
}
const cssRefIcon = styled(icon, `

View File

@@ -1,19 +1,44 @@
import * as commands from 'app/client/components/commands';
import { FieldRulesConfig } from 'app/client/components/Forms/FormConfig';
import { FormFieldRulesConfig } from 'app/client/components/Forms/FormConfig';
import { fromKoSave } from 'app/client/lib/fromKoSave';
import { makeT } from 'app/client/lib/localization';
import { DataRowModel } from 'app/client/models/DataRowModel';
import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
import { KoSaveableObservable } from 'app/client/models/modelUtil';
import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget';
import { fieldWithDefault, KoSaveableObservable } from 'app/client/models/modelUtil';
import { FormToggleFormat } from 'app/client/ui/FormAPI';
import { cssLabel, cssRow } from 'app/client/ui/RightPanelStyles';
import { buttonSelect } from 'app/client/ui2018/buttonSelect';
import { theme } from 'app/client/ui2018/cssVars';
import { dom, DomContents } from 'grainjs';
import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget';
import { dom, DomContents, makeTestId } from 'grainjs';
const t = makeT('Toggle');
const testId = makeTestId('test-toggle-');
/**
* ToggleBase - The base class for toggle widgets, such as a checkbox or a switch.
*/
abstract class ToggleBase extends NewAbstractWidget {
public buildFormConfigDom(): DomContents {
const format = fieldWithDefault<FormToggleFormat>(
this.field.widgetOptionsJson.prop('formToggleFormat'),
'switch'
);
return [
dom.create(FieldRulesConfig, this.field),
cssLabel(t('Field Format')),
cssRow(
buttonSelect(
fromKoSave(format),
[
{value: 'switch', label: t('Switch')},
{value: 'checkbox', label: t('Checkbox')},
],
testId('form-field-format'),
),
),
dom.create(FormFieldRulesConfig, this.field),
];
}

View File

@@ -154,6 +154,7 @@ export const typeDefs: any = {
widgets: {
TextBox: {
cons: 'TextBox',
formCons: 'Switch',
editCons: 'TextEditor',
icon: 'FieldTextbox',
options: {

View File

@@ -65,6 +65,12 @@ export function getWidgetConstructor(widget: string, type: string): WidgetConstr
return nameToWidget[config.cons as keyof typeof nameToWidget] as any;
}
/** return a good class to instantiate for viewing a form widget/type combination */
export function getFormWidgetConstructor(widget: string, type: string): WidgetConstructor {
const {config} = getWidgetConfiguration(widget, type as GristType);
return nameToWidget[(config.formCons || config.cons) as keyof typeof nameToWidget] as any;
}
/** return a good class to instantiate for editing a widget/type combination */
export function getEditorConstructor(widget: string, type: string): typeof NewBaseEditor {
const {config} = getWidgetConfiguration(widget, type as GristType);