import {FormLayoutNode, SELECT_PLACEHOLDER} from 'app/client/components/FormRenderer'; import {buildEditor} from 'app/client/components/Forms/Editor'; import {FormView} from 'app/client/components/Forms/FormView'; 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 {makeT} from 'app/client/lib/localization'; import {refRecord} from 'app/client/models/DocModel'; import { FormNumberFormat, FormOptionsAlignment, FormOptionsSortOrder, FormSelectFormat, FormTextFormat, FormToggleFormat, } from 'app/client/ui/FormAPI'; import {autoGrow} from 'app/client/ui/forms'; import {cssCheckboxSquare, cssLabel, squareCheckbox} from 'app/client/ui2018/checkbox'; import {colors} from 'app/client/ui2018/cssVars'; import {cssRadioInput} from 'app/client/ui2018/radio'; import {isBlankValue} from 'app/common/gristTypes'; import {Constructor, not} from 'app/common/gutil'; import { BindableValue, Computed, Disposable, dom, DomContents, DomElementArg, IDomArgs, makeTestId, MultiHolder, observable, Observable, toKo, } from 'grainjs'; import * as ko from 'knockout'; const testId = makeTestId('test-forms-'); const t = makeT('FormView'); /** * Container class for all fields. */ export class FieldModel extends BoxModel { /** * Edit mode, (only one element can be in edit mode in the form editor). */ public edit = Observable.create(this, false); public fieldRef = this.autoDispose(ko.pureComputed(() => toKo(ko, this.leaf)())); public field = refRecord(this.view.gristDoc.docModel.viewFields, this.fieldRef); public colId = Computed.create(this, (use) => use(use(this.field).colId)); public column = Computed.create(this, (use) => use(use(this.field).column)); public required: Computed; public question = Computed.create(this, (use) => { const field = use(this.field); if (field.isDisposed() || use(field.id) === 0) { return ''; } return use(field.question) || use(field.origLabel); }); public description = Computed.create(this, (use) => { const field = use(this.field); return use(field.description); }); /** * Column type of the field. */ public colType = Computed.create(this, (use) => { const field = use(this.field); return use(use(field.column).pureType); }); /** * Field row id. */ public get leaf() { return this.prop('leaf') as Observable; } /** * A renderer of question instance. */ public renderer = Computed.create(this, (use) => { const ctor = fieldConstructor(use(this.colType)); const instance = new ctor(this); use.owner.autoDispose(instance); return instance; }); constructor(box: FormLayoutNode, parent: BoxModel | null, view: FormView) { super(box, parent, view); this.required = Computed.create(this, (use) => { const field = use(this.field); return Boolean(use(field.widgetOptionsJson.prop('formRequired'))); }); this.question.onWrite(value => { this.field.peek().question.setAndSave(value).catch(reportError); }); this.autoDispose( this.selected.addListener((now, then) => { if (!now && then) { setImmediate(() => !this.edit.isDisposed() && this.edit.set(false)); } }) ); } public override render(...args: IDomArgs): HTMLElement { // Updated question is used for editing, we don't save on every key press, but only on blur (or enter, etc). const save = (value: string) => { value = value?.trim(); // If question is empty or same as original, don't save. if (!value || value === this.field.peek().question()) { return; } this.field.peek().question.setAndSave(value).catch(reportError); }; const overlay = Observable.create(null, true); const content = dom.domComputed(this.renderer, (r) => r.buildDom({ edit: this.edit, overlay, onSave: save, })); return buildEditor({ box: this, overlay, removeIcon: 'CrossBig', removeTooltip: 'Hide', editMode: this.edit, content, }, dom.on('dblclick', () => this.selected.get() && this.edit.set(true)), ...args ); } public async deleteSelf() { const rowId = this.field.peek().id.peek(); const view = this.view; const root = this.root(); this.removeSelf(); // The order here matters for undo. await root.save(async () => { // Make sure to save first layout without this field, otherwise the undo won't work properly. await root.save(); // We are disposed at this point, be still can access the view. if (rowId) { await view.viewSection.removeField(rowId); } }); } } export abstract class Question extends Disposable { protected field = this.model.field; constructor(public model: FieldModel) { super(); } public buildDom(props: { edit: Observable, overlay: Observable, onSave: (value: string) => void, }, ...args: IDomArgs) { return css.cssQuestion( testId('question'), testType(this.model.colType), this.renderLabel(props), this.renderInput(), css.cssQuestion.cls('-required', this.model.required), ...args ); } public abstract renderInput(): DomContents; protected renderLabel(props: { edit: Observable, onSave: (value: string) => void, }, ...args: DomElementArg[]) { const {edit, onSave} = props; const scope = new MultiHolder(); // When in edit, we will update a copy of the question. const draft = Observable.create(scope, this.model.question.get()); scope.autoDispose( this.model.question.addListener(q => draft.set(q)), ); const controller = Computed.create(scope, (use) => use(draft)); controller.onWrite(value => { if (this.isDisposed() || draft.isDisposed()) { return; } if (!edit.get()) { return; } draft.set(value); }); // Wire up save method. const saveDraft = (ok: boolean) => { if (this.isDisposed() || draft.isDisposed()) { return; } if (!ok || !edit.get() || !controller.get()) { controller.set(this.model.question.get()); return; } onSave(controller.get()); }; let element: HTMLTextAreaElement; scope.autoDispose( props.edit.addListener((now, then) => { if (now && !then) { // When we go into edit mode, we copy the question into draft. draft.set(this.model.question.get()); // And focus on the element. setTimeout(() => { element?.focus(); element?.select(); }, 10); } }) ); return [ dom.autoDispose(scope), css.cssRequiredWrapper( testId('label'), // When in edit - hide * and change display from grid to display css.cssRequiredWrapper.cls('-required', use => use(this.model.required) && !use(this.model.edit)), dom.maybe(props.edit, () => [ element = css.cssEditableLabel( controller, {onInput: true}, // Attach common Enter,Escape, blur handlers. css.saveControls(edit, saveDraft), // Autoselect whole text when mounted. // Auto grow for textarea. autoGrow(controller), // Enable normal menu. dom.on('contextmenu', stopEvent), dom.style('resize', 'none'), css.cssEditableLabel.cls('-edit'), testId('label-editor'), ), ]), dom.maybe(not(props.edit), () => [ css.cssRenderedLabel( dom.text(controller), testId('label-rendered'), ), ]), // When selected, we want to be able to edit the label by clicking it // so we need to make it relative and z-indexed. dom.style('position', u => u(this.model.selected) ? 'relative' : 'static'), dom.style('z-index', '2'), dom.on('click', (ev) => { if (this.model.selected.get() && !props.edit.get()) { props.edit.set(true); ev.stopPropagation(); } }), ...args, ), ]; } } class TextModel extends Question { private _format = Computed.create(this, (use) => { const field = use(this.field); return use(field.widgetOptionsJson.prop('formTextFormat')) ?? 'singleline'; }); private _rowCount = Computed.create(this, (use) => { const field = use(this.field); return use(field.widgetOptionsJson.prop('formTextLineCount')) || 3; }); public renderInput() { return dom.domComputed(this._format, (format) => { switch (format) { case 'singleline': { return this._renderSingleLineInput(); } case 'multiline': { return this._renderMultiLineInput(); } } }); } private _renderSingleLineInput() { return css.cssInput( dom.prop('name', u => u(u(this.field).colId)), {type: 'text', tabIndex: "-1"}, ); } private _renderMultiLineInput() { return css.cssTextArea( dom.prop('name', u => u(u(this.field).colId)), dom.prop('rows', this._rowCount), {tabIndex: "-1"}, ); } } class NumericModel extends Question { private _format = Computed.create(this, (use) => { const field = use(this.field); return use(field.widgetOptionsJson.prop('formNumberFormat')) ?? 'text'; }); public renderInput() { return dom.domComputed(this._format, (format) => { switch (format) { case 'text': { return this._renderTextInput(); } case 'spinner': { return this._renderSpinnerInput(); } } }); } private _renderTextInput() { return css.cssInput( dom.prop('name', u => u(u(this.field).colId)), {type: 'text', tabIndex: "-1"}, ); } private _renderSpinnerInput() { return css.cssSpinner(observable(''), {}); } } class ChoiceModel extends Question { protected choices: Computed; protected alignment = Computed.create(this, (use) => { const field = use(this.field); return use(field.widgetOptionsJson.prop('formOptionsAlignment')) ?? 'vertical'; }); private _format = Computed.create(this, (use) => { const field = use(this.field); return use(field.widgetOptionsJson.prop('formSelectFormat')) ?? 'select'; }); private _sortOrder = Computed.create(this, (use) => { const field = use(this.field); return use(field.widgetOptionsJson.prop('formOptionsSortOrder')) ?? 'default'; }); constructor(model: FieldModel) { super(model); this.choices = Computed.create(this, use => { // Read choices from field. const field = use(this.field); const choices = use(field.widgetOptionsJson.prop('choices'))?.slice() ?? []; // Make sure it is an array of strings. if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) { return []; } else { const sort = use(this._sortOrder); if (sort !== 'default') { choices.sort((a, b) => a.localeCompare(b)); if (sort === 'descending') { choices.reverse(); } } return choices; } }); } public renderInput() { return dom('div', dom.domComputed(this._format, (format) => { if (format === 'select') { return this._renderSelectInput(); } else { return this._renderRadioInput(); } }), dom.maybe(use => use(this.choices).length === 0, () => [ css.cssWarningMessage(css.cssWarningIcon('Warning'), t('No choices configured')), ]), ); } private _renderSelectInput() { return css.cssSelect( {tabIndex: "-1"}, ignoreClick, dom.prop('name', use => use(use(this.field).colId)), dom('option', SELECT_PLACEHOLDER, {value: ''}, ), dom.forEach(this.choices, (choice) => dom('option', choice, {value: choice}, )), ); } private _renderRadioInput() { return css.cssRadioList( css.cssRadioList.cls('-horizontal', use => use(this.alignment) === 'horizontal'), dom.prop('name', use => use(use(this.field).colId)), dom.forEach(this.choices, (choice) => css.cssRadioLabel( cssRadioInput({type: 'radio'}), choice, )), ); } } class ChoiceListModel extends ChoiceModel { private _choices = Computed.create(this, use => { // Support for 30 choices. TODO: make limit dynamic. return use(this.choices).slice(0, 30); }); public renderInput() { const field = this.field; return css.cssCheckboxList( css.cssCheckboxList.cls('-horizontal', use => use(this.alignment) === 'horizontal'), dom.prop('name', use => use(use(field).colId)), dom.forEach(this._choices, (choice) => css.cssCheckboxLabel( css.cssCheckboxLabel.cls('-horizontal', use => use(this.alignment) === 'horizontal'), cssCheckboxSquare({type: 'checkbox'}), choice, )), dom.maybe(use => use(this._choices).length === 0, () => [ css.cssWarningMessage(css.cssWarningIcon('Warning'), t('No choices configured')), ]), ); } } class BoolModel extends Question { private _format = Computed.create(this, (use) => { const field = use(this.field); return use(field.widgetOptionsJson.prop('formToggleFormat')) ?? 'switch'; }); public override buildDom(props: { edit: Observable, overlay: Observable, question: Observable, onSave: () => void, }) { return css.cssQuestion( testId('question'), testType(this.model.colType), css.cssToggle( this.renderInput(), this.renderLabel(props, css.cssLabelInline.cls('')), ), ); } public override renderInput() { return dom.domComputed(this._format, (format) => { if (format === 'switch') { return this._renderSwitchInput(); } else { return this._renderCheckboxInput(); } }); } private _renderSwitchInput() { return css.cssWidgetSwitch( dom.style('--grist-actual-cell-color', colors.lightGreen.toString()), dom.cls('switch_transition'), dom('div.switch_slider'), dom('div.switch_circle'), ); } private _renderCheckboxInput() { return cssLabel( cssCheckboxSquare({type: 'checkbox'}), ); } } class DateModel extends Question { public renderInput() { return dom('div', css.cssInput( dom.prop('name', this.model.colId), {type: 'date', style: 'margin-right: 5px;'}, ), ); } } class DateTimeModel extends Question { public renderInput() { return dom('div', css.cssInput( dom.prop('name', this.model.colId), {type: 'datetime-local', style: 'margin-right: 5px;'}, ), dom.style('width', '100%'), ); } } class RefListModel extends Question { protected options: Computed<{label: string, value: string}[]>; protected alignment = Computed.create(this, (use) => { const field = use(this.field); return use(field.widgetOptionsJson.prop('formOptionsAlignment')) ?? 'vertical'; }); private _sortOrder = Computed.create(this, (use) => { const field = use(this.field); return use(field.widgetOptionsJson.prop('formOptionsSortOrder')) ?? 'default'; }); constructor(model: FieldModel) { super(model); this.options = this._getOptions(); } public renderInput() { return css.cssCheckboxList( css.cssCheckboxList.cls('-horizontal', use => use(this.alignment) === 'horizontal'), dom.prop('name', this.model.colId), dom.forEach(this.options, (option) => css.cssCheckboxLabel( squareCheckbox(observable(false)), option.label, )), dom.maybe(use => use(this.options).length === 0, () => [ css.cssWarningMessage( css.cssWarningIcon('Warning'), t('No values in show column of referenced table'), ), ]), ); } private _getOptions() { const tableId = Computed.create(this, use => { const refTable = use(use(this.model.column).refTable); return refTable ? use(refTable.tableId) : ''; }); const colId = Computed.create(this, use => { const dispColumnIdObs = use(use(this.model.column).visibleColModel); return use(dispColumnIdObs.colId) || 'id'; }); const observer = this.model.view.gristDoc.columnObserver(this, tableId, colId); return Computed.create(this, use => { const sort = use(this._sortOrder); const values = use(observer) .filter(([_id, value]) => !isBlankValue(value)) .map(([id, value]) => ({label: String(value), value: String(id)})); if (sort !== 'default') { values.sort((a, b) => a.label.localeCompare(b.label)); if (sort === 'descending') { values.reverse(); } } return values.slice(0, 30); }); } } class RefModel extends RefListModel { private _format = Computed.create(this, (use) => { const field = use(this.field); return use(field.widgetOptionsJson.prop('formSelectFormat')) ?? 'select'; }); public renderInput() { return dom('div', dom.domComputed(this._format, (format) => { if (format === 'select') { return this._renderSelectInput(); } else { return this._renderRadioInput(); } }), dom.maybe(use => use(this.options).length === 0, () => [ css.cssWarningMessage( css.cssWarningIcon('Warning'), t('No values in show column of referenced table'), ), ]), ); } private _renderSelectInput() { return css.cssSelect( {tabIndex: "-1"}, ignoreClick, dom.prop('name', this.model.colId), dom('option', SELECT_PLACEHOLDER, {value: ''}, ), dom.forEach(this.options, ({label, value}) => dom('option', label, {value}, )), ); } private _renderRadioInput() { return css.cssRadioList( css.cssRadioList.cls('-horizontal', use => use(this.alignment) === 'horizontal'), dom.prop('name', use => use(use(this.field).colId)), dom.forEach(this.options, ({label, value}) => css.cssRadioLabel( cssRadioInput({type: 'radio'}), label, )), ); } } const AnyModel = TextModel; // Attachments are not currently supported. const AttachmentsModel = TextModel; function fieldConstructor(type: string): Constructor { switch (type) { case 'Any': return AnyModel; case 'Bool': return BoolModel; case 'Choice': return ChoiceModel; case 'ChoiceList': return ChoiceListModel; case 'Date': return DateModel; case 'DateTime': return DateTimeModel; case 'Int': return NumericModel; case 'Numeric': return NumericModel; case 'Ref': return RefModel; case 'RefList': return RefListModel; case 'Attachments': return AttachmentsModel; default: return TextModel; } } /** * Creates a hidden input element with element type. Used in tests. */ function testType(value: BindableValue) { return dom('input', {type: 'hidden'}, dom.prop('value', value), testId('type')); }