diff --git a/app/client/components/Forms/Columns.ts b/app/client/components/Forms/Columns.ts index 5e125428..775b35e2 100644 --- a/app/client/components/Forms/Columns.ts +++ b/app/client/components/Forms/Columns.ts @@ -1,10 +1,11 @@ import {buildEditor} from 'app/client/components/Forms/Editor'; import {buildMenu} from 'app/client/components/Forms/Menu'; -import {Box, BoxModel} from 'app/client/components/Forms/Model'; +import {BoxModel} from 'app/client/components/Forms/Model'; import * as style from 'app/client/components/Forms/styles'; import {makeTestId} from 'app/client/lib/domUtils'; import {icon} from 'app/client/ui2018/icons'; import * as menus from 'app/client/ui2018/menus'; +import {Box} from 'app/common/Forms'; import {inlineStyle, not} from 'app/common/gutil'; import {bundleChanges, Computed, dom, IDomArgs, MultiHolder, Observable, styled} from 'grainjs'; @@ -30,8 +31,7 @@ export class ColumnsModel extends BoxModel { if (!this.parent) { throw new Error('No parent'); } // We need to remove it from the parent, so find it first. - const droppedId = dropped.id; - const droppedRef = this.root().get(droppedId); + const droppedRef = dropped.id ? this.root().get(dropped.id) : null; // Now we simply insert it after this box. droppedRef?.removeSelf(); @@ -165,6 +165,10 @@ export class PlaceholderModel extends BoxModel { } } +export function Paragraph(text: string, alignment?: 'left'|'right'|'center'): Box { + return {type: 'Paragraph', text, alignment}; +} + export function Placeholder(): Box { return {type: 'Placeholder'}; } diff --git a/app/client/components/Forms/Field.ts b/app/client/components/Forms/Field.ts index 8cf05477..08ae6e04 100644 --- a/app/client/components/Forms/Field.ts +++ b/app/client/components/Forms/Field.ts @@ -1,12 +1,13 @@ import {buildEditor} from 'app/client/components/Forms/Editor'; import {FormView} from 'app/client/components/Forms/FormView'; -import {Box, BoxModel, ignoreClick} from 'app/client/components/Forms/Model'; +import {BoxModel, ignoreClick} from 'app/client/components/Forms/Model'; import * as css from 'app/client/components/Forms/styles'; import {stopEvent} from 'app/client/lib/domUtils'; import {refRecord} from 'app/client/models/DocModel'; import {autoGrow} from 'app/client/ui/forms'; import {squareCheckbox} from 'app/client/ui2018/checkbox'; import {colors} from 'app/client/ui2018/cssVars'; +import {Box} from 'app/common/Forms'; import {Constructor} from 'app/common/gutil'; import { BindableValue, @@ -63,7 +64,11 @@ export class FieldModel extends BoxModel { * Field row id. */ public get leaf() { - return this.props['leaf'] as Observable; + return this.prop('leaf') as Observable; + } + + public get required() { + return this.prop('formRequired', false) as Observable; } /** @@ -260,41 +265,47 @@ class TextModel extends Question { } class ChoiceModel extends Question { - public renderInput() { + protected choices: Computed = Computed.create(this, use => { + // Read choices from field. + const list = use(use(use(this.model.field).origCol).widgetOptionsJson.prop('choices')) || []; + + // Make sure it is array of strings. + if (!Array.isArray(list) || list.some((v) => typeof v !== 'string')) { + return []; + } + return list; + }); + + protected choicesWithEmpty = Computed.create(this, use => { + const list = Array.from(use(this.choices)); + // Add empty choice if not present. + if (list.length === 0 || list[0] !== '') { + list.unshift(''); + } + return list; + }); + + public renderInput(): HTMLElement { const field = this.model.field; - const choices: Computed = Computed.create(this, use => { - return use(use(use(field).origCol).widgetOptionsJson.prop('choices')) || []; - }); - const typedChoices = Computed.create(this, use => { - const value = use(choices); - // Make sure it is array of strings. - if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) { - return []; - } - return value; - }); return css.cssSelect( {tabIndex: "-1"}, ignoreClick, dom.prop('name', use => use(use(field).colId)), - dom.forEach(typedChoices, (choice) => dom('option', choice, {value: choice})), + dom.forEach(this.choicesWithEmpty, (choice) => dom('option', choice, {value: choice})), ); } } -class ChoiceListModel extends Question { +class ChoiceListModel extends ChoiceModel { public renderInput() { const field = this.model.field; - const choices: Computed = Computed.create(this, use => { - return use(use(use(field).origCol).widgetOptionsJson.prop('choices')) || []; - }); return dom('div', dom.prop('name', use => use(use(field).colId)), - dom.forEach(choices, (choice) => css.cssCheckboxLabel( + dom.forEach(this.choices, (choice) => css.cssCheckboxLabel( squareCheckbox(observable(false)), choice )), - dom.maybe(use => use(choices).length === 0, () => [ + dom.maybe(use => use(this.choices).length === 0, () => [ dom('div', 'No choices defined'), ]), ); @@ -393,12 +404,19 @@ class RefListModel extends Question { } class RefModel extends RefListModel { + protected withEmpty = Computed.create(this, use => { + const list = Array.from(use(this.choices)); + // Add empty choice if not present. + list.unshift([0, '']); + return list; + }); + public renderInput() { return css.cssSelect( {tabIndex: "-1"}, ignoreClick, dom.prop('name', this.model.colId), - dom.forEach(this.choices, (choice) => dom('option', String(choice[1] ?? ''), {value: String(choice[0])})), + dom.forEach(this.withEmpty, (choice) => dom('option', String(choice[1] ?? ''), {value: String(choice[0])})), ); } } diff --git a/app/client/components/Forms/FormView.ts b/app/client/components/Forms/FormView.ts index aff01889..ca5b0e34 100644 --- a/app/client/components/Forms/FormView.ts +++ b/app/client/components/Forms/FormView.ts @@ -3,7 +3,7 @@ import * as commands from 'app/client/components/commands'; import {Cursor} from 'app/client/components/Cursor'; import * as components from 'app/client/components/Forms/elements'; import {NewBox} from 'app/client/components/Forms/Menu'; -import {Box, BoxModel, BoxType, LayoutModel, parseBox, Place} from 'app/client/components/Forms/Model'; +import {BoxModel, LayoutModel, parseBox, Place} from 'app/client/components/Forms/Model'; import * as style from 'app/client/components/Forms/styles'; import {GristDoc} from 'app/client/components/GristDoc'; import {copyToClipboard} from 'app/client/lib/clipboardUtils'; @@ -20,7 +20,7 @@ import {showTransientTooltip} from 'app/client/ui/tooltips'; import {cssButton} from 'app/client/ui2018/buttons'; import {icon} from 'app/client/ui2018/icons'; import {confirmModal} from 'app/client/ui2018/modals'; -import {INITIAL_FIELDS_COUNT} from "app/common/Forms"; +import {Box, BoxType, INITIAL_FIELDS_COUNT} from "app/common/Forms"; import {Events as BackboneEvents} from 'backbone'; import {Computed, dom, Holder, IDomArgs, Observable} from 'grainjs'; import defaults from 'lodash/defaults'; diff --git a/app/client/components/Forms/Menu.ts b/app/client/components/Forms/Menu.ts index 0d7a2cb8..0a96f4bf 100644 --- a/app/client/components/Forms/Menu.ts +++ b/app/client/components/Forms/Menu.ts @@ -1,12 +1,13 @@ import {allCommands} from 'app/client/components/commands'; +import * as components from 'app/client/components/Forms/elements'; import {FormView} from 'app/client/components/Forms/FormView'; -import {BoxModel, BoxType, Place} from 'app/client/components/Forms/Model'; +import {BoxModel, Place} from 'app/client/components/Forms/Model'; import {makeTestId, stopEvent} from 'app/client/lib/domUtils'; import {FocusLayer} from 'app/client/lib/FocusLayer'; import {makeT} from 'app/client/lib/localization'; import {getColumnTypes as getNewColumnTypes} from 'app/client/ui/GridViewMenus'; import * as menus from 'app/client/ui2018/menus'; -import * as components from 'app/client/components/Forms/elements'; +import {BoxType} from 'app/common/Forms'; import {Computed, dom, IDomArgs, MultiHolder} from 'grainjs'; const t = makeT('FormView'); @@ -140,8 +141,9 @@ export function buildMenu(props: Props, ...args: IDomArgs): IDomArg ]), menus.menuDivider(), menus.menuSubHeader(t('Building blocks')), - menus.menuItem(where(struct('Columns')), menus.menuIcon('Columns'), t("Columns")), + menus.menuItem(where(struct('Header')), menus.menuIcon('Headband'), t("Header")), menus.menuItem(where(struct('Paragraph')), menus.menuIcon('Paragraph'), t("Paragraph")), + menus.menuItem(where(struct('Columns')), menus.menuIcon('Columns'), t("Columns")), menus.menuItem(where(struct('Separator')), menus.menuIcon('Separator'), t("Separator")), ]; }; diff --git a/app/client/components/Forms/Model.ts b/app/client/components/Forms/Model.ts index 2a6d013d..63c6c775 100644 --- a/app/client/components/Forms/Model.ts +++ b/app/client/components/Forms/Model.ts @@ -1,22 +1,10 @@ import * as elements from 'app/client/components/Forms/elements'; import {FormView} from 'app/client/components/Forms/FormView'; +import {Box, BoxType} from 'app/common/Forms'; import {bundleChanges, Computed, Disposable, dom, IDomArgs, MutableObsArray, obsArray, Observable} from 'grainjs'; import {v4 as uuidv4} from 'uuid'; type Callback = () => Promise; -export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' - | 'Placeholder' | 'Layout' | 'Field' | 'Label' - | 'Separator' - ; - -/** - * Box model is a JSON that represents a form element. Every element can be converted to this element and every - * ViewModel should be able to read it and built itself from it. - */ -export interface Box extends Record { - type: BoxType, - children?: Array, -} /** * A place where to insert a box. @@ -59,10 +47,6 @@ export abstract class BoxModel extends Disposable { * List of children boxes. */ public children: MutableObsArray; - /** - * Any other dynamically added properties (that are not concrete fields in the derived classes) - */ - public props: Record> = {}; /** * Publicly exposed state if the element was just cut. * TODO: this should be moved to FormView, as this model doesn't care about that. @@ -70,6 +54,11 @@ export abstract class BoxModel extends Disposable { public cut = Observable.create(this, false); public selected: Observable; + + /** + * Any other dynamically added properties (that are not concrete fields in the derived classes) + */ + private _props: Record> = {}; /** * Don't use it directly, use the BoxModel.new factory method instead. */ @@ -163,7 +152,7 @@ export abstract class BoxModel extends Disposable { } // We need to remove it from the parent, so find it first. const droppedId = dropped.id; - const droppedRef = this.root().get(droppedId); + const droppedRef = droppedId ? this.root().get(droppedId) : null; if (droppedRef) { droppedRef.removeSelf(); } @@ -171,14 +160,14 @@ export abstract class BoxModel extends Disposable { } public prop(name: string, defaultValue?: any) { - if (!this.props[name]) { - this.props[name] = Observable.create(this, defaultValue ?? null); + if (!this._props[name]) { + this._props[name] = Observable.create(this, defaultValue ?? null); } - return this.props[name]; + return this._props[name]; } public hasProp(name: string) { - return this.props.hasOwnProperty(name); + return this._props.hasOwnProperty(name); } public async save(before?: () => Promise): Promise { @@ -306,7 +295,8 @@ export abstract class BoxModel extends Disposable { } // Update all properties of self. - for (const key in boxDef) { + for (const someKey in boxDef) { + const key = someKey as keyof Box; // Skip some keys. if (key === 'id' || key === 'type' || key === 'children') { continue; } // Skip any inherited properties. @@ -347,7 +337,7 @@ export abstract class BoxModel extends Disposable { id: this.id, type: this.type, children: this.children.get().map(child => child?.toJSON() || null), - ...(Object.fromEntries(Object.entries(this.props).map(([key, val]) => [key, val.get()]))), + ...(Object.fromEntries(Object.entries(this._props).map(([key, val]) => [key, val.get()]))), }; } diff --git a/app/client/components/Forms/Section.ts b/app/client/components/Forms/Section.ts index 4a5a12c7..e11e7627 100644 --- a/app/client/components/Forms/Section.ts +++ b/app/client/components/Forms/Section.ts @@ -1,9 +1,10 @@ import * as style from './styles'; import {buildEditor} from 'app/client/components/Forms/Editor'; import {buildMenu} from 'app/client/components/Forms/Menu'; -import {Box, BoxModel} from 'app/client/components/Forms/Model'; -import {dom, styled} from 'grainjs'; +import {BoxModel} from 'app/client/components/Forms/Model'; import {makeTestId} from 'app/client/lib/domUtils'; +import {Box} from 'app/common/Forms'; +import {dom, styled} from 'grainjs'; const testId = makeTestId('test-forms-'); @@ -53,8 +54,7 @@ export class SectionModel extends BoxModel { return null; } // We need to remove it from the parent, so find it first. - const droppedId = dropped.id; - const droppedRef = this.root().get(droppedId); + const droppedRef = dropped.id ? this.root().get(dropped.id) : null; if (droppedRef) { droppedRef.removeSelf(); } diff --git a/app/client/components/Forms/elements.ts b/app/client/components/Forms/elements.ts index 6e68def6..7f667c79 100644 --- a/app/client/components/Forms/elements.ts +++ b/app/client/components/Forms/elements.ts @@ -1,5 +1,5 @@ -import {Columns, Placeholder} from 'app/client/components/Forms/Columns'; -import {Box, BoxType} from 'app/client/components/Forms/Model'; +import {Columns, Paragraph, Placeholder} from 'app/client/components/Forms/Columns'; +import {Box, BoxType} from 'app/common/Forms'; /** * Add any other element you whish to use in the form here. * FormView will look for any exported BoxModel derived class in format `type` + `Model`, and use It @@ -16,10 +16,8 @@ export function defaultElement(type: BoxType): Box { switch(type) { case 'Columns': return Columns(); case 'Placeholder': return Placeholder(); - case 'Separator': return { - type: 'Paragraph', - text: '---', - }; + case 'Separator': return Paragraph('---'); + case 'Header': return Paragraph('## **Header**', 'center'); default: return {type}; } } diff --git a/app/client/models/features.ts b/app/client/models/features.ts index d5053724..353fe0a3 100644 --- a/app/client/models/features.ts +++ b/app/client/models/features.ts @@ -33,7 +33,3 @@ export function PERMITTED_CUSTOM_WIDGETS(): Observable { } return G.window.PERMITTED_CUSTOM_WIDGETS; } - -export function GRIST_FORMS_FEATURE() { - return Boolean(getGristConfig().experimentalPlugins); -} diff --git a/app/client/ui/PageWidgetPicker.ts b/app/client/ui/PageWidgetPicker.ts index 16c53174..6061ef35 100644 --- a/app/client/ui/PageWidgetPicker.ts +++ b/app/client/ui/PageWidgetPicker.ts @@ -3,7 +3,7 @@ import {GristDoc} from 'app/client/components/GristDoc'; import {makeT} from 'app/client/lib/localization'; import {reportError} from 'app/client/models/AppModel'; import {ColumnRec, TableRec, ViewSectionRec} from 'app/client/models/DocModel'; -import {GRIST_FORMS_FEATURE, PERMITTED_CUSTOM_WIDGETS} from "app/client/models/features"; +import {PERMITTED_CUSTOM_WIDGETS} from "app/client/models/features"; import {GristTooltips} from 'app/client/ui/GristTooltips'; import {linkId, NoLink} from 'app/client/ui/selectBy'; import {overflowTooltip, withInfoTooltip} from 'app/client/ui/tooltips'; @@ -98,21 +98,17 @@ export interface IOptions extends ISelectOptions { const testId = makeTestId('test-wselect-'); -function maybeForms(): Array<'form'> { - return GRIST_FORMS_FEATURE() ? ['form'] : []; -} - // The picker disables some choices that do not make much sense. This function return the list of // compatible types given the tableId and whether user is creating a new page or not. function getCompatibleTypes(tableId: TableRef, isNewPage: boolean|undefined): IWidgetType[] { if (tableId !== 'New Table') { - return ['record', 'single', 'detail', 'chart', 'custom', 'custom.calendar', ...maybeForms()]; + return ['record', 'single', 'detail', 'chart', 'custom', 'custom.calendar', 'form']; } else if (isNewPage) { // New view + new table means we'll be switching to the primary view. - return ['record', ...maybeForms()]; + return ['record', 'form']; } else { // The type 'chart' makes little sense when creating a new table. - return ['record', 'single', 'detail', ...maybeForms()]; + return ['record', 'single', 'detail', 'form']; } } @@ -275,7 +271,7 @@ const permittedCustomWidgets: IAttachedCustomWidget[] = PERMITTED_CUSTOM_WIDGETS const finalListOfCustomWidgetToShow = permittedCustomWidgets.filter(a=> registeredCustomWidgets.includes(a)); const sectionTypes: IWidgetType[] = [ - 'record', 'single', 'detail', ...maybeForms(), 'chart', ...finalListOfCustomWidgetToShow, 'custom' + 'record', 'single', 'detail', 'form', 'chart', ...finalListOfCustomWidgetToShow, 'custom' ]; diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index b00090a1..023fffe4 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -67,7 +67,8 @@ import { MultiHolder, Observable, styled, - subscribe + subscribe, + toKo } from 'grainjs'; import * as ko from 'knockout'; @@ -955,12 +956,25 @@ export class RightPanel extends Disposable { return vsi && vsi.activeFieldBuilder(); })); - const formView = owner.autoDispose(ko.computed(() => { + // Sorry for the acrobatics below, but grainjs are not reentred when the active section changes. + const viewInstance = owner.autoDispose(ko.computed(() => { const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance(); - return (vsi ?? null) as FormView|null; + if (!vsi || vsi.isDisposed() || !toKo(ko, this._isForm)) { return null; } + return vsi; + })); + + const formView = owner.autoDispose(ko.computed(() => { + const view = viewInstance() as unknown as FormView; + if (!view || !view.selectedBox) { return null; } + return view; })); - const selectedBox = Computed.create(owner, (use) => use(formView) && use(use(formView)!.selectedBox)); + const selectedBox = owner.autoDispose(ko.pureComputed(() => { + const view = formView(); + if (!view) { return null; } + const box = toKo(ko, view.selectedBox)(); + return box; + })); const selectedField = Computed.create(owner, (use) => { const box = use(selectedBox); if (!box) { return null; } @@ -983,33 +997,38 @@ export class RightPanel extends Disposable { } }); - return cssSection( + return domAsync(imports.loadViewPane().then(() => buildConfigContainer(cssSection( // Field config. - dom.maybe(selectedField, (field) => { + dom.maybeOwned(selectedField, (scope, field) => { const requiredField = field.widgetOptionsJson.prop('formRequired'); // V2 thing. // const hiddenField = field.widgetOptionsJson.prop('formHidden'); const defaultField = field.widgetOptionsJson.prop('formDefault'); const toComputed = (obs: typeof defaultField) => { - const result = Computed.create(null, (use) => use(obs)); + const result = Computed.create(scope, (use) => use(obs)); result.onWrite(val => obs.setAndSave(val)); return result; }; + const fieldTitle = field.widgetOptionsJson.prop('question'); + return [ cssLabel(t("Field title")), cssRow( cssTextInput( - fromKo(field.label), - (val) => field.displayLabel.saveOnly(val), + fromKo(fieldTitle), + (val) => fieldTitle.saveOnly(val).catch(reportError), dom.prop('readonly', use => use(field.disableModify)), + dom.prop('placeholder', use => use(field.displayLabel) || use(field.colId)), + testId('field-title'), ), ), cssLabel(t("Table column name")), cssRow( cssTextInput( - fromKo(field.colId), - (val) => field.column().colId.saveOnly(val), + fromKo(field.displayLabel), + (val) => field.displayLabel.saveOnly(val).catch(reportError), dom.prop('readonly', use => use(field.disableModify)), + testId('field-label'), ), ), // TODO: this is for V1 as it requires full cell editor here. @@ -1038,7 +1057,11 @@ export class RightPanel extends Disposable { ]), cssSeparator(), cssLabel(t("Field rules")), - cssRow(labeledSquareCheckbox(toComputed(requiredField), t("Required field")),), + cssRow(labeledSquareCheckbox( + toComputed(requiredField), + t("Required field"), + testId('field-required'), + )), // V2 thing // cssRow(labeledSquareCheckbox(toComputed(hiddenField), t("Hidden field")),), ]; @@ -1071,7 +1094,7 @@ export class RightPanel extends Disposable { dom.maybe(u => !u(selectedColumn) && !u(selectedBox), () => [ cssLabel(t('Layout')), ]) - ); + )))); } } diff --git a/app/client/ui/ViewLayoutMenu.ts b/app/client/ui/ViewLayoutMenu.ts index 94275e9f..e7714f3f 100644 --- a/app/client/ui/ViewLayoutMenu.ts +++ b/app/client/ui/ViewLayoutMenu.ts @@ -2,7 +2,6 @@ import {hooks} from 'app/client/Hooks'; import {makeT} from 'app/client/lib/localization'; import {allCommands} from 'app/client/components/commands'; import {ViewSectionRec} from 'app/client/models/DocModel'; -import {GRIST_FORMS_FEATURE} from 'app/client/models/features'; import {urlState} from 'app/client/models/gristUrlState'; import {testId} from 'app/client/ui2018/cssVars'; import {menuDivider, menuItemCmd, menuItemLink} from 'app/client/ui2018/menus'; @@ -96,7 +95,7 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool menuItemCmd(allCommands.viewTabOpen, t("Widget options"), testId('widget-options')), menuItemCmd(allCommands.sortFilterTabOpen, t("Advanced Sort & Filter"), dom.hide(viewSection.isRecordCard)), menuItemCmd(allCommands.dataSelectionTabOpen, t("Data selection"), dom.hide(viewSection.isRecordCard)), - !GRIST_FORMS_FEATURE() ? null : menuItemCmd(allCommands.createForm, t("Create a form"), dom.show(isTable)), + menuItemCmd(allCommands.createForm, t("Create a form"), dom.show(isTable)), ]), menuDivider(dom.hide(viewSection.isRecordCard)), diff --git a/app/client/ui2018/IconList.ts b/app/client/ui2018/IconList.ts index a20720ad..a07a0c36 100644 --- a/app/client/ui2018/IconList.ts +++ b/app/client/ui2018/IconList.ts @@ -79,6 +79,7 @@ export type IconName = "ChartArea" | "FunctionResult" | "GreenArrow" | "Grow" | + "Headband" | "Heart" | "Help" | "Home" | @@ -234,6 +235,7 @@ export const IconList: IconName[] = ["ChartArea", "FunctionResult", "GreenArrow", "Grow", + "Headband", "Heart", "Help", "Home", diff --git a/app/common/Forms.ts b/app/common/Forms.ts index 1df20895..6a2415df 100644 --- a/app/common/Forms.ts +++ b/app/common/Forms.ts @@ -12,8 +12,10 @@ import {marked} from 'marked'; /** * All allowed boxes. */ -export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' | 'Placeholder' | 'Layout' | 'Field' | - 'Label'; +export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' + | 'Placeholder' | 'Layout' | 'Field' | 'Label' + | 'Separator' | 'Header' + ; /** * Number of fields to show in the form by default. @@ -24,7 +26,7 @@ export const INITIAL_FIELDS_COUNT = 9; * Box model is a JSON that represents a form element. Every element can be converted to this element and every * ViewModel should be able to read it and built itself from it. */ -export interface Box extends Record { +export interface Box { type: BoxType, children?: Array, @@ -33,6 +35,18 @@ export interface Box extends Record { successURL?: string, successText?: string, anotherResponse?: boolean, + + // Unique ID of the field, used only in UI. + id?: string, + + // Some properties used by fields and stored in the column/field. + formRequired?: boolean, + // Used by Label and Paragraph. + text?: string, + // Used by Paragraph. + alignment?: string, + // Used by Field. + leaf?: number, } /** @@ -83,10 +97,9 @@ export class RenderBox { class Label extends RenderBox { public override async toHTML() { - const text = this.box['text']; - const cssClass = this.box['cssClass'] || ''; + const text = this.box.text || ''; return ` -
${text || ''}
+
${text || ''}
`; } } @@ -160,7 +173,7 @@ class Field extends RenderBox { } public async toHTML() { - const field = this.ctx.field(this.box['leaf']); + const field = this.box.leaf ? this.ctx.field(this.box.leaf) : null; if (!field) { return `
Field not found
`; } @@ -232,6 +245,8 @@ class Choice extends BaseQuestion { public input(field: FieldModel, context: RenderContext): string { const required = field.options.formRequired ? 'required' : ''; const choices: string[] = field.options.choices || []; + // Insert empty option. + choices.unshift(''); return ` @@ -288,16 +303,20 @@ class ChoiceList extends BaseQuestion { class RefList extends BaseQuestion { public async input(field: FieldModel, context: RenderContext) { + const required = field.options.formRequired ? 'required' : ''; const choices: [number, CellValue][] = (await field.values()) ?? []; // Sort by the second value, which is the display value. choices.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); - // Support for 20 choices, TODO: make it dynamic. - choices.splice(20); + // Support for 30 choices, TODO: make it dynamic. + choices.splice(30); return ` -
+
${choices.map((choice) => `