From 86062a8c28b377faf881fa9475e3c6b8f4ee539b Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Wed, 10 Apr 2024 23:50:30 -0700 Subject: [PATCH] (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 --- app/client/components/FormRenderer.ts | 386 +++++++++++++++++++--- app/client/components/FormRendererCss.ts | 164 +++++++-- app/client/components/Forms/Columns.ts | 15 +- app/client/components/Forms/Field.ts | 332 +++++++++++++++---- app/client/components/Forms/FormConfig.ts | 108 +++++- app/client/components/Forms/FormView.ts | 116 +++---- app/client/components/Forms/Model.ts | 14 +- app/client/components/Forms/Section.ts | 1 - app/client/components/Forms/Submit.ts | 11 +- app/client/components/Forms/styles.ts | 179 +++++----- app/client/components/GristDoc.ts | 72 ++-- app/client/ui/FormAPI.ts | 47 ++- app/client/ui/FormContainer.ts | 156 +++++++-- app/client/ui/FormErrorPage.ts | 31 +- app/client/ui/FormPage.ts | 70 ++-- app/client/ui/FormPagesCss.ts | 139 -------- app/client/ui/FormSuccessPage.ts | 33 +- app/client/ui/RightPanelStyles.ts | 5 + app/client/ui2018/checkbox.ts | 1 + app/client/ui2018/radio.ts | 25 ++ app/client/widgets/ChoiceListCell.ts | 16 +- app/client/widgets/ChoiceTextBox.ts | 17 +- app/client/widgets/DateTextBox.js | 4 +- app/client/widgets/FieldBuilder.ts | 16 +- app/client/widgets/NTextBox.ts | 46 ++- app/client/widgets/NumericSpinner.ts | 172 ++++++++++ app/client/widgets/NumericTextBox.ts | 174 ++++------ app/client/widgets/Reference.ts | 9 +- app/client/widgets/ReferenceList.ts | 14 + app/client/widgets/Toggle.ts | 35 +- app/client/widgets/UserType.ts | 1 + app/client/widgets/UserTypeImpl.ts | 6 + app/common/gutil.ts | 15 + app/server/lib/DocApi.ts | 45 ++- test/nbrowser/FormView.ts | 270 +++++++++++++-- 35 files changed, 2033 insertions(+), 712 deletions(-) delete mode 100644 app/client/ui/FormPagesCss.ts create mode 100644 app/client/ui2018/radio.ts create mode 100644 app/client/widgets/NumericSpinner.ts diff --git a/app/client/components/FormRenderer.ts b/app/client/components/FormRenderer.ts index 857067a5..b9ff7e62 100644 --- a/app/client/components/FormRenderer.ts +++ b/app/client/components/FormRenderer.ts @@ -149,10 +149,14 @@ class SectionRenderer extends FormRenderer { class ColumnsRenderer extends FormRenderer { public render() { return css.columns( - {style: `--grist-columns-count: ${this.children.length || 1}`}, + {style: `--grist-columns-count: ${this._getColumnsCount()}`}, this.children.map((child) => child.render()), ); } + + private _getColumnsCount() { + return this.children.length || 1; + } } class SubmitRenderer extends FormRenderer { @@ -180,22 +184,7 @@ class SubmitRenderer extends FormRenderer { type: 'submit', value: this.context.rootLayoutNode.submitText || 'Submit', }, - dom.on('click', () => { - // Make sure that all choice or reference lists that are required have at least one option selected. - const lists = document.querySelectorAll('.grist-checkbox-list.required:not(:has(input:checked))'); - Array.from(lists).forEach(function(list) { - // If the form has at least one checkbox, make it required. - const firstCheckbox = list.querySelector('input[type="checkbox"]'); - firstCheckbox?.setAttribute('required', 'required'); - }); - - // All other required choice or reference lists with at least one option selected are no longer required. - const checkedLists = document.querySelectorAll('.grist-checkbox-list.required:has(input:checked)'); - Array.from(checkedLists).forEach(function(list) { - const firstCheckbox = list.querySelector('input[type="checkbox"]'); - firstCheckbox?.removeAttribute('required'); - }); - }), + dom.on('click', () => validateRequiredLists()), ) ), ), @@ -228,7 +217,7 @@ class FieldRenderer extends FormRenderer { } public render() { - return css.field(this.renderer.render()); + return this.renderer.render(); } public reset() { @@ -267,41 +256,120 @@ abstract class BaseFieldRenderer extends Disposable { } class TextRenderer extends BaseFieldRenderer { - protected type = 'text'; - private _value = Observable.create(this, ''); + protected inputType = 'text'; + + private _format = this.field.options.formTextFormat ?? 'singleline'; + private _lineCount = String(this.field.options.formTextLineCount || 3); + private _value = Observable.create(this, ''); public input() { - return dom('input', + if (this._format === 'singleline') { + return this._renderSingleLineInput(); + } else { + return this._renderMultiLineInput(); + } + } + + public resetInput(): void { + this._value.setAndTrigger(''); + } + + private _renderSingleLineInput() { + return css.textInput( { - type: this.type, + type: this.inputType, name: this.name(), required: this.field.options.formRequired, }, dom.prop('value', this._value), + preventSubmitOnEnter(), + ); + } + + private _renderMultiLineInput() { + return css.textarea( + { + name: this.name(), + required: this.field.options.formRequired, + rows: this._lineCount, + }, + dom.prop('value', this._value), dom.on('input', (_e, elem) => this._value.set(elem.value)), ); } +} + +class NumericRenderer extends BaseFieldRenderer { + protected inputType = 'text'; + + private _format = this.field.options.formNumberFormat ?? 'text'; + private _value = Observable.create(this, ''); + private _spinnerValue = Observable.create(this, ''); + + public input() { + if (this._format === 'text') { + return this._renderTextInput(); + } else { + return this._renderSpinnerInput(); + } + } public resetInput(): void { - this._value.set(''); + this._value.setAndTrigger(''); + this._spinnerValue.setAndTrigger(''); + } + + private _renderTextInput() { + return css.textInput( + { + type: this.inputType, + name: this.name(), + required: this.field.options.formRequired, + }, + dom.prop('value', this._value), + preventSubmitOnEnter(), + ); + } + + private _renderSpinnerInput() { + return css.spinner( + this._spinnerValue, + { + setValueOnInput: true, + inputArgs: [ + { + name: this.name(), + required: this.field.options.formRequired, + }, + preventSubmitOnEnter(), + ], + } + ); } } class DateRenderer extends TextRenderer { - protected type = 'date'; + protected inputType = 'date'; } class DateTimeRenderer extends TextRenderer { - protected type = 'datetime-local'; + protected inputType = 'datetime-local'; } export const SELECT_PLACEHOLDER = 'Select...'; class ChoiceRenderer extends BaseFieldRenderer { - protected value = Observable.create(this, ''); + protected value: Observable; + private _choices: string[]; private _selectElement: HTMLElement; private _ctl?: PopupControl; + private _format = this.field.options.formSelectFormat ?? 'select'; + private _alignment = this.field.options.formOptionsAlignment ?? 'vertical'; + private _radioButtons: MutableObsArray<{ + label: string; + checked: Observable + }> = this.autoDispose(obsArray()); public constructor(field: FormField, context: FormRendererContext) { super(field, context); @@ -310,24 +378,59 @@ class ChoiceRenderer extends BaseFieldRenderer { if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) { this._choices = []; } else { + const sortOrder = this.field.options.formOptionsSortOrder ?? 'default'; + if (sortOrder !== 'default') { + choices.sort((a, b) => String(a).localeCompare(String(b))); + if (sortOrder === 'descending') { + choices.reverse(); + } + } // Support for 1000 choices. TODO: make limit dynamic. this._choices = choices.slice(0, 1000); } + + this.value = Observable.create(this, ''); + + this._radioButtons.set(this._choices.map(choice => ({ + label: String(choice), + checked: Observable.create(this, null), + }))); } public input() { + if (this._format === 'select') { + return this._renderSelectInput(); + } else { + return this._renderRadioInput(); + } + } + + public resetInput() { + this.value.set(''); + this._radioButtons.get().forEach(radioButton => { + radioButton.checked.set(null); + }); + } + + private _renderSelectInput() { return css.hybridSelect( this._selectElement = css.select( {name: this.name(), required: this.field.options.formRequired}, - dom.prop('value', this.value), dom.on('input', (_e, elem) => this.value.set(elem.value)), dom('option', {value: ''}, SELECT_PLACEHOLDER), - this._choices.map((choice) => dom('option', {value: choice}, choice)), + this._choices.map((choice) => dom('option', + {value: choice}, + dom.prop('selected', use => use(this.value) === choice), + choice + )), dom.onKeyDown({ + Enter$: (ev) => this._maybeOpenSearchSelect(ev), ' $': (ev) => this._maybeOpenSearchSelect(ev), ArrowUp$: (ev) => this._maybeOpenSearchSelect(ev), ArrowDown$: (ev) => this._maybeOpenSearchSelect(ev), + Backspace$: () => this.value.set(''), }), + preventSubmitOnEnter(), ), dom.maybe(use => !use(isXSmallScreenObs()), () => css.searchSelect( @@ -359,8 +462,29 @@ class ChoiceRenderer extends BaseFieldRenderer { ); } - public resetInput(): void { - this.value.set(''); + private _renderRadioInput() { + const required = this.field.options.formRequired; + return css.radioList( + css.radioList.cls('-horizontal', this._alignment === 'horizontal'), + dom.cls('grist-radio-list'), + dom.cls('required', Boolean(required)), + {name: this.name(), required}, + dom.forEach(this._radioButtons, (radioButton) => + css.radio( + dom('input', + dom.prop('checked', radioButton.checked), + dom.on('change', (_e, elem) => radioButton.checked.set(elem.value)), + { + type: 'radio', + name: `${this.name()}`, + value: radioButton.label, + }, + preventSubmitOnEnter(), + ), + dom('span', radioButton.label), + ) + ), + ); } private _maybeOpenSearchSelect(ev: KeyboardEvent) { @@ -375,8 +499,11 @@ class ChoiceRenderer extends BaseFieldRenderer { } class BoolRenderer extends BaseFieldRenderer { + protected inputType = 'checkbox'; protected checked = Observable.create(this, false); + private _format = this.field.options.formToggleFormat ?? 'switch'; + public render() { return css.field( dom('div', this.input()), @@ -384,16 +511,29 @@ class BoolRenderer extends BaseFieldRenderer { } public input() { - return css.toggle( + if (this._format === 'switch') { + return this._renderSwitchInput(); + } else { + return this._renderCheckboxInput(); + } + } + + public resetInput(): void { + this.checked.set(false); + } + + private _renderSwitchInput() { + return css.toggleSwitch( dom('input', dom.prop('checked', this.checked), + dom.prop('value', use => use(this.checked) ? '1' : '0'), dom.on('change', (_e, elem) => this.checked.set(elem.checked)), { - type: 'checkbox', + type: this.inputType, name: this.name(), - value: '1', required: this.field.options.formRequired, }, + preventSubmitOnEnter(), ), css.gristSwitch( css.gristSwitchSlider(), @@ -406,8 +546,24 @@ class BoolRenderer extends BaseFieldRenderer { ); } - public resetInput(): void { - this.checked.set(false); + private _renderCheckboxInput() { + return css.toggle( + dom('input', + dom.prop('checked', this.checked), + dom.prop('value', use => use(this.checked) ? '1' : '0'), + dom.on('change', (_e, elem) => this.checked.set(elem.checked)), + { + type: this.inputType, + name: this.name(), + required: this.field.options.formRequired, + }, + preventSubmitOnEnter(), + ), + css.toggleLabel( + css.label.cls('-required', Boolean(this.field.options.formRequired)), + this.field.question, + ), + ); } } @@ -417,6 +573,8 @@ class ChoiceListRenderer extends BaseFieldRenderer { checked: Observable }> = this.autoDispose(obsArray()); + private _alignment = this.field.options.formOptionsAlignment ?? 'vertical'; + public constructor(field: FormField, context: FormRendererContext) { super(field, context); @@ -424,6 +582,13 @@ class ChoiceListRenderer extends BaseFieldRenderer { if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) { choices = []; } else { + const sortOrder = this.field.options.formOptionsSortOrder ?? 'default'; + if (sortOrder !== 'default') { + choices.sort((a, b) => String(a).localeCompare(String(b))); + if (sortOrder === 'descending') { + choices.reverse(); + } + } // Support for 30 choices. TODO: make limit dynamic. choices = choices.slice(0, 30); } @@ -437,6 +602,7 @@ class ChoiceListRenderer extends BaseFieldRenderer { public input() { const required = this.field.options.formRequired; return css.checkboxList( + css.checkboxList.cls('-horizontal', this._alignment === 'horizontal'), dom.cls('grist-checkbox-list'), dom.cls('required', Boolean(required)), {name: this.name(), required}, @@ -449,7 +615,8 @@ class ChoiceListRenderer extends BaseFieldRenderer { type: 'checkbox', name: `${this.name()}[]`, value: checkbox.label, - } + }, + preventSubmitOnEnter(), ), dom('span', checkbox.label), ) @@ -471,12 +638,20 @@ class RefListRenderer extends BaseFieldRenderer { checked: Observable }> = this.autoDispose(obsArray()); + private _alignment = this.field.options.formOptionsAlignment ?? 'vertical'; + public constructor(field: FormField, context: FormRendererContext) { super(field, context); const references = this.field.refValues ?? []; - // Sort by the second value, which is the display value. - references.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); + const sortOrder = this.field.options.formOptionsSortOrder; + if (sortOrder !== 'default') { + // Sort by the second value, which is the display value. + references.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); + if (sortOrder === 'descending') { + references.reverse(); + } + } // Support for 30 choices. TODO: make limit dynamic. references.splice(30); this.checkboxes.set(references.map(reference => ({ @@ -488,6 +663,7 @@ class RefListRenderer extends BaseFieldRenderer { public input() { const required = this.field.options.formRequired; return css.checkboxList( + css.checkboxList.cls('-horizontal', this._alignment === 'horizontal'), dom.cls('grist-checkbox-list'), dom.cls('required', Boolean(required)), {name: this.name(), required}, @@ -501,7 +677,8 @@ class RefListRenderer extends BaseFieldRenderer { 'data-grist-type': this.field.type, name: `${this.name()}[]`, value: checkbox.value, - } + }, + preventSubmitOnEnter(), ), dom('span', checkbox.label), ) @@ -518,15 +695,58 @@ class RefListRenderer extends BaseFieldRenderer { class RefRenderer extends BaseFieldRenderer { protected value = Observable.create(this, ''); + + private _format = this.field.options.formSelectFormat ?? 'select'; + private _alignment = this.field.options.formOptionsAlignment ?? 'vertical'; + private _choices: [number|string, CellValue][]; private _selectElement: HTMLElement; private _ctl?: PopupControl; + private _radioButtons: MutableObsArray<{ + label: string; + value: string; + checked: Observable + }> = this.autoDispose(obsArray()); + + public constructor(field: FormField, context: FormRendererContext) { + super(field, context); - public input() { const choices: [number|string, CellValue][] = this.field.refValues ?? []; - // Sort by the second value, which is the display value. - choices.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); + const sortOrder = this.field.options.formOptionsSortOrder ?? 'default'; + if (sortOrder !== 'default') { + // Sort by the second value, which is the display value. + choices.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); + if (sortOrder === 'descending') { + choices.reverse(); + } + } // Support for 1000 choices. TODO: make limit dynamic. - choices.splice(1000); + this._choices = choices.slice(0, 1000); + + this.value = Observable.create(this, ''); + + this._radioButtons.set(this._choices.map(reference => ({ + label: String(reference[1]), + value: String(reference[0]), + checked: Observable.create(this, null), + }))); + } + + public input() { + if (this._format === 'select') { + return this._renderSelectInput(); + } else { + return this._renderRadioInput(); + } + } + + public resetInput(): void { + this.value.set(''); + this._radioButtons.get().forEach(radioButton => { + radioButton.checked.set(null); + }); + } + + private _renderSelectInput() { return css.hybridSelect( this._selectElement = css.select( { @@ -534,27 +754,37 @@ class RefRenderer extends BaseFieldRenderer { 'data-grist-type': this.field.type, required: this.field.options.formRequired, }, - dom.prop('value', this.value), dom.on('input', (_e, elem) => this.value.set(elem.value)), - dom('option', {value: ''}, SELECT_PLACEHOLDER), - choices.map((choice) => dom('option', {value: String(choice[0])}, String(choice[1]))), + dom('option', + {value: ''}, + SELECT_PLACEHOLDER, + dom.prop('selected', use => use(this.value) === ''), + ), + this._choices.map((choice) => dom('option', + {value: String(choice[0])}, + String(choice[1]), + dom.prop('selected', use => use(this.value) === String(choice[0])), + )), dom.onKeyDown({ + Enter$: (ev) => this._maybeOpenSearchSelect(ev), ' $': (ev) => this._maybeOpenSearchSelect(ev), ArrowUp$: (ev) => this._maybeOpenSearchSelect(ev), ArrowDown$: (ev) => this._maybeOpenSearchSelect(ev), + Backspace$: () => this.value.set(''), }), + preventSubmitOnEnter(), ), dom.maybe(use => !use(isXSmallScreenObs()), () => css.searchSelect( dom('div', dom.text(use => { - const choice = choices.find((c) => String(c[0]) === use(this.value)); + const choice = this._choices.find((c) => String(c[0]) === use(this.value)); return String(choice?.[1] || SELECT_PLACEHOLDER); })), dropdownWithSearch({ action: (value) => this.value.set(value), options: () => [ {label: SELECT_PLACEHOLDER, value: '', placeholder: true}, - ...choices.map((choice) => ({ + ...this._choices.map((choice) => ({ label: String(choice[1]), value: String(choice[0]), }), @@ -577,8 +807,29 @@ class RefRenderer extends BaseFieldRenderer { ); } - public resetInput(): void { - this.value.set(''); + private _renderRadioInput() { + const required = this.field.options.formRequired; + return css.radioList( + css.radioList.cls('-horizontal', this._alignment === 'horizontal'), + dom.cls('grist-radio-list'), + dom.cls('required', Boolean(required)), + {name: this.name(), required, 'data-grist-type': this.field.type}, + dom.forEach(this._radioButtons, (radioButton) => + css.radio( + dom('input', + dom.prop('checked', radioButton.checked), + dom.on('change', (_e, elem) => radioButton.checked.set(elem.value)), + { + type: 'radio', + name: `${this.name()}`, + value: radioButton.value, + }, + preventSubmitOnEnter(), + ), + dom('span', radioButton.label), + ) + ), + ); } private _maybeOpenSearchSelect(ev: KeyboardEvent) { @@ -594,6 +845,8 @@ class RefRenderer extends BaseFieldRenderer { const FieldRenderers = { 'Text': TextRenderer, + 'Numeric': NumericRenderer, + 'Int': NumericRenderer, 'Choice': ChoiceRenderer, 'Bool': BoolRenderer, 'ChoiceList': ChoiceListRenderer, @@ -616,3 +869,36 @@ const FormRenderers = { 'Separator': ParagraphRenderer, 'Header': ParagraphRenderer, }; + +function preventSubmitOnEnter() { + return dom.onKeyDown({Enter$: (ev) => ev.preventDefault()}); +} + +/** + * Validates the required attribute of checkbox and radio lists, such as those + * used by Choice, Choice List, Reference, and Reference List fields. + * + * Since lists of checkboxes and radios don't natively support a required attribute, we + * simulate it by marking the first checkbox/radio of each required list as being a + * required input. Then, we make another pass and unmark all required checkbox/radio + * inputs if they belong to a list where at least one checkbox/radio is checked. If any + * inputs in a required are left as required, HTML validations that are triggered when + * submitting a form will catch them and prevent the submission. + */ +function validateRequiredLists() { + for (const type of ['checkbox', 'radio']) { + const requiredLists = document + .querySelectorAll(`.grist-${type}-list.required:not(:has(input:checked))`); + Array.from(requiredLists).forEach(function(list) { + const firstOption = list.querySelector(`input[type="${type}"]`); + firstOption?.setAttribute('required', 'required'); + }); + + const requiredListsWithCheckedOption = document + .querySelectorAll(`.grist-${type}-list.required:has(input:checked`); + Array.from(requiredListsWithCheckedOption).forEach(function(list) { + const firstOption = list.querySelector(`input[type="${type}"]`); + firstOption?.removeAttribute('required'); + }); + } +} diff --git a/app/client/components/FormRendererCss.ts b/app/client/components/FormRendererCss.ts index dc776819..461ddff9 100644 --- a/app/client/components/FormRendererCss.ts +++ b/app/client/components/FormRendererCss.ts @@ -1,5 +1,6 @@ import {colors, mediaXSmall, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; +import {numericSpinner} from 'app/client/widgets/NumericSpinner'; import {styled} from 'grainjs'; export const label = styled('div', ` @@ -26,20 +27,23 @@ export const section = styled('div', ` border-radius: 3px; border: 1px solid ${colors.darkGrey}; padding: 24px; - margin-top: 24px; + margin-top: 12px; + margin-bottom: 24px; & > div + div { - margin-top: 16px; + margin-top: 8px; + margin-bottom: 12px; } `); export const columns = styled('div', ` display: grid; grid-template-columns: repeat(var(--grist-columns-count), 1fr); - gap: 4px; + gap: 16px; `); export const submitButtons = styled('div', ` + margin-top: 16px; display: flex; justify-content: center; column-gap: 8px; @@ -100,32 +104,13 @@ export const submitButton = styled('div', ` export const field = styled('div', ` display: flex; flex-direction: column; + height: 100%; + justify-content: space-between; - & input[type="text"], - & input[type="date"], - & input[type="datetime-local"], - & input[type="number"] { - height: 27px; - padding: 4px 8px; - border: 1px solid ${colors.darkGrey}; - border-radius: 3px; - outline-color: ${vars.primaryBgHover}; - } - & input[type="text"] { - font-size: 13px; - line-height: inherit; - width: 100%; - color: ${colors.dark}; - background-color: ${colors.light}; - } - & input[type="datetime-local"], - & input[type="date"] { - width: 100%; - line-height: inherit; - } & input[type="checkbox"] { -webkit-appearance: none; -moz-appearance: none; + margin: 0; padding: 0; flex-shrink: 0; display: inline-block; @@ -195,19 +180,80 @@ export const field = styled('div', ` `); export const error = styled('div', ` + margin-top: 16px; text-align: center; color: ${colors.error}; min-height: 22px; `); +export const textInput = styled('input', ` + color: ${colors.dark}; + background-color: ${colors.light}; + height: 29px; + width: 100%; + font-size: 13px; + line-height: inherit; + padding: 4px 8px; + border: 1px solid ${colors.darkGrey}; + border-radius: 3px; + outline-color: ${vars.primaryBgHover}; +`); + +export const textarea = styled('textarea', ` + display: block; + color: ${colors.dark}; + background-color: ${colors.light}; + min-height: 29px; + width: 100%; + font-size: 13px; + line-height: inherit; + padding: 4px 8px; + border: 1px solid ${colors.darkGrey}; + border-radius: 3px; + outline-color: ${vars.primaryBgHover}; + resize: none; +`); + +export const spinner = styled(numericSpinner, ` + & input { + height: 29px; + border: none; + font-size: 13px; + line-height: inherit; + } + + &:focus-within { + outline: 2px solid ${vars.primaryBgHover}; + } +`); + export const toggle = styled('label', ` position: relative; - cursor: pointer; display: inline-flex; - align-items: center; + margin-top: 8px; + + &:hover { + --color: ${colors.hover}; + } +`); + +export const toggleSwitch = styled(toggle, ` + cursor: pointer; & input[type='checkbox'] { + margin: 0; position: absolute; + top: 1px; + left: 4px; + } + & input[type='checkbox'], + & input[type='checkbox']::before, + & input[type='checkbox']::after { + height: 1px; + width: 1px; + } + & input[type='checkbox']:focus { + outline: none; } & input[type='checkbox']:focus { outline: none; @@ -220,6 +266,8 @@ export const toggle = styled('label', ` export const toggleLabel = styled('span', ` font-size: 13px; font-weight: 700; + line-height: 16px; + overflow-wrap: anywhere; `); export const gristSwitchSlider = styled('div', ` @@ -233,10 +281,6 @@ export const gristSwitchSlider = styled('div', ` border-radius: 17px; -webkit-transition: background-color .4s; transition: background-color .4s; - - &:hover { - box-shadow: 0 0 1px #2196F3; - } `); export const gristSwitchCircle = styled('div', ` @@ -277,19 +321,67 @@ export const gristSwitch = styled('div', ` `); export const checkboxList = styled('div', ` - display: flex; + display: inline-flex; flex-direction: column; - gap: 4px; + gap: 8px; + + &-horizontal { + flex-direction: row; + flex-wrap: wrap; + column-gap: 16px; + } `); export const checkbox = styled('label', ` display: flex; + font-size: 13px; + line-height: 16px; + gap: 8px; + overflow-wrap: anywhere; + & input { + margin: 0px !important; + } &:hover { --color: ${colors.hover}; } `); +export const radioList = checkboxList; + +export const radio = styled('label', ` + position: relative; + display: inline-flex; + gap: 8px; + font-size: 13px; + line-height: 16px; + font-weight: normal; + min-width: 0px; + outline-color: ${vars.primaryBgHover}; + overflow-wrap: anywhere; + + & input { + flex-shrink: 0; + appearance: none; + width: 16px; + height: 16px; + margin: 0px; + border-radius: 50%; + background-clip: content-box; + border: 1px solid ${colors.darkGrey}; + background-color: transparent; + outline-color: ${vars.primaryBgHover}; + } + & input:hover { + border: 1px solid ${colors.hover}; + } + & input:checked { + padding: 2px; + background-color: ${vars.primaryBg}; + border: 1px solid ${vars.primaryBg}; + } +`); + export const hybridSelect = styled('div', ` position: relative; `); @@ -303,7 +395,7 @@ export const select = styled('select', ` outline: none; background: white; line-height: inherit; - height: 27px; + height: 29px; flex: auto; width: 100%; @@ -323,11 +415,11 @@ export const searchSelect = styled('div', ` position: relative; padding: 4px 8px; border-radius: 3px; - border: 1px solid ${colors.darkGrey}; + outline: 1px solid ${colors.darkGrey}; font-size: 13px; background: white; line-height: inherit; - height: 27px; + height: 29px; flex: auto; width: 100%; diff --git a/app/client/components/Forms/Columns.ts b/app/client/components/Forms/Columns.ts index eda80299..8232e760 100644 --- a/app/client/components/Forms/Columns.ts +++ b/app/client/components/Forms/Columns.ts @@ -5,6 +5,7 @@ import {buildMenu} from 'app/client/components/Forms/Menu'; 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 {makeT} from 'app/client/lib/localization'; import {icon} from 'app/client/ui2018/icons'; import * as menus from 'app/client/ui2018/menus'; import {inlineStyle, not} from 'app/common/gutil'; @@ -13,6 +14,8 @@ import {v4 as uuidv4} from 'uuid'; const testId = makeTestId('test-forms-'); +const t = makeT('FormView'); + export class ColumnsModel extends BoxModel { private _columnCount = Computed.create(this, use => use(this.children).length); @@ -64,7 +67,11 @@ export class ColumnsModel extends BoxModel { cssPlaceholder( testId('add'), icon('Plus'), - dom.on('click', () => this.placeAfterListChild()(Placeholder())), + dom.on('click', async () => { + await this.save(() => { + this.placeAfterListChild()(Placeholder()); + }); + }), style.cssColumn.cls('-add-button'), style.cssColumn.cls('-drag-over', dragHover), @@ -152,7 +159,7 @@ export class PlaceholderModel extends BoxModel { buildMenu({ box: this, insertBox, - customItems: [menus.menuItem(removeColumn, menus.menuIcon('Remove'), 'Remove Column')], + customItems: [menus.menuItem(removeColumn, menus.menuIcon('Remove'), t('Remove Column'))], }), dom.on('contextmenu', (ev) => { @@ -219,8 +226,8 @@ export class PlaceholderModel extends BoxModel { return box.parent.replace(box, childBox); } - function removeColumn() { - box.removeSelf(); + async function removeColumn() { + await box.deleteSelf(); } } } diff --git a/app/client/components/Forms/Field.ts b/app/client/components/Forms/Field.ts index d1eb0bcf..5d1864c3 100644 --- a/app/client/components/Forms/Field.ts +++ b/app/client/components/Forms/Field.ts @@ -4,10 +4,20 @@ 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 {squareCheckbox} from 'app/client/ui2018/checkbox'; +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 { @@ -22,13 +32,14 @@ import { MultiHolder, observable, Observable, - styled, - toKo + toKo, } from 'grainjs'; import * as ko from 'knockout'; const testId = makeTestId('test-forms-'); +const t = makeT('FormView'); + /** * Container class for all fields. */ @@ -86,9 +97,6 @@ export class FieldModel extends BoxModel { const field = use(this.field); return Boolean(use(field.widgetOptionsJson.prop('formRequired'))); }); - this.required.onWrite(value => { - this.field.peek().widgetOptionsJson.prop('formRequired').setAndSave(value).catch(reportError); - }); this.question.onWrite(value => { this.field.peek().question.setAndSave(value).catch(reportError); @@ -152,6 +160,8 @@ export class FieldModel extends BoxModel { } export abstract class Question extends Disposable { + protected field = this.model.field; + constructor(public model: FieldModel) { super(); } @@ -164,7 +174,7 @@ export abstract class Question extends Disposable { return css.cssQuestion( testId('question'), testType(this.model.colType), - this.renderLabel(props, dom.style('margin-bottom', '5px')), + this.renderLabel(props), this.renderInput(), css.cssQuestion.cls('-required', this.model.required), ...args @@ -223,7 +233,7 @@ export abstract class Question extends Disposable { css.cssRequiredWrapper( testId('label'), // When in edit - hide * and change display from grid to display - css.cssRequiredWrapper.cls('-required', use => Boolean(use(this.model.required) && !use(this.model.edit))), + css.cssRequiredWrapper.cls('-required', use => use(this.model.required) && !use(this.model.edit)), dom.maybe(props.edit, () => [ element = css.cssEditableLabel( controller, @@ -264,36 +274,156 @@ export abstract class Question extends Disposable { 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.model.field).colId)), - {disabled: true}, + 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 = Computed.create(this, use => { - // Read choices from field. - const choices = use(use(this.model.field).widgetOptionsJson.prop('choices')); - - // Make sure it is an array of strings. - if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) { - return []; - } else { - return choices; - } + protected choices: Computed; + + protected alignment = Computed.create(this, (use) => { + const field = use(this.field); + return use(field.widgetOptionsJson.prop('formOptionsAlignment')) ?? 'vertical'; }); - public renderInput(): HTMLElement { - const field = this.model.field; + 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(field).colId)), - dom('option', SELECT_PLACEHOLDER, {value: ''}), - dom.forEach(this.choices, (choice) => dom('option', choice, {value: choice})), + 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, + )), ); } } @@ -305,21 +435,28 @@ class ChoiceListModel extends ChoiceModel { }); public renderInput() { - const field = this.model.field; - return dom('div', + 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( - squareCheckbox(observable(false)), - choice + css.cssCheckboxLabel.cls('-horizontal', use => use(this.alignment) === 'horizontal'), + cssCheckboxSquare({type: 'checkbox'}), + choice, )), dom.maybe(use => use(this._choices).length === 0, () => [ - dom('div', 'No choices defined'), + 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, @@ -329,22 +466,37 @@ class BoolModel extends Question { return css.cssQuestion( testId('question'), testType(this.model.colType), - cssToggle( + css.cssToggle( this.renderInput(), this.renderLabel(props, css.cssLabelInline.cls('')), ), ); } + public override renderInput() { - const value = Observable.create(this, true); - return dom('div.widget_switch', + 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_on', value), - dom.cls('switch_transition', true), + dom.cls('switch_transition'), dom('div.switch_slider'), dom('div.switch_circle'), ); } + + private _renderCheckboxInput() { + return cssLabel( + cssCheckboxSquare({type: 'checkbox'}), + ); + } } class DateModel extends Question { @@ -352,8 +504,8 @@ class DateModel extends Question { return dom('div', css.cssInput( dom.prop('name', this.model.colId), - {type: 'date', style: 'margin-right: 5px; width: 100%;' - }), + {type: 'date', style: 'margin-right: 5px;'}, + ), ); } } @@ -363,7 +515,7 @@ class DateTimeModel extends Question { return dom('div', css.cssInput( dom.prop('name', this.model.colId), - {type: 'datetime-local', style: 'margin-right: 5px; width: 100%;'} + {type: 'datetime-local', style: 'margin-right: 5px;'}, ), dom.style('width', '100%'), ); @@ -371,19 +523,38 @@ class DateTimeModel extends Question { } class RefListModel extends Question { - protected options = this._getOptions(); + 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 dom('div', + 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, () => [ - dom('div', 'No values in show column of referenced table'), + css.cssWarningMessage( + css.cssWarningIcon('Warning'), + t('No values in show column of referenced table'), + ), ]), - ) as HTMLElement; + ); } private _getOptions() { @@ -394,39 +565,83 @@ class RefListModel extends Question { const colId = Computed.create(this, use => { const dispColumnIdObs = use(use(this.model.column).visibleColModel); - return use(dispColumnIdObs.colId); + return use(dispColumnIdObs.colId) || 'id'; }); const observer = this.model.view.gristDoc.columnObserver(this, tableId, colId); return Computed.create(this, use => { - return use(observer) + const sort = use(this._sortOrder); + const values = use(observer) .filter(([_id, value]) => !isBlankValue(value)) - .map(([id, value]) => ({label: String(value), value: String(id)})) - .sort((a, b) => a.label.localeCompare(b.label)) - .slice(0, 30); // TODO: make limit dynamic. + .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})), + 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, + )), ); } } -// TODO: decide which one we need and implement rest. const AnyModel = TextModel; -const NumericModel = TextModel; -const IntModel = TextModel; -const AttachmentsModel = TextModel; +// Attachments are not currently supported. +const AttachmentsModel = TextModel; function fieldConstructor(type: string): Constructor { switch (type) { @@ -436,7 +651,7 @@ function fieldConstructor(type: string): Constructor { case 'ChoiceList': return ChoiceListModel; case 'Date': return DateModel; case 'DateTime': return DateTimeModel; - case 'Int': return IntModel; + case 'Int': return NumericModel; case 'Numeric': return NumericModel; case 'Ref': return RefModel; case 'RefList': return RefListModel; @@ -451,12 +666,3 @@ function fieldConstructor(type: string): Constructor { function testType(value: BindableValue) { return dom('input', {type: 'hidden'}, dom.prop('value', value), testId('type')); } - -const cssToggle = styled('div', ` - display: grid; - align-items: center; - grid-template-columns: auto 1fr; - gap: 8px; - padding: 4px 0px; - --grist-actual-cell-color: ${colors.lightGreen}; -`); diff --git a/app/client/components/Forms/FormConfig.ts b/app/client/components/Forms/FormConfig.ts index 31c32db7..832a8558 100644 --- a/app/client/components/Forms/FormConfig.ts +++ b/app/client/components/Forms/FormConfig.ts @@ -1,25 +1,119 @@ import {fromKoSave} from 'app/client/lib/fromKoSave'; import {makeT} from 'app/client/lib/localization'; import {ViewFieldRec} from 'app/client/models/DocModel'; -import {KoSaveableObservable} from 'app/client/models/modelUtil'; -import {cssLabel, cssRow, cssSeparator} from 'app/client/ui/RightPanelStyles'; +import {fieldWithDefault} from 'app/client/models/modelUtil'; +import {FormOptionsAlignment, FormOptionsSortOrder, FormSelectFormat} from 'app/client/ui/FormAPI'; +import { + cssLabel, + cssRow, + cssSeparator, +} from 'app/client/ui/RightPanelStyles'; +import {buttonSelect} from 'app/client/ui2018/buttonSelect'; import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox'; -import {testId} from 'app/client/ui2018/cssVars'; -import {Disposable} from 'grainjs'; +import {select} from 'app/client/ui2018/menus'; +import {Disposable, dom, makeTestId} from 'grainjs'; const t = makeT('FormConfig'); -export class FieldRulesConfig extends Disposable { +const testId = makeTestId('test-form-'); + +export class FormSelectConfig extends Disposable { + constructor(private _field: ViewFieldRec) { + super(); + } + + public buildDom() { + const format = fieldWithDefault( + this._field.widgetOptionsJson.prop('formSelectFormat'), + 'select' + ); + + return [ + cssLabel(t('Field Format')), + cssRow( + buttonSelect( + fromKoSave(format), + [ + {value: 'select', label: t('Select')}, + {value: 'radio', label: t('Radio')}, + ], + testId('field-format'), + ), + ), + dom.maybe(use => use(format) === 'radio', () => dom.create(FormOptionsAlignmentConfig, this._field)), + ]; + } +} + +export class FormOptionsAlignmentConfig extends Disposable { + constructor(private _field: ViewFieldRec) { + super(); + } + + public buildDom() { + const alignment = fieldWithDefault( + this._field.widgetOptionsJson.prop('formOptionsAlignment'), + 'vertical' + ); + + return [ + cssLabel(t('Options Alignment')), + cssRow( + select( + fromKoSave(alignment), + [ + {value: 'vertical', label: t('Vertical')}, + {value: 'horizontal', label: t('Horizontal')}, + ], + {defaultLabel: t('Vertical')} + ), + ), + ]; + } +} + +export class FormOptionsSortConfig extends Disposable { + constructor(private _field: ViewFieldRec) { + super(); + } + + public buildDom() { + const optionsSortOrder = fieldWithDefault( + this._field.widgetOptionsJson.prop('formOptionsSortOrder'), + 'default' + ); + + return [ + cssLabel(t('Options Sort Order')), + cssRow( + select( + fromKoSave(optionsSortOrder), + [ + {value: 'default', label: t('Default')}, + {value: 'ascending', label: t('Ascending')}, + {value: 'descending', label: t('Descending')}, + ], + {defaultLabel: t('Default')} + ), + ), + ]; + } +} + +export class FormFieldRulesConfig extends Disposable { constructor(private _field: ViewFieldRec) { super(); } public buildDom() { - const requiredField: KoSaveableObservable = this._field.widgetOptionsJson.prop('formRequired'); + const requiredField = fieldWithDefault( + this._field.widgetOptionsJson.prop('formRequired'), + false + ); return [ cssSeparator(), - cssLabel(t('Field rules')), + cssLabel(t('Field Rules')), cssRow(labeledSquareCheckbox( fromKoSave(requiredField), t('Required field'), diff --git a/app/client/components/Forms/FormView.ts b/app/client/components/Forms/FormView.ts index e53b21c5..6de4ac3a 100644 --- a/app/client/components/Forms/FormView.ts +++ b/app/client/components/Forms/FormView.ts @@ -16,6 +16,7 @@ import {logTelemetryEvent} from 'app/client/lib/telemetry'; import DataTableModel from 'app/client/models/DataTableModel'; import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel'; import {reportError} from 'app/client/models/errors'; +import {jsonObservable, SaveableObjObservable} from 'app/client/models/modelUtil'; import {ShareRec} from 'app/client/models/entities/ShareRec'; import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec'; import {docUrl, urlState} from 'app/client/models/gristUrlState'; @@ -55,7 +56,8 @@ export class FormView extends Disposable { protected bundle: (clb: () => Promise) => Promise; private _formFields: Computed; - private _autoLayout: Computed; + private _layoutSpec: SaveableObjObservable; + private _layout: Computed; private _root: BoxModel; private _savedLayout: any; private _saving: boolean = false; @@ -67,7 +69,7 @@ export class FormView extends Disposable { private _showPublishedMessage: Observable; private _isOwner: boolean; private _openingForm: Observable; - private _formElement: HTMLElement; + private _formEditorBodyElement: HTMLElement; public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) { BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false}); @@ -134,28 +136,30 @@ export class FormView extends Disposable { this._formFields = Computed.create(this, use => { const fields = use(use(this.viewSection.viewFields).getObservable()); - return fields.filter(f => use(use(f.column).isFormCol)); + return fields.filter(f => { + const column = use(f.column); + return ( + use(column.pureType) !== 'Attachments' && + !(use(column.isRealFormula) && !use(column.colId).startsWith('gristHelper_Transform')) + ); + }); + }); + + this._layoutSpec = jsonObservable(this.viewSection.layoutSpec, (layoutSpec: FormLayoutNode|null) => { + return layoutSpec ?? buildDefaultFormLayout(this._formFields.get()); }); - this._autoLayout = Computed.create(this, use => { + this._layout = Computed.create(this, use => { const fields = use(this._formFields); - const layout = use(this.viewSection.layoutSpecObj); - if (!layout || !layout.id) { - return this._formTemplate(fields); - } else { - const patchedLayout = patchLayoutSpec(layout, new Set(fields.map(f => f.id()))); - if (!patchedLayout) { throw new Error('Invalid form layout spec'); } + const layoutSpec = use(this._layoutSpec); + const patchedLayout = patchLayoutSpec(layoutSpec, new Set(fields.map(f => f.id()))); + if (!patchedLayout) { throw new Error('Invalid form layout spec'); } - return patchedLayout; - } + return patchedLayout; }); - this._root = this.autoDispose(new LayoutModel(this._autoLayout.get(), null, async (clb?: () => Promise) => { + this._root = this.autoDispose(new LayoutModel(this._layout.get(), null, async (clb?: () => Promise) => { await this.bundle(async () => { - // If the box is autogenerated we need to save it first. - if (!this.viewSection.layoutSpecObj.peek()?.id) { - await this.save(); - } if (clb) { await clb(); } @@ -163,7 +167,7 @@ export class FormView extends Disposable { }); }, this)); - this._autoLayout.addListener((v) => { + this._layout.addListener((v) => { if (this._saving) { console.warn('Layout changed while saving'); return; @@ -421,9 +425,9 @@ export class FormView extends Disposable { public buildDom() { return style.cssFormView( testId('editor'), - style.cssFormEditBody( + this._formEditorBodyElement = style.cssFormEditBody( style.cssFormContainer( - this._formElement = dom('div', dom.forEach(this._root.children, (child) => { + dom('div', dom.forEach(this._root.children, (child) => { if (!child) { return dom('div', 'Empty node'); } @@ -433,9 +437,9 @@ export class FormView extends Disposable { } return element; })), - this._buildPublisher(), ), ), + this._buildPublisher(), dom.on('click', () => this.selectedBox.set(null)), dom.maybe(this.gristDoc.docPageModel.isReadonly, () => style.cssFormDisabledOverlay()), ); @@ -481,7 +485,7 @@ export class FormView extends Disposable { // If nothing has changed, don't bother. if (isEqual(newVersion, this._savedLayout)) { return; } this._savedLayout = newVersion; - await this.viewSection.layoutSpecObj.setAndSave(newVersion); + await this._layoutSpec.setAndSave(newVersion); } finally { this._saving = false; } @@ -861,17 +865,17 @@ export class FormView extends Disposable { ); } + private _getSectionCount() { + return [...this._root.filter(box => box.type === 'Section')].length; + } + private _getEstimatedFormHeightPx() { return ( - // Form content height. - this._formElement.scrollHeight + - // Plus top/bottom page padding. - (2 * 52) + - // Plus top/bottom form padding. - (2 * 20) + - // Plus minimum form error height. - 38 + - // Plus form footer height. + // Form height. + this._formEditorBodyElement.scrollHeight + + // Minus "+" button height in each section. + (-32 * this._getSectionCount()) + + // Plus form footer height (visible only in the preview and published form). 64 ); } @@ -902,30 +906,6 @@ export class FormView extends Disposable { }); } - /** - * Generates a form template based on the fields in the view section. - */ - private _formTemplate(fields: ViewFieldRec[]): FormLayoutNode { - const boxes: FormLayoutNode[] = fields.map(f => { - return { - id: uuidv4(), - type: 'Field', - leaf: f.id(), - }; - }); - const section = components.Section(...boxes); - return { - id: uuidv4(), - type: 'Layout', - children: [ - {id: uuidv4(), type: 'Paragraph', text: FORM_TITLE, alignment: 'center', }, - {id: uuidv4(), type: 'Paragraph', text: FORM_DESC, alignment: 'center', }, - section, - {id: uuidv4(), type: 'Submit'}, - ], - }; - } - private async _resetForm() { this.selectedBox.set(null); await this.gristDoc.docData.bundleActions('Reset form', async () => { @@ -951,11 +931,35 @@ export class FormView extends Disposable { ]); const fields = this.viewSection.viewFields().all().slice(0, 9); - await this.viewSection.layoutSpecObj.setAndSave(this._formTemplate(fields)); + await this._layoutSpec.setAndSave(buildDefaultFormLayout(fields)); }); } } +/** + * Generates a default form layout based on the fields in the view section. + */ +export function buildDefaultFormLayout(fields: ViewFieldRec[]): FormLayoutNode { + const boxes: FormLayoutNode[] = fields.map(f => { + return { + id: uuidv4(), + type: 'Field', + leaf: f.id(), + }; + }); + const section = components.Section(...boxes); + return { + id: uuidv4(), + type: 'Layout', + children: [ + {id: uuidv4(), type: 'Paragraph', text: FORM_TITLE, alignment: 'center', }, + {id: uuidv4(), type: 'Paragraph', text: FORM_DESC, alignment: 'center', }, + section, + {id: uuidv4(), type: 'Submit'}, + ], + }; +} + // Getting an ES6 class to work with old-style multiple base classes takes a little hacking. Credits: ./ChartView.ts defaults(FormView.prototype, BaseView.prototype); Object.assign(FormView.prototype, BackboneEvents); diff --git a/app/client/components/Forms/Model.ts b/app/client/components/Forms/Model.ts index 2f24d456..c7531472 100644 --- a/app/client/components/Forms/Model.ts +++ b/app/client/components/Forms/Model.ts @@ -1,7 +1,17 @@ import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer'; import * as elements from 'app/client/components/Forms/elements'; import {FormView} from 'app/client/components/Forms/FormView'; -import {bundleChanges, Computed, Disposable, dom, IDomArgs, MutableObsArray, obsArray, Observable} from 'grainjs'; +import {MaybePromise} from 'app/plugin/gutil'; +import { + bundleChanges, + Computed, + Disposable, + dom, + IDomArgs, + MutableObsArray, + obsArray, + Observable, +} from 'grainjs'; type Callback = () => Promise; @@ -186,7 +196,7 @@ export abstract class BoxModel extends Disposable { return this._props.hasOwnProperty(name); } - public async save(before?: () => Promise): Promise { + public async save(before?: () => MaybePromise): Promise { if (!this.parent) { throw new Error('Cannot save detached box'); } return this.parent.save(before); } diff --git a/app/client/components/Forms/Section.ts b/app/client/components/Forms/Section.ts index 9d0f0106..24c2ba0d 100644 --- a/app/client/components/Forms/Section.ts +++ b/app/client/components/Forms/Section.ts @@ -62,7 +62,6 @@ export class SectionModel extends BoxModel { ), ) )}, - style.cssSectionEditor.cls(''), ); } diff --git a/app/client/components/Forms/Submit.ts b/app/client/components/Forms/Submit.ts index 9989d8c4..be81eaff 100644 --- a/app/client/components/Forms/Submit.ts +++ b/app/client/components/Forms/Submit.ts @@ -1,3 +1,4 @@ +import * as css from "app/client/components/FormRendererCss"; import { BoxModel } from "app/client/components/Forms/Model"; import { makeTestId } from "app/client/lib/domUtils"; import { bigPrimaryButton } from "app/client/ui2018/buttons"; @@ -9,8 +10,14 @@ export class SubmitModel extends BoxModel { const text = this.view.viewSection.layoutSpecObj.prop('submitText'); return dom( "div", - { style: "text-align: center; margin-top: 20px;" }, - bigPrimaryButton(dom.text(use => use(text) || 'Submit'), testId("submit")) + css.error(testId("error")), + css.submitButtons( + bigPrimaryButton( + dom.text(use => use(text) || 'Submit'), + { disabled: true }, + testId("submit"), + ), + ), ); } } diff --git a/app/client/components/Forms/styles.ts b/app/client/components/Forms/styles.ts index f6b921f9..6b5fa8d0 100644 --- a/app/client/components/Forms/styles.ts +++ b/app/client/components/Forms/styles.ts @@ -1,8 +1,10 @@ import {textarea} from 'app/client/ui/inputs'; import {sanitizeHTML} from 'app/client/ui/sanitizeHTML'; import {basicButton, basicButtonLink, textButton} from 'app/client/ui2018/buttons'; +import {cssLabel} from 'app/client/ui2018/checkbox'; import {colors, theme} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; +import {numericSpinner} from 'app/client/widgets/NumericSpinner'; import {BindableValue, dom, DomElementArg, IDomArgs, Observable, styled, subscribeBindable} from 'grainjs'; import {marked} from 'marked'; @@ -14,7 +16,6 @@ export const cssFormView = styled('div.flexauto.flexvbox', ` align-items: center; justify-content: space-between; position: relative; - background-color: ${theme.leftPanelBg}; overflow: auto; min-height: 100%; width: 100%; @@ -22,7 +23,6 @@ export const cssFormView = styled('div.flexauto.flexvbox', ` export const cssFormContainer = styled('div', ` background-color: ${theme.mainPanelBg}; - border: 1px solid ${theme.modalBorderDark}; color: ${theme.text}; width: 600px; align-self: center; @@ -31,10 +31,8 @@ export const cssFormContainer = styled('div', ` display: flex; flex-direction: column; max-width: calc(100% - 32px); - padding-top: 20px; - padding-left: 48px; - padding-right: 48px; gap: 8px; + line-height: 1.42857143; `); export const cssFieldEditor = styled('div.hover_border.field_editor', ` @@ -47,6 +45,11 @@ export const cssFieldEditor = styled('div.hover_border.field_editor', ` margin-bottom: 4px; --hover-visible: hidden; transition: transform 0.2s ease-in-out; + &-Section { + outline: 1px solid ${theme.modalBorderDark}; + margin-bottom: 24px; + padding: 16px; + } &:hover:not(:has(.hover_border:hover),&-cut) { --hover-visible: visible; outline: 1px solid ${theme.controlPrimaryBg}; @@ -78,37 +81,40 @@ export const cssFieldEditor = styled('div.hover_border.field_editor', ` } `); -export const cssSectionEditor = styled('div', ` - border-radius: 3px; - padding: 16px; - border: 1px solid ${theme.modalBorderDark}; -`); - - export const cssSection = styled('div', ` position: relative; color: ${theme.text}; margin: 0px auto; min-height: 50px; - .${cssFormView.className}-preview & { - background: transparent; - border-radius: unset; - padding: 0px; - min-height: auto; +`); + +export const cssCheckboxList = styled('div', ` + display: flex; + flex-direction: column; + gap: 8px; + + &-horizontal { + flex-direction: row; + flex-wrap: wrap; + column-gap: 16px; } `); -export const cssCheckboxLabel = styled('label', ` - font-size: 15px; +export const cssCheckboxLabel = styled(cssLabel, ` + font-size: 13px; + line-height: 16px; font-weight: normal; user-select: none; display: flex; - align-items: center; gap: 8px; margin: 0px; - margin-bottom: 8px; + overflow-wrap: anywhere; `); +export const cssRadioList = cssCheckboxList; + +export const cssRadioLabel = cssCheckboxLabel; + export function textbox(obs: Observable, ...args: DomElementArg[]): HTMLInputElement { return dom('input', dom.prop('value', u => u(obs) || ''), @@ -118,11 +124,14 @@ export function textbox(obs: Observable, ...args: DomElementAr } export const cssQuestion = styled('div', ` - + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; `); export const cssRequiredWrapper = styled('div', ` - margin-bottom: 8px; + margin: 8px 0px; min-height: 16px; &-required { display: grid; @@ -148,7 +157,7 @@ export const cssRenderedLabel = styled('div', ` min-height: 16px; color: ${theme.mediumText}; - font-size: 11px; + font-size: 13px; line-height: 16px; font-weight: 700; white-space: pre-wrap; @@ -186,17 +195,9 @@ export const cssEditableLabel = styled(textarea, ` `); export const cssLabelInline = styled('div', ` - margin-bottom: 0px; - & .${cssRenderedLabel.className} { - color: ${theme.mediumText}; - font-size: 15px; - font-weight: normal; - } - & .${cssEditableLabel.className} { - color: ${colors.darkText}; - font-size: 15px; - font-weight: normal; - } + line-height: 16px; + margin: 0px; + overflow-wrap: anywhere; `); export const cssDesc = styled('div', ` @@ -211,15 +212,19 @@ export const cssDesc = styled('div', ` `); export const cssInput = styled('input', ` - background-color: ${theme.inputDisabledBg}; + background-color: ${theme.inputBg}; font-size: inherit; - height: 27px; + height: 29px; padding: 4px 8px; border: 1px solid ${theme.inputBorder}; border-radius: 3px; outline: none; pointer-events: none; + &:disabled { + color: ${theme.inputDisabledFg}; + background-color: ${theme.inputDisabledBg}; + } &-invalid { color: ${theme.inputInvalid}; } @@ -228,10 +233,37 @@ export const cssInput = styled('input', ` } `); +export const cssTextArea = styled('textarea', ` + background-color: ${theme.inputBg}; + font-size: inherit; + min-height: 29px; + padding: 4px 8px; + border: 1px solid ${theme.inputBorder}; + border-radius: 3px; + outline: none; + pointer-events: none; + resize: none; + width: 100%; + + &:disabled { + color: ${theme.inputDisabledFg}; + background-color: ${theme.inputDisabledBg}; + } +`); + +export const cssSpinner = styled(numericSpinner, ` + height: 29px; + + &-hidden { + color: ${theme.inputDisabledFg}; + background-color: ${theme.inputDisabledBg}; + } +`); + export const cssSelect = styled('select', ` flex: auto; width: 100%; - background-color: ${theme.inputDisabledBg}; + background-color: ${theme.inputBg}; font-size: inherit; height: 27px; padding: 4px 8px; @@ -241,8 +273,34 @@ export const cssSelect = styled('select', ` pointer-events: none; `); -export const cssFieldEditorContent = styled('div', ` +export const cssToggle = styled('div', ` + display: grid; + grid-template-columns: auto 1fr; + margin-top: 12px; + gap: 8px; + --grist-actual-cell-color: ${colors.lightGreen}; +`); + +export const cssWidgetSwitch = styled('div.widget_switch', ` + &-hidden { + opacity: 0.6; + } +`); + +export const cssWarningMessage = styled('div', ` + margin-top: 8px; + display: flex; + align-items: center; + column-gap: 8px; +`); +export const cssWarningIcon = styled(icon, ` + --icon-color: ${colors.warning}; + flex-shrink: 0; +`); + +export const cssFieldEditorContent = styled('div', ` + height: 100%; `); export const cssSelectedOverlay = styled('div._cssSelectedOverlay', ` @@ -253,10 +311,6 @@ export const cssSelectedOverlay = styled('div._cssSelectedOverlay', ` .${cssFieldEditor.className}-selected > & { opacity: 1; } - - .${cssFormView.className}-preview & { - display: none; - } `); export const cssPlusButton = styled('div', ` @@ -288,22 +342,12 @@ export const cssPlusIcon = styled(icon, ` export const cssColumns = styled('div', ` - --css-columns-count: 2; display: grid; grid-template-columns: repeat(var(--css-columns-count), 1fr) 32px; gap: 8px; padding: 8px 4px; - - .${cssFormView.className}-preview & { - background: transparent; - border-radius: unset; - padding: 0px; - grid-template-columns: repeat(var(--css-columns-count), 1fr); - min-height: auto; - } `); - export const cssColumn = styled('div', ` position: relative; &-empty, &-add-button { @@ -336,21 +380,6 @@ export const cssColumn = styled('div', ` &-drag-over { outline: 2px dashed ${theme.controlPrimaryBg}; } - - &-add-button { - } - - .${cssFormView.className}-preview &-add-button { - display: none; - } - - .${cssFormView.className}-preview &-empty { - background: transparent; - border-radius: unset; - padding: 0px; - min-height: auto; - border: 0px; - } `); export const cssButtonGroup = styled('div', ` @@ -511,16 +540,13 @@ export const cssPreview = styled('iframe', ` `); export const cssSwitcher = styled('div', ` - flex-shrink: 0; - margin-top: 24px; - border-top: 1px solid ${theme.modalBorder}; - margin-left: -48px; - margin-right: -48px; + border-top: 1px solid ${theme.menuBorder}; + width: 100%; `); export const cssSwitcherMessage = styled('div', ` display: flex; - padding: 0px 16px 0px 16px; + padding: 8px 16px; `); export const cssSwitcherMessageBody = styled('div', ` @@ -528,7 +554,7 @@ export const cssSwitcherMessageBody = styled('div', ` display: flex; justify-content: center; align-items: center; - padding: 10px 32px; + padding: 8px 16px; `); export const cssSwitcherMessageDismissButton = styled('div', ` @@ -551,8 +577,7 @@ export const cssParagraph = styled('div', ` export const cssFormEditBody = styled('div', ` width: 100%; overflow: auto; - padding-top: 52px; - padding-bottom: 24px; + padding: 20px; `); export const cssRemoveButton = styled('div', ` diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index a2d08077..89e08d36 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -14,6 +14,7 @@ import {DocComm} from 'app/client/components/DocComm'; import * as DocConfigTab from 'app/client/components/DocConfigTab'; import {Drafts} from "app/client/components/Drafts"; import {EditorMonitor} from "app/client/components/EditorMonitor"; +import {buildDefaultFormLayout} from 'app/client/components/Forms/FormView'; import GridView from 'app/client/components/GridView'; import {importFromFile, selectAndImport} from 'app/client/components/Importer'; import {RawDataPage, RawDataPopup} from 'app/client/components/RawDataPage'; @@ -946,6 +947,9 @@ export class GristDoc extends DisposableWithEvents { if (val.type === 'chart') { await this._ensureOneNumericSeries(result.sectionRef); } + if (val.type === 'form') { + await this._setDefaultFormLayoutSpec(result.sectionRef); + } await this.saveLink(val.link, result.sectionRef); return result; } @@ -962,42 +966,48 @@ export class GristDoc extends DisposableWithEvents { }, }); - if (val.table === 'New Table') { - const name = await this._promptForName(); - if (name === undefined) { - return; - } - let newViewId: IDocPage; - if (val.type === WidgetType.Table) { - const result = await this.docData.sendAction(['AddEmptyTable', name]); - newViewId = result.views[0].id; + let viewRef: IDocPage; + let sectionRef: number | undefined; + await this.docData.bundleActions('Add new page', async () => { + if (val.table === 'New Table') { + const name = await this._promptForName(); + if (name === undefined) { + return; + } + if (val.type === WidgetType.Table) { + const result = await this.docData.sendAction(['AddEmptyTable', name]); + viewRef = result.views[0].id; + } else { + // This will create a new table and page. + const result = await this.docData.sendAction( + ['CreateViewSection', /* new table */0, 0, val.type, null, name] + ); + [viewRef, sectionRef] = [result.viewRef, result.sectionRef]; + } } else { - // This will create a new table and page. const result = await this.docData.sendAction( - ['CreateViewSection', /* new table */0, 0, val.type, null, name] - ); - newViewId = result.viewRef; - } - await this.openDocPage(newViewId); - } else { - let result: any; - await this.docData.bundleActions(`Add new page`, async () => { - result = await this.docData.sendAction( ['CreateViewSection', val.table, 0, val.type, val.summarize ? val.columns : null, null] ); + [viewRef, sectionRef] = [result.viewRef, result.sectionRef]; if (val.type === 'chart') { - await this._ensureOneNumericSeries(result.sectionRef); + await this._ensureOneNumericSeries(sectionRef!); } - }); - await this.openDocPage(result.viewRef); + } + if (val.type === 'form') { + await this._setDefaultFormLayoutSpec(sectionRef!); + } + }); + + await this.openDocPage(viewRef!); + if (sectionRef) { // The newly-added section should be given focus. - this.viewModel.activeSectionId(result.sectionRef); + this.viewModel.activeSectionId(sectionRef); + } - this._maybeShowEditCardLayoutTip(val.type).catch(reportError); + this._maybeShowEditCardLayoutTip(val.type).catch(reportError); - if (AttachedCustomWidgets.guard(val.type)) { - this._handleNewAttachedCustomWidget(val.type).catch(reportError); - } + if (AttachedCustomWidgets.guard(val.type)) { + this._handleNewAttachedCustomWidget(val.type).catch(reportError); } } @@ -1425,6 +1435,8 @@ export class GristDoc extends DisposableWithEvents { const toggle = () => !refreshed.isDisposed() && refreshed.set(refreshed.get() + 1); const holder = Holder.create(owner); const listener = (tab: TableModel) => { + if (tab.tableData.tableId === '') { return; } + // Now subscribe to any data change in that table. const subs = MultiHolder.create(holder); subs.autoDispose(tab.tableData.dataLoadedEmitter.addListener(toggle)); @@ -1921,6 +1933,12 @@ export class GristDoc extends DisposableWithEvents { } } + private async _setDefaultFormLayoutSpec(viewSectionId: number) { + const viewSection = this.docModel.viewSections.getRowModel(viewSectionId); + const viewFields = viewSection.viewFields.peek().peek(); + await viewSection.layoutSpecObj.setAndSave(buildDefaultFormLayout(viewFields)); + } + private _handleTriggerQueueOverflowMessage() { this.listenTo(this, 'webhookOverflowError', (err: any) => { this.app.topAppModel.notifier.createNotification({ diff --git a/app/client/ui/FormAPI.ts b/app/client/ui/FormAPI.ts index 51bd2c15..88de27d5 100644 --- a/app/client/ui/FormAPI.ts +++ b/app/client/ui/FormAPI.ts @@ -41,13 +41,52 @@ export interface FormField { refValues: [number, CellValue][] | null; } -interface FormFieldOptions { - /** True if the field is required to submit the form. */ - formRequired?: boolean; - /** Populated with a list of options. Only set if the field `type` is a Choice/Reference Liste. */ +export interface FormFieldOptions { + /** Choices for a Choice or Choice List field. */ choices?: string[]; + /** Text or Any field format. Defaults to `"singleline"`. */ + formTextFormat?: FormTextFormat; + /** Number of lines/rows for the `"multiline"` option of `formTextFormat`. Defaults to `3`. */ + formTextLineCount?: number; + /** Numeric or Int field format. Defaults to `"text"`. */ + formNumberFormat?: FormNumberFormat; + /** Toggle field format. Defaults to `"switch"`. */ + formToggleFormat?: FormToggleFormat; + /** Choice or Reference field format. Defaults to `"select"`. */ + formSelectFormat?: FormSelectFormat; + /** + * Field options alignment. + * + * Only applicable to Choice List and Reference List fields, and Choice and Reference fields + * when `formSelectFormat` is `"radio"`. + * + * Defaults to `"vertical"`. + */ + formOptionsAlignment?: FormOptionsAlignment; + /** + * Field options sort order. + * + * Only applicable to Choice, Choice List, Reference, and Reference List fields. + * + * Defaults to `"default"`. + */ + formOptionsSortOrder?: FormOptionsSortOrder; + /** True if the field is required. Defaults to `false`. */ + formRequired?: boolean; } +export type FormTextFormat = 'singleline' | 'multiline'; + +export type FormNumberFormat = 'text' | 'spinner'; + +export type FormToggleFormat = 'switch' | 'checkbox'; + +export type FormSelectFormat = 'select' | 'radio'; + +export type FormOptionsAlignment = 'vertical' | 'horizontal'; + +export type FormOptionsSortOrder = 'default' | 'ascending' | 'descending'; + export interface FormAPI { getForm(options: GetFormOptions): Promise
; createRecord(options: CreateRecordOptions): Promise; diff --git a/app/client/ui/FormContainer.ts b/app/client/ui/FormContainer.ts index 4ed7ee9b..80da4c82 100644 --- a/app/client/ui/FormContainer.ts +++ b/app/client/ui/FormContainer.ts @@ -1,36 +1,144 @@ import {makeT} from 'app/client/lib/localization'; -import * as css from 'app/client/ui/FormPagesCss'; +import {colors, mediaSmall} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {commonUrls} from 'app/common/gristUrls'; -import {DomContents, makeTestId} from 'grainjs'; +import {DomContents, DomElementArg, styled} from 'grainjs'; const t = makeT('FormContainer'); -const testId = makeTestId('test-form-'); - -export function buildFormContainer(buildBody: () => DomContents) { - return css.formContainer( - css.form( - css.formBody( +export function buildFormMessagePage(buildBody: () => DomContents, ...args: DomElementArg[]) { + return cssFormMessagePage( + cssFormMessage( + cssFormMessageBody( buildBody(), ), - css.formFooter( - css.poweredByGrist( - css.poweredByGristLink( - {href: commonUrls.forms, target: '_blank'}, - t('Powered by'), - css.gristLogo(), - ) - ), - css.buildForm( - css.buildFormLink( - {href: commonUrls.forms, target: '_blank'}, - t('Build your own form'), - icon('Expand'), - ), - ), + cssFormMessageFooter( + buildFormFooter(), ), ), - testId('container'), + ...args, ); } + +export function buildFormFooter() { + return [ + cssPoweredByGrist( + cssPoweredByGristLink( + {href: commonUrls.forms, target: '_blank'}, + t('Powered by'), + cssGristLogo(), + ) + ), + cssBuildForm( + cssBuildFormLink( + {href: commonUrls.forms, target: '_blank'}, + t('Build your own form'), + icon('Expand'), + ), + ), + ]; +} + +export const cssFormMessageImageContainer = styled('div', ` + margin-top: 28px; + display: flex; + justify-content: center; +`); + +export const cssFormMessageImage = styled('img', ` + height: 100%; + width: 100%; +`); + +export const cssFormMessageText = styled('div', ` + color: ${colors.dark}; + text-align: center; + font-weight: 600; + font-size: 16px; + line-height: 24px; + margin-top: 32px; + margin-bottom: 24px; +`); + +const cssFormMessagePage = styled('div', ` + padding: 16px; +`); + +const cssFormMessage = styled('div', ` + display: flex; + flex-direction: column; + align-items: center; + background-color: white; + border: 1px solid ${colors.darkGrey}; + border-radius: 3px; + max-width: 600px; + margin: 0px auto; +`); + +const cssFormMessageBody = styled('div', ` + width: 100%; + padding: 20px 48px 20px 48px; + + @media ${mediaSmall} { + & { + padding: 20px; + } + } +`); + +const cssFormMessageFooter = styled('div', ` + border-top: 1px solid ${colors.darkGrey}; + padding: 8px 16px; + width: 100%; +`); + +const cssPoweredByGrist = styled('div', ` + color: ${colors.darkText}; + font-size: 13px; + font-style: normal; + font-weight: 600; + line-height: 16px; + display: flex; + align-items: center; + justify-content: center; + padding: 0px 10px; +`); + +const cssPoweredByGristLink = styled('a', ` + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: ${colors.darkText}; + text-decoration: none; +`); + +const cssGristLogo = styled('div', ` + width: 58px; + height: 20.416px; + flex-shrink: 0; + background: url(img/logo-grist.png); + background-position: 0 0; + background-size: contain; + background-color: transparent; + background-repeat: no-repeat; + margin-top: 3px; +`); + +const cssBuildForm = styled('div', ` + display: flex; + align-items: center; + justify-content: center; + margin-top: 8px; +`); + +const cssBuildFormLink = styled('a', ` + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + line-height: 16px; + text-decoration-line: underline; + color: ${colors.darkGreen}; + --icon-color: ${colors.darkGreen}; +`); diff --git a/app/client/ui/FormErrorPage.ts b/app/client/ui/FormErrorPage.ts index 2bc87333..e7786b0f 100644 --- a/app/client/ui/FormErrorPage.ts +++ b/app/client/ui/FormErrorPage.ts @@ -1,9 +1,13 @@ import {makeT} from 'app/client/lib/localization'; -import {buildFormContainer} from 'app/client/ui/FormContainer'; -import * as css from 'app/client/ui/FormPagesCss'; +import { + buildFormMessagePage, + cssFormMessageImage, + cssFormMessageImageContainer, + cssFormMessageText, +} from 'app/client/ui/FormContainer'; import {getPageTitleSuffix} from 'app/common/gristUrls'; import {getGristConfig} from 'app/common/urlUtils'; -import {Disposable, makeTestId} from 'grainjs'; +import {Disposable, makeTestId, styled} from 'grainjs'; const testId = makeTestId('test-form-'); @@ -16,11 +20,20 @@ export class FormErrorPage extends Disposable { } public buildDom() { - return buildFormContainer(() => [ - css.formErrorMessageImageContainer(css.formErrorMessageImage({ - src: 'img/form-error.svg', - })), - css.formMessageText(this._message, testId('error-text')), - ]); + return buildFormMessagePage(() => [ + cssFormErrorMessageImageContainer( + cssFormErrorMessageImage({src: 'img/form-error.svg'}), + ), + cssFormMessageText(this._message, testId('error-page-text')), + ], testId('error-page')); } } + +const cssFormErrorMessageImageContainer = styled(cssFormMessageImageContainer, ` + height: 281px; +`); + +const cssFormErrorMessageImage = styled(cssFormMessageImage, ` + max-height: 281px; + max-width: 250px; +`); diff --git a/app/client/ui/FormPage.ts b/app/client/ui/FormPage.ts index 97d233ca..ead02922 100644 --- a/app/client/ui/FormPage.ts +++ b/app/client/ui/FormPage.ts @@ -2,18 +2,19 @@ import {FormRenderer} from 'app/client/components/FormRenderer'; import {handleSubmit, TypedFormData} from 'app/client/lib/formUtils'; import {makeT} from 'app/client/lib/localization'; import {FormModel, FormModelImpl} from 'app/client/models/FormModel'; -import {buildFormContainer} from 'app/client/ui/FormContainer'; +import {buildFormFooter} from 'app/client/ui/FormContainer'; import {FormErrorPage} from 'app/client/ui/FormErrorPage'; -import * as css from 'app/client/ui/FormPagesCss'; import {FormSuccessPage} from 'app/client/ui/FormSuccessPage'; import {colors} from 'app/client/ui2018/cssVars'; import {ApiError} from 'app/common/ApiError'; import {getPageTitleSuffix} from 'app/common/gristUrls'; import {getGristConfig} from 'app/common/urlUtils'; -import {Disposable, dom, Observable, styled, subscribe} from 'grainjs'; +import {Disposable, dom, makeTestId, Observable, styled, subscribe} from 'grainjs'; const t = makeT('FormPage'); +const testId = makeTestId('test-form-'); + export class FormPage extends Disposable { private readonly _model: FormModel = new FormModelImpl(); private readonly _error = Observable.create(this, null); @@ -30,7 +31,7 @@ export class FormPage extends Disposable { } public buildDom() { - return css.pageContainer( + return cssPageContainer( dom.domComputed(use => { const error = use(this._model.error); if (error) { return dom.create(FormErrorPage, error); } @@ -38,12 +39,12 @@ export class FormPage extends Disposable { const submitted = use(this._model.submitted); if (submitted) { return dom.create(FormSuccessPage, this._model); } - return this._buildFormDom(); + return this._buildFormPageDom(); }), ); } - private _buildFormDom() { + private _buildFormPageDom() { return dom.domComputed(use => { const form = use(this._model.form); const rootLayoutNode = use(this._model.formLayout); @@ -56,16 +57,24 @@ export class FormPage extends Disposable { error: this._error, }); - return buildFormContainer(() => + return dom('div', cssForm( - dom.autoDispose(formRenderer), - formRenderer.render(), - handleSubmit(this._model.submitting, - (_formData, formElement) => this._handleFormSubmit(formElement), - () => this._handleFormSubmitSuccess(), - (e) => this._handleFormError(e), + cssFormBody( + cssFormContent( + dom.autoDispose(formRenderer), + formRenderer.render(), + handleSubmit(this._model.submitting, + (_formData, formElement) => this._handleFormSubmit(formElement), + () => this._handleFormSubmitSuccess(), + (e) => this._handleFormError(e), + ), + ), + ), + cssFormFooter( + buildFormFooter(), ), ), + testId('page'), ); }); } @@ -101,22 +110,40 @@ export class FormPage extends Disposable { } } -// TODO: see if we can move the rest of this to `FormRenderer.ts`. -const cssForm = styled('form', ` +const cssPageContainer = styled('div', ` + height: 100%; + width: 100%; + padding: 20px; + overflow: auto; +`); + +const cssForm = styled('div', ` + display: flex; + flex-direction: column; + align-items: center; + background-color: white; + border-radius: 3px; + max-width: 600px; + margin: 0px auto; +`); + +const cssFormBody = styled('div', ` + width: 100%; +`); + +// TODO: break up and move to `FormRendererCss.ts`. +const cssFormContent = styled('form', ` color: ${colors.dark}; font-size: 15px; line-height: 1.42857143; - & > div + div { - margin-top: 16px; - } & h1, & h2, & h3, & h4, & h5, & h6 { - margin: 4px 0px; + margin: 8px 0px 12px 0px; font-weight: normal; } & h1 { @@ -149,3 +176,8 @@ const cssForm = styled('form', ` margin: 4px 0px; } `); + +const cssFormFooter = styled('div', ` + padding: 8px 16px; + width: 100%; +`); diff --git a/app/client/ui/FormPagesCss.ts b/app/client/ui/FormPagesCss.ts deleted file mode 100644 index 1f8311b3..00000000 --- a/app/client/ui/FormPagesCss.ts +++ /dev/null @@ -1,139 +0,0 @@ -import {colors, mediaSmall} from 'app/client/ui2018/cssVars'; -import {styled} from 'grainjs'; - -export const pageContainer = styled('div', ` - background-color: ${colors.lightGrey}; - height: 100%; - width: 100%; - padding: 52px 0px 52px 0px; - overflow: auto; - - @media ${mediaSmall} { - & { - padding: 20px 0px 20px 0px; - } - } -`); - -export const formContainer = styled('div', ` - padding-left: 16px; - padding-right: 16px; -`); - -export const form = styled('div', ` - display: flex; - flex-direction: column; - align-items: center; - background-color: white; - border: 1px solid ${colors.darkGrey}; - border-radius: 3px; - max-width: 600px; - margin: 0px auto; -`); - -export const formBody = styled('div', ` - width: 100%; - padding: 20px 48px 20px 48px; - - @media ${mediaSmall} { - & { - padding: 20px; - } - } -`); - -const formMessageImageContainer = styled('div', ` - margin-top: 28px; - display: flex; - justify-content: center; -`); - -export const formErrorMessageImageContainer = styled(formMessageImageContainer, ` - height: 281px; -`); - -export const formSuccessMessageImageContainer = styled(formMessageImageContainer, ` - height: 215px; -`); - -export const formMessageImage = styled('img', ` - height: 100%; - width: 100%; -`); - -export const formErrorMessageImage = styled(formMessageImage, ` - max-height: 281px; - max-width: 250px; -`); - -export const formSuccessMessageImage = styled(formMessageImage, ` - max-height: 215px; - max-width: 250px; -`); - -export const formMessageText = styled('div', ` - color: ${colors.dark}; - text-align: center; - font-weight: 600; - font-size: 16px; - line-height: 24px; - margin-top: 32px; - margin-bottom: 24px; -`); - -export const formFooter = styled('div', ` - border-top: 1px solid ${colors.darkGrey}; - padding: 8px 16px; - width: 100%; -`); - -export const poweredByGrist = styled('div', ` - color: ${colors.darkText}; - font-size: 13px; - font-style: normal; - font-weight: 600; - line-height: 16px; - display: flex; - align-items: center; - justify-content: center; - padding: 0px 10px; -`); - -export const poweredByGristLink = styled('a', ` - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - color: ${colors.darkText}; - text-decoration: none; -`); - -export const buildForm = styled('div', ` - display: flex; - align-items: center; - justify-content: center; - margin-top: 8px; -`); - -export const buildFormLink = styled('a', ` - display: flex; - align-items: center; - justify-content: center; - font-size: 11px; - line-height: 16px; - text-decoration-line: underline; - color: ${colors.darkGreen}; - --icon-color: ${colors.darkGreen}; -`); - -export const gristLogo = styled('div', ` - width: 58px; - height: 20.416px; - flex-shrink: 0; - background: url(img/logo-grist.png); - background-position: 0 0; - background-size: contain; - background-color: transparent; - background-repeat: no-repeat; - margin-top: 3px; -`); diff --git a/app/client/ui/FormSuccessPage.ts b/app/client/ui/FormSuccessPage.ts index fa168fd3..f7163abd 100644 --- a/app/client/ui/FormSuccessPage.ts +++ b/app/client/ui/FormSuccessPage.ts @@ -1,7 +1,11 @@ import {makeT} from 'app/client/lib/localization'; -import {FormModel } from 'app/client/models/FormModel'; -import {buildFormContainer} from 'app/client/ui/FormContainer'; -import * as css from 'app/client/ui/FormPagesCss'; +import {FormModel} from 'app/client/models/FormModel'; +import { + buildFormMessagePage, + cssFormMessageImage, + cssFormMessageImageContainer, + cssFormMessageText, +} from 'app/client/ui/FormContainer'; import {vars} from 'app/client/ui2018/cssVars'; import {getPageTitleSuffix} from 'app/common/gristUrls'; import {getGristConfig} from 'app/common/urlUtils'; @@ -28,20 +32,20 @@ export class FormSuccessPage extends Disposable { } public buildDom() { - return buildFormContainer(() => [ - css.formSuccessMessageImageContainer(css.formSuccessMessageImage({ - src: 'img/form-success.svg', - })), - css.formMessageText(dom.text(this._successText), testId('success-text')), + return buildFormMessagePage(() => [ + cssFormSuccessMessageImageContainer( + cssFormSuccessMessageImage({src: 'img/form-success.svg'}), + ), + cssFormMessageText(dom.text(this._successText), testId('success-page-text')), dom.maybe(this._showNewResponseButton, () => cssFormButtons( cssFormNewResponseButton( - 'Submit new response', + t('Submit new response'), dom.on('click', () => this._handleClickNewResponseButton()), ), ) ), - ]); + ], testId('success-page')); } private async _handleClickNewResponseButton() { @@ -49,6 +53,15 @@ export class FormSuccessPage extends Disposable { } } +const cssFormSuccessMessageImageContainer = styled(cssFormMessageImageContainer, ` + height: 215px; +`); + +const cssFormSuccessMessageImage = styled(cssFormMessageImage, ` + max-height: 215px; + max-width: 250px; +`); + const cssFormButtons = styled('div', ` display: flex; justify-content: center; diff --git a/app/client/ui/RightPanelStyles.ts b/app/client/ui/RightPanelStyles.ts index fac081e5..b6ec7c0f 100644 --- a/app/client/ui/RightPanelStyles.ts +++ b/app/client/ui/RightPanelStyles.ts @@ -1,5 +1,6 @@ import {theme, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; +import {numericSpinner} from 'app/client/widgets/NumericSpinner'; import {styled} from 'grainjs'; export const cssIcon = styled(icon, ` @@ -89,3 +90,7 @@ export const cssPinButton = styled('div', ` background-color: ${theme.hover}; } `); + +export const cssNumericSpinner = styled(numericSpinner, ` + height: 28px; +`); diff --git a/app/client/ui2018/checkbox.ts b/app/client/ui2018/checkbox.ts index 35f09c77..5ede5ef9 100644 --- a/app/client/ui2018/checkbox.ts +++ b/app/client/ui2018/checkbox.ts @@ -23,6 +23,7 @@ export const cssLabel = styled('label', ` display: inline-flex; min-width: 0px; margin-bottom: 0px; + flex-shrink: 0; outline: none; user-select: none; diff --git a/app/client/ui2018/radio.ts b/app/client/ui2018/radio.ts new file mode 100644 index 00000000..fc15be3b --- /dev/null +++ b/app/client/ui2018/radio.ts @@ -0,0 +1,25 @@ +import {theme} from 'app/client/ui2018/cssVars'; +import {styled} from 'grainjs'; + +export const cssRadioInput = styled('input', ` + appearance: none; + width: 16px; + height: 16px; + margin: 0px !important; + border-radius: 50%; + background-clip: content-box; + border: 1px solid ${theme.checkboxBorder}; + background-color: ${theme.checkboxBg}; + flex-shrink: 0; + &:hover { + border: 1px solid ${theme.checkboxBorderHover}; + } + &:disabled { + background-color: 1px solid ${theme.checkboxDisabledBg}; + } + &:checked { + padding: 2px; + background-color: ${theme.controlPrimaryBg}; + border: 1px solid ${theme.controlPrimaryBg}; + } +`); diff --git a/app/client/widgets/ChoiceListCell.ts b/app/client/widgets/ChoiceListCell.ts index dd33f47a..1269cf20 100644 --- a/app/client/widgets/ChoiceListCell.ts +++ b/app/client/widgets/ChoiceListCell.ts @@ -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', ` diff --git a/app/client/widgets/ChoiceTextBox.ts b/app/client/widgets/ChoiceTextBox.ts index e41753e1..f5a4208d 100644 --- a/app/client/widgets/ChoiceTextBox.ts +++ b/app/client/widgets/ChoiceTextBox.ts @@ -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) diff --git a/app/client/widgets/DateTextBox.js b/app/client/widgets/DateTextBox.js index a378a0f4..ebceb506 100644 --- a/app/client/widgets/DateTextBox.js +++ b/app/client/widgets/DateTextBox.js @@ -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), ]; }; diff --git a/app/client/widgets/FieldBuilder.ts b/app/client/widgets/FieldBuilder.ts index ef41d1a8..1e4511fa 100644 --- a/app/client/widgets/FieldBuilder.ts +++ b/app/client/widgets/FieldBuilder.ts @@ -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; + private readonly _isForm: ko.Computed; private readonly _comments: ko.Computed; private readonly _showRefConfigPopup: ko.Observable; 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> = []; _.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. diff --git a/app/client/widgets/NTextBox.ts b/app/client/widgets/NTextBox.ts index c756ef0b..e939c315 100644 --- a/app/client/widgets/NTextBox.ts +++ b/app/client/widgets/NTextBox.ts @@ -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( + this.field.widgetOptionsJson.prop('formTextFormat'), + 'singleline' + ); + const lineCount = fieldWithDefault( + 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), ]; } diff --git a/app/client/widgets/NumericSpinner.ts b/app/client/widgets/NumericSpinner.ts new file mode 100644 index 00000000..f5eb79c3 --- /dev/null +++ b/app/client/widgets/NumericSpinner.ts @@ -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; + /** No minimum if unset. */ + minValue?: number; + /** No maximum if unset. */ + maxValue?: number; + disabled?: BindableValue; + inputArgs?: IDomArgs; + /** Called on blur and spinner button click. */ + save?: (val?: number) => MaybePromise, +} + +export function numericSpinner( + value: Observable, + 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; +`); diff --git a/app/client/widgets/NumericTextBox.ts b/app/client/widgets/NumericTextBox.ts index 48a3b342..04470433 100644 --- a/app/client/widgets/NumericTextBox.ts +++ b/app/client/widgets/NumericTextBox.ts @@ -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> = [ {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(value: unknown, def: T): number | T { - return value !== null && value !== undefined ? Number(value) : def; + public buildFormConfigDom(): DomContents { + const format = fieldWithDefault( + 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, - defaultValue: Observable, - setFunc: (val?: number) => void, - disabled: BindableValue, - ...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}; diff --git a/app/client/widgets/Reference.ts b/app/client/widgets/Reference.ts index 38529bf8..282388ac 100644 --- a/app/client/widgets/Reference.ts +++ b/app/client/widgets/Reference.ts @@ -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), ]; } diff --git a/app/client/widgets/ReferenceList.ts b/app/client/widgets/ReferenceList.ts index bddff202..0429b7c3 100644 --- a/app/client/widgets/ReferenceList.ts +++ b/app/client/widgets/ReferenceList.ts @@ -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, ` diff --git a/app/client/widgets/Toggle.ts b/app/client/widgets/Toggle.ts index 5a6f440d..4ad16f6f 100644 --- a/app/client/widgets/Toggle.ts +++ b/app/client/widgets/Toggle.ts @@ -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( + 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), ]; } diff --git a/app/client/widgets/UserType.ts b/app/client/widgets/UserType.ts index c1a1fb20..06586c53 100644 --- a/app/client/widgets/UserType.ts +++ b/app/client/widgets/UserType.ts @@ -154,6 +154,7 @@ export const typeDefs: any = { widgets: { TextBox: { cons: 'TextBox', + formCons: 'Switch', editCons: 'TextEditor', icon: 'FieldTextbox', options: { diff --git a/app/client/widgets/UserTypeImpl.ts b/app/client/widgets/UserTypeImpl.ts index e9c5b25e..e2907595 100644 --- a/app/client/widgets/UserTypeImpl.ts +++ b/app/client/widgets/UserTypeImpl.ts @@ -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); diff --git a/app/common/gutil.ts b/app/common/gutil.ts index 2ff8f3b5..bea94bf1 100644 --- a/app/common/gutil.ts +++ b/app/common/gutil.ts @@ -175,6 +175,21 @@ export async function firstDefined(...list: Array<() => Promise>): Promise return undefined; } +/** + * Returns the number repesentation of `value`, or `defaultVal` if it cannot + * be represented as a valid number. + */ +export function numberOrDefault(value: unknown, defaultVal: T): number | T { + if (typeof value === 'number') { + return !Number.isNaN(value) ? value : defaultVal; + } else if (typeof value === 'string') { + const maybeNumber = Number.parseFloat(value); + return !Number.isNaN(maybeNumber) ? maybeNumber : defaultVal; + } else { + return defaultVal; + } +} + /** * Parses json and returns the result, or returns defaultVal if parsing fails. */ diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index 350fc8a4..8f01df16 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -12,7 +12,13 @@ import { UserAction } from 'app/common/DocActions'; import {DocData} from 'app/common/DocData'; -import {extractTypeFromColType, isBlankValue, isFullReferencingType, isRaisedException} from "app/common/gristTypes"; +import { + extractTypeFromColType, + getReferencedTableId, + isBlankValue, + isFullReferencingType, + isRaisedException, +} from "app/common/gristTypes"; import {INITIAL_FIELDS_COUNT} from "app/common/Forms"; import {buildUrlId, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls"; import {isAffirmative, safeJsonParse, timeoutReached} from "app/common/gutil"; @@ -260,9 +266,15 @@ export class DocWorkerApi { } function asRecords( - columnData: TableColValues, opts?: { optTableId?: string; includeHidden?: boolean }): TableRecordValue[] { + columnData: TableColValues, + opts?: { + optTableId?: string; + includeHidden?: boolean; + includeId?: boolean; + } + ): TableRecordValue[] { const fieldNames = Object.keys(columnData).filter((k) => { - if (k === "id") { + if (!opts?.includeId && k === "id") { return false; } if ( @@ -1451,9 +1463,8 @@ export class DocWorkerApi { } // Cache the table reads based on tableId. We are caching only the promise, not the result. - const table = _.memoize( - (tableId: string) => readTable(req, activeDoc, tableId, {}, {}).then(r => asRecords(r)) - ); + const table = _.memoize((tableId: string) => + readTable(req, activeDoc, tableId, {}, {}).then(r => asRecords(r, {includeId: true}))); const getTableValues = async (tableId: string, colId: string) => { const records = await table(tableId); @@ -1463,19 +1474,17 @@ export class DocWorkerApi { const Tables = activeDoc.docData.getMetaTable('_grist_Tables'); const getRefTableValues = async (col: MetaRowRecord<'_grist_Tables_column'>) => { - const refId = col.visibleCol; - if (!refId) { return [] as any; } - - const refCol = Tables_column.getRecord(refId); - if (!refCol) { return []; } + const refTableId = getReferencedTableId(col.type); + let refColId: string; + if (col.visibleCol) { + const refCol = Tables_column.getRecord(col.visibleCol); + if (!refCol) { return []; } - const refTable = Tables.getRecord(refCol.parentId); - if (!refTable) { return []; } - - const refTableId = refTable.tableId as string; - const refColId = refCol.colId as string; - if (!refTableId || !refColId) { return () => []; } - if (typeof refTableId !== 'string' || typeof refColId !== 'string') { return []; } + refColId = refCol.colId as string; + } else { + refColId = 'id'; + } + if (!refTableId || typeof refTableId !== 'string' || !refColId) { return []; } const values = await getTableValues(refTableId, refColId); return values.filter(([_id, value]) => !isBlankValue(value)); diff --git a/test/nbrowser/FormView.ts b/test/nbrowser/FormView.ts index 1aeb71dc..bfcb970b 100644 --- a/test/nbrowser/FormView.ts +++ b/test/nbrowser/FormView.ts @@ -5,7 +5,7 @@ import * as gu from 'test/nbrowser/gristUtils'; import {setupTestSuite} from 'test/nbrowser/testUtils'; describe('FormView', function() { - this.timeout('90s'); + this.timeout('2m'); gu.bigScreen(); let api: UserAPI; @@ -80,9 +80,9 @@ describe('FormView', function() { async function waitForConfirm() { await gu.waitForServer(); await gu.waitToPass(async () => { - assert.isTrue(await driver.findWait('.test-form-container', 2000).isDisplayed()); + assert.isTrue(await driver.findWait('.test-form-success-page', 2000).isDisplayed()); assert.equal( - await driver.find('.test-form-success-text').getText(), + await driver.find('.test-form-success-page-text').getText(), 'Thank you! Your response has been recorded.' ); }); @@ -96,6 +96,12 @@ describe('FormView', function() { assert.deepEqual(await api.getTable(docId, 'Table1').then(t => t.D), values); } + async function assertSubmitOnEnterIsDisabled() { + await gu.sendKeys(Key.ENTER); + await gu.waitForServer(); + assert.isFalse(await driver.find('.test-form-success-page').isPresent()); + } + describe('on personal site', async function() { before(async function() { const session = await gu.session().login(); @@ -157,7 +163,7 @@ describe('FormView', function() { await removeForm(); }); - it('can submit a form with Text field', async function() { + it('can submit a form with single-line Text field', async function() { const formUrl = await createFormWith('Text'); // We are in a new window. await gu.onNewTab(async () => { @@ -170,6 +176,7 @@ describe('FormView', function() { assert.equal(await driver.find('input[name="D"]').value(), ''); await driver.find('input[name="D"]').click(); await gu.sendKeys('Hello World'); + await assertSubmitOnEnterIsDisabled(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -178,7 +185,32 @@ describe('FormView', function() { await removeForm(); }); - it('can submit a form with Numeric field', async function() { + it('can submit a form with multi-line Text field', async function() { + const formUrl = await createFormWith('Text'); + await gu.openColumnPanel(); + await gu.waitForSidePanel(); + await driver.findContent('.test-tb-form-field-format .test-select-button', /Multi line/).click(); + await gu.waitForServer(); + // We are in a new window. + await gu.onNewTab(async () => { + await driver.get(formUrl); + await driver.findWait('textarea[name="D"]', 2000).click(); + await gu.sendKeys('Hello'); + assert.equal(await driver.find('textarea[name="D"]').value(), 'Hello'); + await driver.find('.test-form-reset').click(); + await driver.find('.test-modal-confirm').click(); + assert.equal(await driver.find('textarea[name="D"]').value(), ''); + await driver.find('textarea[name="D"]').click(); + await gu.sendKeys('Hello,', Key.ENTER, 'World'); + await driver.find('input[type="submit"]').click(); + await waitForConfirm(); + }); + // Make sure we see the new record. + await expectSingle('Hello,\nWorld'); + await removeForm(); + }); + + it('can submit a form with text Numeric field', async function() { const formUrl = await createFormWith('Numeric'); // We are in a new window. await gu.onNewTab(async () => { @@ -191,6 +223,38 @@ describe('FormView', function() { assert.equal(await driver.find('input[name="D"]').value(), ''); await driver.find('input[name="D"]').click(); await gu.sendKeys('1984'); + await assertSubmitOnEnterIsDisabled(); + await driver.find('input[type="submit"]').click(); + await waitForConfirm(); + }); + // Make sure we see the new record. + await expectSingle(1984); + await removeForm(); + }); + + it('can submit a form with spinner Numeric field', async function() { + const formUrl = await createFormWith('Numeric'); + await driver.findContent('.test-numeric-form-field-format .test-select-button', /Spinner/).click(); + await gu.waitForServer(); + // We are in a new window. + await gu.onNewTab(async () => { + await driver.get(formUrl); + await driver.findWait('input[name="D"]', 2000).click(); + await gu.sendKeys('1983'); + assert.equal(await driver.find('input[name="D"]').value(), '1983'); + await driver.find('.test-form-reset').click(); + await driver.find('.test-modal-confirm').click(); + assert.equal(await driver.find('input[name="D"]').value(), ''); + await driver.find('input[name="D"]').click(); + await gu.sendKeys('1984', Key.ARROW_UP); + assert.equal(await driver.find('input[name="D"]').value(), '1985'); + await gu.sendKeys(Key.ARROW_DOWN); + assert.equal(await driver.find('input[name="D"]').value(), '1984'); + await driver.find('.test-numeric-spinner-increment').click(); + assert.equal(await driver.find('input[name="D"]').value(), '1985'); + await driver.find('.test-numeric-spinner-decrement').click(); + assert.equal(await driver.find('input[name="D"]').value(), '1984'); + await assertSubmitOnEnterIsDisabled(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -212,6 +276,7 @@ describe('FormView', function() { assert.equal(await driver.find('input[name="D"]').getAttribute('value'), ''); await driver.find('input[name="D"]').click(); await gu.sendKeys('01012000'); + await assertSubmitOnEnterIsDisabled(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -220,17 +285,14 @@ describe('FormView', function() { await removeForm(); }); - it('can submit a form with Choice field', async function() { + it('can submit a form with select Choice field', async function() { const formUrl = await createFormWith('Choice'); // Add some options. - await gu.openColumnPanel(); - await gu.choicesEditor.edit(); await gu.choicesEditor.add('Foo'); await gu.choicesEditor.add('Bar'); await gu.choicesEditor.add('Baz'); await gu.choicesEditor.save(); - await gu.toggleSidePanel('right', 'close'); // We need to press view, as form is not saved yet. await gu.scrollActiveViewTop(); @@ -256,6 +318,12 @@ describe('FormView', function() { assert.equal(await driver.find('select[name="D"]').value(), ''); await driver.find('.test-form-search-select').click(); await driver.findContent('.test-sd-searchable-list-item', 'Bar').click(); + // Check keyboard shortcuts work. + assert.equal(await driver.find('.test-form-search-select').getText(), 'Bar'); + await gu.sendKeys(Key.BACK_SPACE); + assert.equal(await driver.find('.test-form-search-select').getText(), 'Select...'); + await gu.sendKeys(Key.ENTER); + await driver.findContent('.test-sd-searchable-list-item', 'Bar').click(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -263,7 +331,41 @@ describe('FormView', function() { await removeForm(); }); - it('can submit a form with Integer field', async function() { + it('can submit a form with radio Choice field', async function() { + const formUrl = await createFormWith('Choice'); + await driver.findContent('.test-form-field-format .test-select-button', /Radio/).click(); + await gu.waitForServer(); + await gu.choicesEditor.edit(); + await gu.choicesEditor.add('Foo'); + await gu.choicesEditor.add('Bar'); + await gu.choicesEditor.add('Baz'); + await gu.choicesEditor.save(); + await gu.scrollActiveViewTop(); + await gu.waitToPass(async () => { + assert.isTrue(await driver.find('.test-forms-view').isDisplayed()); + }); + // We are in a new window. + await gu.onNewTab(async () => { + await driver.get(formUrl); + await driver.findWait('input[name="D"]', 2000); + assert.deepEqual( + await driver.findAll('label:has(input[name="D"])', e => e.getText()), ['Foo', 'Bar', 'Baz'] + ); + await driver.find('input[name="D"][value="Baz"]').click(); + assert.equal(await driver.find('input[name="D"][value="Baz"]').getAttribute('checked'), 'true'); + await driver.find('.test-form-reset').click(); + await driver.find('.test-modal-confirm').click(); + assert.equal(await driver.find('input[name="D"][value="Baz"]').getAttribute('checked'), null); + await driver.find('input[name="D"][value="Bar"]').click(); + await assertSubmitOnEnterIsDisabled(); + await driver.find('input[type="submit"]').click(); + await waitForConfirm(); + }); + await expectSingle('Bar'); + await removeForm(); + }); + + it('can submit a form with text Integer field', async function() { const formUrl = await createFormWith('Integer', true); // We are in a new window. await gu.onNewTab(async () => { @@ -276,6 +378,7 @@ describe('FormView', function() { assert.equal(await driver.find('input[name="D"]').value(), ''); await driver.find('input[name="D"]').click(); await gu.sendKeys('1984'); + await assertSubmitOnEnterIsDisabled(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -284,7 +387,38 @@ describe('FormView', function() { await removeForm(); }); - it('can submit a form with Toggle field', async function() { + it('can submit a form with spinner Integer field', async function() { + const formUrl = await createFormWith('Integer', true); + await driver.findContent('.test-numeric-form-field-format .test-select-button', /Spinner/).click(); + await gu.waitForServer(); + // We are in a new window. + await gu.onNewTab(async () => { + await driver.get(formUrl); + await driver.findWait('input[name="D"]', 2000).click(); + await gu.sendKeys('1983'); + assert.equal(await driver.find('input[name="D"]').value(), '1983'); + await driver.find('.test-form-reset').click(); + await driver.find('.test-modal-confirm').click(); + assert.equal(await driver.find('input[name="D"]').value(), ''); + await driver.find('input[name="D"]').click(); + await gu.sendKeys('1984', Key.ARROW_UP); + assert.equal(await driver.find('input[name="D"]').value(), '1985'); + await gu.sendKeys(Key.ARROW_DOWN); + assert.equal(await driver.find('input[name="D"]').value(), '1984'); + await driver.find('.test-numeric-spinner-increment').click(); + assert.equal(await driver.find('input[name="D"]').value(), '1985'); + await driver.find('.test-numeric-spinner-decrement').click(); + assert.equal(await driver.find('input[name="D"]').value(), '1984'); + await assertSubmitOnEnterIsDisabled(); + await driver.find('input[type="submit"]').click(); + await waitForConfirm(); + }); + // Make sure we see the new record. + await expectSingle(1984); + await removeForm(); + }); + + it('can submit a form with switch Toggle field', async function() { const formUrl = await createFormWith('Toggle', true); // We are in a new window. await gu.onNewTab(async () => { @@ -295,6 +429,39 @@ describe('FormView', function() { await driver.find('.test-modal-confirm').click(); assert.equal(await driver.find('input[name="D"]').getAttribute('checked'), null); await driver.find('input[name="D"]').findClosest("label").click(); + await assertSubmitOnEnterIsDisabled(); + await driver.find('input[type="submit"]').click(); + await waitForConfirm(); + }); + await expectSingle(true); + await gu.onNewTab(async () => { + await driver.get(formUrl); + await driver.findWait('input[type="submit"]', 2000).click(); + await waitForConfirm(); + }); + await expectInD([true, false]); + + // Remove the additional record added just now. + await gu.sendActions([ + ['RemoveRecord', 'Table1', 2], + ]); + await removeForm(); + }); + + it('can submit a form with checkbox Toggle field', async function() { + const formUrl = await createFormWith('Toggle', true); + await driver.findContent('.test-toggle-form-field-format .test-select-button', /Checkbox/).click(); + await gu.waitForServer(); + // We are in a new window. + await gu.onNewTab(async () => { + await driver.get(formUrl); + await driver.findWait('input[name="D"]', 2000).findClosest("label").click(); + assert.equal(await driver.find('input[name="D"]').getAttribute('checked'), 'true'); + await driver.find('.test-form-reset').click(); + await driver.find('.test-modal-confirm').click(); + assert.equal(await driver.find('input[name="D"]').getAttribute('checked'), null); + await driver.find('input[name="D"]').findClosest("label").click(); + await assertSubmitOnEnterIsDisabled(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -334,6 +501,7 @@ describe('FormView', function() { assert.equal(await driver.find('input[name="D[]"][value="Bar"]').getAttribute('checked'), null); await driver.find('input[name="D[]"][value="Foo"]').click(); await driver.find('input[name="D[]"][value="Baz"]').click(); + await assertSubmitOnEnterIsDisabled(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -342,7 +510,7 @@ describe('FormView', function() { await removeForm(); }); - it('can submit a form with Ref field', async function() { + it('can submit a form with select Ref field', async function() { const formUrl = await createFormWith('Reference', true); // Add some options. await gu.openColumnPanel(); @@ -353,22 +521,21 @@ describe('FormView', function() { ['AddRecord', 'Table1', null, {A: 'Bar'}], // id 2 ['AddRecord', 'Table1', null, {A: 'Baz'}], // id 3 ]); - await gu.toggleSidePanel('right', 'close'); // We are in a new window. await gu.onNewTab(async () => { await driver.get(formUrl); await driver.findWait('select[name="D"]', 2000); assert.deepEqual( await driver.findAll('select[name="D"] option', e => e.getText()), - ['Select...', ...['Bar', 'Baz', 'Foo']] + ['Select...', 'Foo', 'Bar', 'Baz'] ); assert.deepEqual( await driver.findAll('select[name="D"] option', e => e.value()), - ['', ...['2', '3', '1']] + ['', '1', '2', '3'] ); await driver.find('.test-form-search-select').click(); assert.deepEqual( - await driver.findAll('.test-sd-searchable-list-item', e => e.getText()), ['Select...', 'Bar', 'Baz', 'Foo'] + await driver.findAll('.test-sd-searchable-list-item', e => e.getText()), ['Select...', 'Foo', 'Bar', 'Baz'] ); await gu.sendKeys('Baz', Key.ENTER); assert.equal(await driver.find('select[name="D"]').value(), '3'); @@ -377,6 +544,51 @@ describe('FormView', function() { assert.equal(await driver.find('select[name="D"]').value(), ''); await driver.find('.test-form-search-select').click(); await driver.findContent('.test-sd-searchable-list-item', 'Bar').click(); + // Check keyboard shortcuts work. + assert.equal(await driver.find('.test-form-search-select').getText(), 'Bar'); + await gu.sendKeys(Key.BACK_SPACE); + assert.equal(await driver.find('.test-form-search-select').getText(), 'Select...'); + await gu.sendKeys(Key.ENTER); + await driver.findContent('.test-sd-searchable-list-item', 'Bar').click(); + await driver.find('input[type="submit"]').click(); + await waitForConfirm(); + }); + await expectInD([0, 0, 0, 2]); + + // Remove 3 records. + await gu.sendActions([ + ['BulkRemoveRecord', 'Table1', [1, 2, 3, 4]], + ]); + + await removeForm(); + }); + + it('can submit a form with radio Ref field', async function() { + const formUrl = await createFormWith('Reference', true); + await driver.findContent('.test-form-field-format .test-select-button', /Radio/).click(); + await gu.waitForServer(); + await gu.setRefShowColumn('A'); + await gu.sendActions([ + ['AddRecord', 'Table1', null, {A: 'Foo'}], + ['AddRecord', 'Table1', null, {A: 'Bar'}], + ['AddRecord', 'Table1', null, {A: 'Baz'}], + ]); + // We are in a new window. + await gu.onNewTab(async () => { + await driver.get(formUrl); + await driver.findWait('input[name="D"]', 2000); + assert.deepEqual( + await driver.findAll('label:has(input[name="D"])', e => e.getText()), ['Foo', 'Bar', 'Baz'] + ); + assert.equal(await driver.find('label:has(input[name="D"][value="3"])').getText(), 'Baz'); + await driver.find('input[name="D"][value="3"]').click(); + assert.equal(await driver.find('input[name="D"][value="3"]').getAttribute('checked'), 'true'); + await driver.find('.test-form-reset').click(); + await driver.find('.test-modal-confirm').click(); + assert.equal(await driver.find('input[name="D"][value="3"]').getAttribute('checked'), null); + assert.equal(await driver.find('label:has(input[name="D"][value="2"])').getText(), 'Bar'); + await driver.find('input[name="D"][value="2"]').click(); + await assertSubmitOnEnterIsDisabled(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -393,8 +605,6 @@ describe('FormView', function() { it('can submit a form with RefList field', async function() { const formUrl = await createFormWith('Reference List', true); // Add some options. - await gu.openColumnPanel(); - await gu.setRefShowColumn('A'); // Add 3 records to this table (it is now empty). await gu.sendActions([ @@ -416,6 +626,7 @@ describe('FormView', function() { assert.equal(await driver.find('input[name="D[]"][value="1"]').getAttribute('checked'), null); await driver.find('input[name="D[]"][value="1"]').click(); await driver.find('input[name="D[]"][value="2"]').click(); + await assertSubmitOnEnterIsDisabled(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -542,9 +753,9 @@ describe('FormView', function() { await gu.waitForServer(); await gu.onNewTab(async () => { await driver.get(formUrl); - assert.isTrue(await driver.findWait('.test-form-container', 2000).isDisplayed()); + assert.isTrue(await driver.findWait('.test-form-error-page', 2000).isDisplayed()); assert.equal( - await driver.find('.test-form-error-text').getText(), + await driver.find('.test-form-error-page-text').getText(), 'Oops! This form is no longer published.' ); }); @@ -739,8 +950,8 @@ describe('FormView', function() { // Now B is selected. assert.equal(await selectedLabel(), 'B'); - // Click on the edit button. - await driver.find('.test-forms-submit').click(); + // Click the blank space above the submit button. + await driver.find('.test-forms-error').click(); // Now nothing is selected. assert.isFalse(await isSelected(), 'Something is selected'); @@ -825,7 +1036,6 @@ describe('FormView', function() { assert.deepEqual(await hiddenColumns(), []); // Now hide it using Delete key. - await driver.find('.test-forms-submit').click(); await question('Choice').click(); await gu.sendKeys(Key.DELETE); await gu.waitForServer(); @@ -833,8 +1043,20 @@ describe('FormView', function() { // It should be hidden again. assert.deepEqual(await hiddenColumns(), ['Choice']); assert.deepEqual(await readLabels(), ['A', 'B', 'C']); + }); - + it('changing field types works', async function() { + await gu.openColumnPanel(); + assert.equal(await questionType('A'), 'Any'); + await question('A').click(); + await gu.setType('Text'); + assert.equal(await questionType('A'), 'Text'); + await gu.sendActions([['AddRecord', 'Form', null, {A: 'Foo'}]]); + await question('A').click(); + await gu.setType('Numeric', {apply: true}); + assert.equal(await questionType('A'), 'Numeric'); + await gu.sendActions([['RemoveRecord', 'Form', 1]]); + await gu.undo(2); await gu.toggleSidePanel('right', 'close'); });