(core) Form kanban tasks

Summary:
- Open all links in a new tab
- Excluding not filled columns (to fix trigger formulas)
- Fixed Ref/RefList submission
- Removing redundant type definitions for Box
- Adding header menu item
- Default empty values in select control

Test Plan: Updated

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D4166
This commit is contained in:
Jarosław Sadziński
2024-01-23 21:52:57 +01:00
parent 007c4492dc
commit 95c0441d84
22 changed files with 363 additions and 124 deletions

View File

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

View File

@@ -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<number>;
return this.prop('leaf') as Observable<number>;
}
public get required() {
return this.prop('formRequired', false) as Observable<boolean|undefined>;
}
/**
@@ -260,41 +265,47 @@ class TextModel extends Question {
}
class ChoiceModel extends Question {
public renderInput() {
protected choices: Computed<string[]> = 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<string[]> = 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<string[]> = 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])})),
);
}
}

View File

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

View File

@@ -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<HTMLElement>): 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")),
];
};

View File

@@ -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<void>;
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<string, any> {
type: BoxType,
children?: Array<Box>,
}
/**
* A place where to insert a box.
@@ -59,10 +47,6 @@ export abstract class BoxModel extends Disposable {
* List of children boxes.
*/
public children: MutableObsArray<BoxModel>;
/**
* Any other dynamically added properties (that are not concrete fields in the derived classes)
*/
public props: Record<string, Observable<any>> = {};
/**
* 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<boolean>;
/**
* Any other dynamically added properties (that are not concrete fields in the derived classes)
*/
private _props: Record<string, Observable<any>> = {};
/**
* 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<void>): Promise<void> {
@@ -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()]))),
};
}

View File

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

View File

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

View File

@@ -33,7 +33,3 @@ export function PERMITTED_CUSTOM_WIDGETS(): Observable<string[]> {
}
return G.window.PERMITTED_CUSTOM_WIDGETS;
}
export function GRIST_FORMS_FEATURE() {
return Boolean(getGristConfig().experimentalPlugins);
}

View File

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

View File

@@ -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 selectedBox = Computed.create(owner, (use) => use(formView) && use(use(formView)!.selectedBox));
const formView = owner.autoDispose(ko.computed(() => {
const view = viewInstance() as unknown as FormView;
if (!view || !view.selectedBox) { return null; }
return view;
}));
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')),
])
);
))));
}
}

View File

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

View File

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

View File

@@ -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<string, any> {
export interface Box {
type: BoxType,
children?: Array<Box>,
@@ -33,6 +35,18 @@ export interface Box extends Record<string, any> {
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 `
<div class="grist-label ${cssClass}">${text || ''}</div>
<div class="grist-label">${text || ''}</div>
`;
}
}
@@ -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 `<div class="grist-field">Field not found</div>`;
}
@@ -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 `
<select name='${field.colId}' ${required} >
${choices.map((choice) => `<option value='${choice}'>${choice}</option>`).join('')}
@@ -272,7 +287,7 @@ class ChoiceList extends BaseQuestion {
const required = field.options.formRequired ? 'required' : '';
const choices: string[] = field.options.choices || [];
return `
<div name='${field.colId}' class='grist-choice-list ${required}'>
<div name='${field.colId}' class='grist-choice-list grist-checkbox-list ${required}'>
${choices.map((choice) => `
<label>
<input type='checkbox' name='${field.colId}[]' value='${choice}' />
@@ -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 `
<div name='${field.colId}' class='grist-ref-list'>
<div name='${field.colId}' class='grist-ref-list grist-checkbox-list ${required}'>
${choices.map((choice) => `
<label class='grist-checkbox'>
<input type='checkbox' name='${field.colId}[]' value='${String(choice[0])}' />
<input type='checkbox'
data-grist-type='${field.type}'
name='${field.colId}[]'
value='${String(choice[0])}' />
<span>
${String(choice[1] ?? '')}
</span>
@@ -310,14 +329,17 @@ class RefList extends BaseQuestion {
class Ref extends BaseQuestion {
public async input(field: FieldModel) {
const choices: [number, CellValue][] = (await field.values()) ?? [];
const choices: [number|string, 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 1000 choices, TODO: make it dynamic.
choices.splice(1000);
// Insert empty option.
choices.unshift(['', '']);
// <option type='number' is not standard, we parse it ourselves.
const required = field.options.formRequired ? 'required' : '';
return `
<select name='${field.colId}' class='grist-ref' data-grist-type='${field.type}'>
<select name='${field.colId}' class='grist-ref' data-grist-type='${field.type}' ${required}>
${choices.map((choice) => `<option value='${String(choice[0])}'>${String(choice[1] ?? '')}</option>`).join('')}
</select>
`;
@@ -351,4 +373,8 @@ const elements = {
'Layout': Layout,
'Field': Field,
'Label': Label,
// Those are just aliases for Paragraph.
'Separator': Paragraph,
'Header': Paragraph,
};

View File

@@ -166,6 +166,7 @@ handlebars.registerHelper('dompurify', (html: string) => {
return new handlebars.SafeString(`
<script data-html="${handlebars.escapeExpression(html)}">
document.write(DOMPurify.sanitize(document.currentScript.getAttribute('data-html')));
document.currentScript.remove(); // remove the script tag so it is easier to inspect the DOM
</script>
`);
});