mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(core) New Grist Forms styling and field options
Summary: - New styling for forms. - New field options for various field types (spinner, checkbox, radio buttons, alignment, sort). - Improved alignment of form fields in columns. - Support for additional select input keyboard shortcuts (Enter and Backspace). - Prevent submitting form on Enter if an input has focus. - Fix for changing form field type causing the field to disappear. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D4223
This commit is contained in:
		
							parent
							
								
									661f1c1804
								
							
						
					
					
						commit
						86062a8c28
					
				| @ -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<string>(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<string>(this, ''); | ||||
|   private _spinnerValue = Observable.create<number|''>(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<string>(this, ''); | ||||
|   protected value: Observable<string>; | ||||
| 
 | ||||
|   private _choices: string[]; | ||||
|   private _selectElement: HTMLElement; | ||||
|   private _ctl?: PopupControl<IPopupOptions>; | ||||
|   private _format = this.field.options.formSelectFormat ?? 'select'; | ||||
|   private _alignment = this.field.options.formOptionsAlignment ?? 'vertical'; | ||||
|   private _radioButtons: MutableObsArray<{ | ||||
|     label: string; | ||||
|     checked: Observable<string|null> | ||||
|   }> = 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<string>(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<boolean>(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<string|null> | ||||
|   }> = 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<string|null> | ||||
|   }> = 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<IPopupOptions>; | ||||
|   private _radioButtons: MutableObsArray<{ | ||||
|     label: string; | ||||
|     value: string; | ||||
|     checked: Observable<string|null> | ||||
|   }> = this.autoDispose(obsArray()); | ||||
| 
 | ||||
|   public constructor(field: FormField, context: FormRendererContext) { | ||||
|     super(field, context); | ||||
| 
 | ||||
|     const choices: [number|string, CellValue][] = this.field.refValues ?? []; | ||||
|     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.
 | ||||
|     this._choices = choices.slice(0, 1000); | ||||
| 
 | ||||
|     this.value = Observable.create<string>(this, ''); | ||||
| 
 | ||||
|     this._radioButtons.set(this._choices.map(reference => ({ | ||||
|       label: String(reference[1]), | ||||
|       value: String(reference[0]), | ||||
|       checked: Observable.create(this, null), | ||||
|     }))); | ||||
|   } | ||||
| 
 | ||||
|   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]))); | ||||
|     // Support for 1000 choices. TODO: make limit dynamic.
 | ||||
|     choices.splice(1000); | ||||
|     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<string>({ | ||||
|             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'); | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -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%; | ||||
| 
 | ||||
|  | ||||
| @ -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(); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -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<FormTextFormat>(this, (use) => { | ||||
|     const field = use(this.field); | ||||
|     return use(field.widgetOptionsJson.prop('formTextFormat')) ?? 'singleline'; | ||||
|   }); | ||||
| 
 | ||||
|   private _rowCount = Computed.create<number>(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.model.field).colId)), | ||||
|       {disabled: true}, | ||||
|       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 ChoiceModel extends Question { | ||||
|   protected choices: Computed<string[]> = 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; | ||||
|     } | ||||
| class NumericModel extends Question { | ||||
|   private _format = Computed.create<FormNumberFormat>(this, (use) => { | ||||
|     const field = use(this.field); | ||||
|     return use(field.widgetOptionsJson.prop('formNumberFormat')) ?? 'text'; | ||||
|   }); | ||||
| 
 | ||||
|   public renderInput(): HTMLElement { | ||||
|     const field = this.model.field; | ||||
|   public renderInput() { | ||||
|     return dom.domComputed(this._format, (format) => { | ||||
|       switch (format) { | ||||
|         case 'text': { | ||||
|           return this._renderTextInput(); | ||||
|         } | ||||
|         case 'spinner': { | ||||
|           return this._renderSpinnerInput(); | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   private _renderTextInput() { | ||||
|     return css.cssInput( | ||||
|       dom.prop('name', u => u(u(this.field).colId)), | ||||
|       {type: 'text', tabIndex: "-1"}, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   private _renderSpinnerInput() { | ||||
|     return css.cssSpinner(observable(''), {}); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class ChoiceModel extends Question { | ||||
|   protected choices: Computed<string[]>; | ||||
| 
 | ||||
|   protected alignment = Computed.create<FormOptionsAlignment>(this, (use) => { | ||||
|     const field = use(this.field); | ||||
|     return use(field.widgetOptionsJson.prop('formOptionsAlignment')) ?? 'vertical'; | ||||
|   }); | ||||
| 
 | ||||
|   private _format = Computed.create<FormSelectFormat>(this, (use) => { | ||||
|     const field = use(this.field); | ||||
|     return use(field.widgetOptionsJson.prop('formSelectFormat')) ?? 'select'; | ||||
|   }); | ||||
| 
 | ||||
|   private _sortOrder = Computed.create<FormOptionsSortOrder>(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<FormToggleFormat>(this, (use) => { | ||||
|     const field = use(this.field); | ||||
|     return use(field.widgetOptionsJson.prop('formToggleFormat')) ?? 'switch'; | ||||
|   }); | ||||
| 
 | ||||
|   public override buildDom(props: { | ||||
|     edit: Observable<boolean>, | ||||
|     overlay: Observable<boolean>, | ||||
| @ -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<FormOptionsAlignment>(this, (use) => { | ||||
|     const field = use(this.field); | ||||
|     return use(field.widgetOptionsJson.prop('formOptionsAlignment')) ?? 'vertical'; | ||||
|   }); | ||||
| 
 | ||||
|   private _sortOrder = Computed.create<FormOptionsSortOrder>(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<FormSelectFormat>(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<Question> { | ||||
|   switch (type) { | ||||
| @ -436,7 +651,7 @@ function fieldConstructor(type: string): Constructor<Question> { | ||||
|     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<Question> { | ||||
| function testType(value: BindableValue<string>) { | ||||
|   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}; | ||||
| `);
 | ||||
|  | ||||
| @ -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 requiredField: KoSaveableObservable<boolean> = this._field.widgetOptionsJson.prop('formRequired'); | ||||
|     const format = fieldWithDefault<FormSelectFormat>( | ||||
|       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<FormOptionsAlignment>( | ||||
|       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<FormOptionsSortOrder>( | ||||
|       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 = fieldWithDefault<boolean>( | ||||
|       this._field.widgetOptionsJson.prop('formRequired'), | ||||
|       false | ||||
|     ); | ||||
| 
 | ||||
|     return [ | ||||
|       cssSeparator(), | ||||
|       cssLabel(t('Field rules')), | ||||
|       cssLabel(t('Field Rules')), | ||||
|       cssRow(labeledSquareCheckbox( | ||||
|         fromKoSave(requiredField), | ||||
|         t('Required field'), | ||||
|  | ||||
| @ -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<void>) => Promise<void>; | ||||
| 
 | ||||
|   private _formFields: Computed<ViewFieldRec[]>; | ||||
|   private _autoLayout: Computed<FormLayoutNode>; | ||||
|   private _layoutSpec: SaveableObjObservable<FormLayoutNode>; | ||||
|   private _layout: Computed<FormLayoutNode>; | ||||
|   private _root: BoxModel; | ||||
|   private _savedLayout: any; | ||||
|   private _saving: boolean = false; | ||||
| @ -67,7 +69,7 @@ export class FormView extends Disposable { | ||||
|   private _showPublishedMessage: Observable<boolean>; | ||||
|   private _isOwner: boolean; | ||||
|   private _openingForm: Observable<boolean>; | ||||
|   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._autoLayout = Computed.create(this, use => { | ||||
|     this._layoutSpec = jsonObservable(this.viewSection.layoutSpec, (layoutSpec: FormLayoutNode|null) => { | ||||
|       return layoutSpec ?? buildDefaultFormLayout(this._formFields.get()); | ||||
|     }); | ||||
| 
 | ||||
|     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<void>) => { | ||||
|     this._root = this.autoDispose(new LayoutModel(this._layout.get(), null, async (clb?: () => Promise<void>) => { | ||||
|       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); | ||||
|  | ||||
| @ -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<void>; | ||||
| 
 | ||||
| @ -186,7 +196,7 @@ export abstract class BoxModel extends Disposable { | ||||
|     return this._props.hasOwnProperty(name); | ||||
|   } | ||||
| 
 | ||||
|   public async save(before?: () => Promise<void>): Promise<void> { | ||||
|   public async save(before?: () => MaybePromise<void>): Promise<void> { | ||||
|     if (!this.parent) { throw new Error('Cannot save detached box'); } | ||||
|     return this.parent.save(before); | ||||
|   } | ||||
|  | ||||
| @ -62,7 +62,6 @@ export class SectionModel extends BoxModel { | ||||
|           ), | ||||
|         ) | ||||
|       )}, | ||||
|       style.cssSectionEditor.cls(''), | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -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"), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -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<string|undefined>, ...args: DomElementArg[]): HTMLInputElement { | ||||
|   return dom('input', | ||||
|     dom.prop('value', u => u(obs) || ''), | ||||
| @ -118,11 +124,14 @@ export function textbox(obs: Observable<string|undefined>, ...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', ` | ||||
|  | ||||
| @ -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); | ||||
|       // The newly-added section should be given focus.
 | ||||
|       this.viewModel.activeSectionId(result.sectionRef); | ||||
| 
 | ||||
|       this._maybeShowEditCardLayoutTip(val.type).catch(reportError); | ||||
| 
 | ||||
|       if (AttachedCustomWidgets.guard(val.type)) { | ||||
|         this._handleNewAttachedCustomWidget(val.type).catch(reportError); | ||||
|       } | ||||
|       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(sectionRef); | ||||
|     } | ||||
| 
 | ||||
|     this._maybeShowEditCardLayoutTip(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({ | ||||
|  | ||||
| @ -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<Form>; | ||||
|   createRecord(options: CreateRecordOptions): Promise<void>; | ||||
|  | ||||
| @ -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}; | ||||
| `);
 | ||||
|  | ||||
| @ -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; | ||||
| `);
 | ||||
|  | ||||
| @ -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<string|null>(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%; | ||||
| `);
 | ||||
|  | ||||
| @ -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; | ||||
| `);
 | ||||
| @ -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; | ||||
|  | ||||
| @ -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; | ||||
| `);
 | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
							
								
								
									
										25
									
								
								app/client/ui2018/radio.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								app/client/ui2018/radio.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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}; | ||||
|   } | ||||
| `);
 | ||||
| @ -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', ` | ||||
|  | ||||
| @ -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) | ||||
|  | ||||
| @ -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), | ||||
|   ]; | ||||
| }; | ||||
| 
 | ||||
|  | ||||
| @ -108,12 +108,11 @@ export class FieldBuilder extends Disposable { | ||||
|   private readonly _widgetCons: ko.Computed<{create: (...args: any[]) => NewAbstractWidget}>; | ||||
|   private readonly _docModel: DocModel; | ||||
|   private readonly _readonly: Computed<boolean>; | ||||
|   private readonly _isForm: ko.Computed<boolean>; | ||||
|   private readonly _comments: ko.Computed<boolean>; | ||||
|   private readonly _showRefConfigPopup: ko.Observable<boolean>; | ||||
|   private readonly _isEditorActive = Observable.create(this, false); | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
|   public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec, | ||||
|                      private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) { | ||||
|     super(); | ||||
| @ -128,9 +127,13 @@ export class FieldBuilder extends Disposable { | ||||
|     this._readonly = Computed.create(this, (use) => | ||||
|       use(gristDoc.isReadonly) || use(field.disableEditData) || Boolean(this._options.isPreview)); | ||||
| 
 | ||||
|     this._isForm = this.autoDispose(ko.computed(() => { | ||||
|       return this.field.viewSection().widgetType() === WidgetType.Form; | ||||
|     })); | ||||
| 
 | ||||
|     // Observable with a list of available types.
 | ||||
|     this._availableTypes = Computed.create(this, (use) => { | ||||
|       const isForm = use(use(this.field.viewSection).widgetType) === WidgetType.Form; | ||||
|       const isForm = use(this._isForm); | ||||
|       const isFormula = use(this.origColumn.isFormula); | ||||
|       const types: Array<IOptionFull<string>> = []; | ||||
|       _.each(UserType.typeDefs, (def: any, key: string|number) => { | ||||
| @ -201,8 +204,11 @@ export class FieldBuilder extends Disposable { | ||||
| 
 | ||||
|     // Returns the constructor for the widget, and only notifies subscribers on changes.
 | ||||
|     this._widgetCons = this.autoDispose(koUtil.withKoUtils(ko.computed(() => { | ||||
|       return UserTypeImpl.getWidgetConstructor(this.options().widget, | ||||
|                                                this._readOnlyPureType()); | ||||
|       if (this._isForm()) { | ||||
|         return UserTypeImpl.getFormWidgetConstructor(this.options().widget, this._readOnlyPureType()); | ||||
|       } else { | ||||
|         return UserTypeImpl.getWidgetConstructor(this.options().widget, this._readOnlyPureType()); | ||||
|       } | ||||
|     })).onlyNotifyUnequal()); | ||||
| 
 | ||||
|     // Computed builder for the widget.
 | ||||
|  | ||||
| @ -1,14 +1,16 @@ | ||||
| import { FieldRulesConfig } from 'app/client/components/Forms/FormConfig'; | ||||
| import { FormFieldRulesConfig } from 'app/client/components/Forms/FormConfig'; | ||||
| import { fromKoSave } from 'app/client/lib/fromKoSave'; | ||||
| import { makeT } from 'app/client/lib/localization'; | ||||
| import { DataRowModel } from 'app/client/models/DataRowModel'; | ||||
| import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec'; | ||||
| import { cssRow } from 'app/client/ui/RightPanelStyles'; | ||||
| import { alignmentSelect, cssButtonSelect, makeButtonSelect } from 'app/client/ui2018/buttonSelect'; | ||||
| import { fieldWithDefault } from 'app/client/models/modelUtil'; | ||||
| import { FormTextFormat } from 'app/client/ui/FormAPI'; | ||||
| import { cssLabel, cssNumericSpinner, cssRow } from 'app/client/ui/RightPanelStyles'; | ||||
| import { alignmentSelect, buttonSelect, cssButtonSelect, makeButtonSelect } from 'app/client/ui2018/buttonSelect'; | ||||
| import { testId } from 'app/client/ui2018/cssVars'; | ||||
| import { makeLinks } from 'app/client/ui2018/links'; | ||||
| import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget'; | ||||
| import { Computed, dom, DomContents, fromKo, Observable } from 'grainjs'; | ||||
| import { makeT } from 'app/client/lib/localization'; | ||||
| 
 | ||||
| const t = makeT('NTextBox'); | ||||
| 
 | ||||
| @ -60,8 +62,42 @@ export class NTextBox extends NewAbstractWidget { | ||||
|   } | ||||
| 
 | ||||
|   public buildFormConfigDom(): DomContents { | ||||
|     const format = fieldWithDefault<FormTextFormat>( | ||||
|       this.field.widgetOptionsJson.prop('formTextFormat'), | ||||
|       'singleline' | ||||
|     ); | ||||
|     const lineCount = fieldWithDefault<number|"">( | ||||
|       this.field.widgetOptionsJson.prop('formTextLineCount'), | ||||
|       '' | ||||
|     ); | ||||
| 
 | ||||
|     return [ | ||||
|       dom.create(FieldRulesConfig, this.field), | ||||
|       cssLabel(t('Field Format')), | ||||
|       cssRow( | ||||
|         buttonSelect( | ||||
|           fromKoSave(format), | ||||
|           [ | ||||
|             {value: 'singleline', label: t('Single line')}, | ||||
|             {value: 'multiline', label: t('Multi line')}, | ||||
|           ], | ||||
|           testId('tb-form-field-format'), | ||||
|         ), | ||||
|       ), | ||||
|       dom.maybe(use => use(format) === 'multiline', () => | ||||
|         cssRow( | ||||
|           cssNumericSpinner( | ||||
|             fromKo(lineCount), | ||||
|             { | ||||
|               label: t('Lines'), | ||||
|               defaultValue: 3, | ||||
|               minValue: 1, | ||||
|               maxValue: 99, | ||||
|               save: async (val) => lineCount.setAndSave((val && Math.floor(val)) ?? ''), | ||||
|             }, | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|       dom.create(FormFieldRulesConfig, this.field), | ||||
|     ]; | ||||
|   } | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										172
									
								
								app/client/widgets/NumericSpinner.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								app/client/widgets/NumericSpinner.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,172 @@ | ||||
| import {theme} from 'app/client/ui2018/cssVars'; | ||||
| import {icon} from 'app/client/ui2018/icons'; | ||||
| import {clamp, numberOrDefault} from 'app/common/gutil'; | ||||
| import {MaybePromise} from 'app/plugin/gutil'; | ||||
| import {BindableValue, dom, DomElementArg, IDomArgs, makeTestId, Observable, styled} from 'grainjs'; | ||||
| 
 | ||||
| const testId = makeTestId('test-numeric-spinner-'); | ||||
| 
 | ||||
| export interface NumericSpinnerOptions { | ||||
|   /** Defaults to `false`. */ | ||||
|   setValueOnInput?: boolean; | ||||
|   label?: string; | ||||
|   defaultValue?: number | Observable<number>; | ||||
|   /** No minimum if unset. */ | ||||
|   minValue?: number; | ||||
|   /** No maximum if unset. */ | ||||
|   maxValue?: number; | ||||
|   disabled?: BindableValue<boolean>; | ||||
|   inputArgs?: IDomArgs<HTMLInputElement>; | ||||
|   /** Called on blur and spinner button click. */ | ||||
|   save?: (val?: number) => MaybePromise<void>, | ||||
| } | ||||
| 
 | ||||
| export function numericSpinner( | ||||
|   value: Observable<number | ''>, | ||||
|   options: NumericSpinnerOptions = {}, | ||||
|   ...args: DomElementArg[] | ||||
| ) { | ||||
|   const { | ||||
|     setValueOnInput = false, | ||||
|     label, | ||||
|     defaultValue, | ||||
|     minValue = Number.NEGATIVE_INFINITY, | ||||
|     maxValue = Number.POSITIVE_INFINITY, | ||||
|     disabled, | ||||
|     inputArgs = [], | ||||
|     save, | ||||
|   } = options; | ||||
| 
 | ||||
|   const getDefaultValue = () => { | ||||
|     if (defaultValue === undefined) { | ||||
|       return 0; | ||||
|     } else if (typeof defaultValue === 'number') { | ||||
|       return defaultValue; | ||||
|     } else { | ||||
|       return defaultValue.get(); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   let inputElement: HTMLInputElement; | ||||
| 
 | ||||
|   const shiftValue = async (delta: 1 | -1, opts: {saveValue?: boolean} = {}) => { | ||||
|     const {saveValue} = opts; | ||||
|     const currentValue = numberOrDefault(inputElement.value, getDefaultValue()); | ||||
|     const newValue = clamp(Math.floor(currentValue + delta), minValue, maxValue); | ||||
|     if (setValueOnInput) { value.set(newValue); } | ||||
|     if (saveValue) { await save?.(newValue); } | ||||
|     return newValue; | ||||
|   }; | ||||
|   const incrementValue = (opts: {saveValue?: boolean} = {}) => shiftValue(1, opts); | ||||
|   const decrementValue = (opts: {saveValue?: boolean} = {}) => shiftValue(-1, opts); | ||||
| 
 | ||||
|   return cssNumericSpinner( | ||||
|     disabled ? cssNumericSpinner.cls('-disabled', disabled) : null, | ||||
|     label ? cssNumLabel(label) : null, | ||||
|     inputElement = cssNumInput( | ||||
|       {type: 'number'}, | ||||
|       dom.prop('value', value), | ||||
|       defaultValue !== undefined ? dom.prop('placeholder', defaultValue) : null, | ||||
|       dom.onKeyDown({ | ||||
|         ArrowUp: async (_ev, elem) => { elem.value = String(await incrementValue()); }, | ||||
|         ArrowDown: async (_ev, elem) => { elem.value = String(await decrementValue()); }, | ||||
|         Enter$: async (_ev, elem) => save && elem.blur(), | ||||
|       }), | ||||
|       !setValueOnInput ? null : dom.on('input', (_ev, elem) => { | ||||
|         value.set(Number.parseFloat(elem.value)); | ||||
|       }), | ||||
|       !save ? null : dom.on('blur', async () => { | ||||
|         let newValue = numberOrDefault(inputElement.value, undefined); | ||||
|         if (newValue !== undefined) { newValue = clamp(newValue, minValue, maxValue); } | ||||
|         await save(newValue); | ||||
|       }), | ||||
|       dom.on('focus', (_ev, elem) => elem.select()), | ||||
|       ...inputArgs, | ||||
|     ), | ||||
|     cssSpinner( | ||||
|       cssSpinnerBtn( | ||||
|         cssSpinnerTop('DropdownUp'), | ||||
|         dom.on('click', async () => incrementValue({saveValue: true})), | ||||
|         testId('increment'), | ||||
|       ), | ||||
|       cssSpinnerBtn( | ||||
|         cssSpinnerBottom('Dropdown'), | ||||
|         dom.on('click', async () => decrementValue({saveValue: true})), | ||||
|         testId('decrement'), | ||||
|       ), | ||||
|     ), | ||||
|     ...args | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| const cssNumericSpinner = styled('div', ` | ||||
|   position: relative; | ||||
|   flex: auto; | ||||
|   font-weight: normal; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   outline: 1px solid ${theme.inputBorder}; | ||||
|   background-color: ${theme.inputBg}; | ||||
|   border-radius: 3px; | ||||
|   &-disabled { | ||||
|     opacity: 0.4; | ||||
|     pointer-events: none; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssNumLabel = styled('div', ` | ||||
|   color: ${theme.lightText}; | ||||
|   flex-shrink: 0; | ||||
|   padding-left: 8px; | ||||
|   pointer-events: none; | ||||
| `);
 | ||||
| 
 | ||||
| const cssNumInput = styled('input', ` | ||||
|   flex-grow: 1; | ||||
|   padding: 4px 32px 4px 8px; | ||||
|   width: 100%; | ||||
|   text-align: right; | ||||
|   appearance: none; | ||||
|   color: ${theme.inputFg}; | ||||
|   background-color: transparent; | ||||
|   border: none; | ||||
|   outline: none; | ||||
|   -moz-appearance: textfield; | ||||
| 
 | ||||
|   &::-webkit-outer-spin-button, | ||||
|   &::-webkit-inner-spin-button { | ||||
|     -webkit-appearance: none; | ||||
|     margin: 0; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssSpinner = styled('div', ` | ||||
|   position: absolute; | ||||
|   right: 8px; | ||||
|   width: 16px; | ||||
|   height: 100%; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
| `);
 | ||||
| 
 | ||||
| const cssSpinnerBtn = styled('div', ` | ||||
|   --icon-color: ${theme.controlSecondaryFg}; | ||||
|   flex: 1 1 0px; | ||||
|   min-height: 0px; | ||||
|   position: relative; | ||||
|   cursor: pointer; | ||||
|   overflow: hidden; | ||||
|   &:hover { | ||||
|     --icon-color: ${theme.controlSecondaryHoverFg}; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssSpinnerTop = styled(icon, ` | ||||
|   position: absolute; | ||||
|   top: 0px; | ||||
| `);
 | ||||
| 
 | ||||
| const cssSpinnerBottom = styled(icon, ` | ||||
|   position: absolute; | ||||
|   bottom: 0px; | ||||
| `);
 | ||||
| @ -1,23 +1,25 @@ | ||||
| /** | ||||
|  * See app/common/NumberFormat for description of options we support. | ||||
|  */ | ||||
| import {FormFieldRulesConfig} from 'app/client/components/Forms/FormConfig'; | ||||
| import {fromKoSave} from 'app/client/lib/fromKoSave'; | ||||
| import {makeT} from 'app/client/lib/localization'; | ||||
| import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; | ||||
| import {reportError} from 'app/client/models/errors'; | ||||
| import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles'; | ||||
| import {cssButtonSelect, ISelectorOption, makeButtonSelect} from 'app/client/ui2018/buttonSelect'; | ||||
| import {fieldWithDefault} from 'app/client/models/modelUtil'; | ||||
| import {FormNumberFormat} from 'app/client/ui/FormAPI'; | ||||
| import {cssLabel, cssNumericSpinner, cssRow} from 'app/client/ui/RightPanelStyles'; | ||||
| import {buttonSelect, cssButtonSelect, ISelectorOption, makeButtonSelect} from 'app/client/ui2018/buttonSelect'; | ||||
| import {testId, theme} from 'app/client/ui2018/cssVars'; | ||||
| import {icon} from 'app/client/ui2018/icons'; | ||||
| import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker'; | ||||
| import {NTextBox} from 'app/client/widgets/NTextBox'; | ||||
| import {clamp} from 'app/common/gutil'; | ||||
| import {numberOrDefault} from 'app/common/gutil'; | ||||
| import {buildNumberFormat, NumberFormatOptions, NumMode, NumSign} from 'app/common/NumberFormat'; | ||||
| import {BindableValue, Computed, dom, DomContents, DomElementArg, | ||||
|         fromKo, MultiHolder, Observable, styled} from 'grainjs'; | ||||
| import {Computed, dom, DomContents, fromKo, MultiHolder, styled} from 'grainjs'; | ||||
| import * as LocaleCurrency from 'locale-currency'; | ||||
| 
 | ||||
| 
 | ||||
| const t = makeT('NumericTextBox'); | ||||
| 
 | ||||
| const modeOptions: Array<ISelectorOption<NumMode>> = [ | ||||
|   {value: 'currency', label: '$'}, | ||||
|   {value: 'decimal', label: ','}, | ||||
| @ -75,9 +77,10 @@ export class NumericTextBox extends NTextBox { | ||||
|     }; | ||||
| 
 | ||||
|     // Prepare setters for the UI elements.
 | ||||
|     // Min/max fraction digits may range from 0 to 20; other values are invalid.
 | ||||
|     const setMinDecimals = (val?: number) => setSave('decimals', val && clamp(val, 0, 20)); | ||||
|     const setMaxDecimals = (val?: number) => setSave('maxDecimals', val && clamp(val, 0, 20)); | ||||
|     // If defined, `val` will be a floating point number between 0 and 20; make sure it's
 | ||||
|     // saved as an integer.
 | ||||
|     const setMinDecimals = (val?: number) => setSave('decimals', val && Math.floor(val)); | ||||
|     const setMaxDecimals = (val?: number) => setSave('maxDecimals', val && Math.floor(val)); | ||||
|     // Mode and Sign behave as toggles: clicking a selected on deselects it.
 | ||||
|     const setMode = (val: NumMode) => setSave('numMode', val !== numMode.get() ? val : undefined); | ||||
|     const setSign = (val: NumSign) => setSave('numSign', val !== numSign.get() ? val : undefined); | ||||
| @ -105,16 +108,56 @@ export class NumericTextBox extends NTextBox { | ||||
|       ]), | ||||
|       cssLabel(t('Decimals')), | ||||
|       cssRow( | ||||
|         decimals('min', minDecimals, defaultMin, setMinDecimals, disabled, testId('numeric-min-decimals')), | ||||
|         decimals('max', maxDecimals, defaultMax, setMaxDecimals, disabled, testId('numeric-max-decimals')), | ||||
|         cssNumericSpinner( | ||||
|           minDecimals, | ||||
|           { | ||||
|             label: t('min'), | ||||
|             minValue: 0, | ||||
|             maxValue: 20, | ||||
|             defaultValue: defaultMin, | ||||
|             disabled, | ||||
|             save: setMinDecimals, | ||||
|           }, | ||||
|           testId('numeric-min-decimals'), | ||||
|         ), | ||||
|         cssNumericSpinner( | ||||
|           maxDecimals, | ||||
|           { | ||||
|             label: t('max'), | ||||
|             minValue: 0, | ||||
|             maxValue: 20, | ||||
|             defaultValue: defaultMax, | ||||
|             disabled, | ||||
|             save: setMaxDecimals, | ||||
|           }, | ||||
|           testId('numeric-max-decimals'), | ||||
|         ), | ||||
|       ), | ||||
|     ]; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| function numberOrDefault<T>(value: unknown, def: T): number | T { | ||||
|   return value !== null && value !== undefined ? Number(value) : def; | ||||
|   public buildFormConfigDom(): DomContents { | ||||
|     const format = fieldWithDefault<FormNumberFormat>( | ||||
|       this.field.widgetOptionsJson.prop('formNumberFormat'), | ||||
|       'text' | ||||
|     ); | ||||
| 
 | ||||
|     return [ | ||||
|       cssLabel(t('Field Format')), | ||||
|       cssRow( | ||||
|         buttonSelect( | ||||
|           fromKoSave(format), | ||||
|           [ | ||||
|             {value: 'text', label: t('Text')}, | ||||
|             {value: 'spinner', label: t('Spinner')}, | ||||
|           ], | ||||
|           testId('numeric-form-field-format'), | ||||
|         ), | ||||
|       ), | ||||
|       dom.create(FormFieldRulesConfig, this.field), | ||||
|     ]; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // Helper used by setSave() above to reset some properties when switching modes.
 | ||||
| @ -126,107 +169,6 @@ function updateOptions(prop: keyof NumberFormatOptions, value: unknown): Partial | ||||
|   return {}; | ||||
| } | ||||
| 
 | ||||
| function decimals( | ||||
|   label: string, | ||||
|   value: Observable<number | ''>, | ||||
|   defaultValue: Observable<number>, | ||||
|   setFunc: (val?: number) => void, | ||||
|   disabled: BindableValue<boolean>, | ||||
|   ...args: DomElementArg[] | ||||
| ) { | ||||
|   return cssDecimalsBox( | ||||
|     cssDecimalsBox.cls('-disabled', disabled), | ||||
|     cssNumLabel(label), | ||||
|     cssNumInput({type: 'text', size: '2', min: '0'}, | ||||
|       dom.prop('value', value), | ||||
|       dom.prop('placeholder', defaultValue), | ||||
|       dom.on('change', (ev, elem) => { | ||||
|         const newVal = parseInt(elem.value, 10); | ||||
|         // Set value explicitly before its updated via setFunc; this way the value reflects the
 | ||||
|         // observable in the case the observable is left unchanged (e.g. because of clamping).
 | ||||
|         elem.value = String(value.get()); | ||||
|         setFunc(Number.isNaN(newVal) ? undefined : newVal); | ||||
|         elem.blur(); | ||||
|       }), | ||||
|       dom.on('focus', (ev, elem) => elem.select()), | ||||
|     ), | ||||
|     cssSpinner( | ||||
|       cssSpinnerBtn(cssSpinnerTop('DropdownUp'), | ||||
|         dom.on('click', () => setFunc(numberOrDefault(value.get(), defaultValue.get()) + 1))), | ||||
|       cssSpinnerBtn(cssSpinnerBottom('Dropdown'), | ||||
|         dom.on('click', () => setFunc(numberOrDefault(value.get(), defaultValue.get()) - 1))), | ||||
|     ), | ||||
|     ...args | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| const cssDecimalsBox = styled('div', ` | ||||
|   position: relative; | ||||
|   flex: auto; | ||||
|   --icon-color: ${theme.lightText}; | ||||
|   color: ${theme.lightText}; | ||||
|   font-weight: normal; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   &:first-child { | ||||
|     margin-right: 16px; | ||||
|   } | ||||
|   &-disabled { | ||||
|     opacity: 0.4; | ||||
|     pointer-events: none; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssNumLabel = styled('div', ` | ||||
|   position: absolute; | ||||
|   padding-left: 8px; | ||||
|   pointer-events: none; | ||||
| `);
 | ||||
| 
 | ||||
| const cssNumInput = styled('input', ` | ||||
|   padding: 4px 32px 4px 40px; | ||||
|   border: 1px solid ${theme.inputBorder}; | ||||
|   border-radius: 3px; | ||||
|   background-color: ${theme.inputBg}; | ||||
|   color: ${theme.inputFg}; | ||||
|   width: 100%; | ||||
|   text-align: right; | ||||
|   appearance: none; | ||||
|   -moz-appearance: none; | ||||
|   -webkit-appearance: none; | ||||
| `);
 | ||||
| 
 | ||||
| const cssSpinner = styled('div', ` | ||||
|   position: absolute; | ||||
|   right: 8px; | ||||
|   width: 16px; | ||||
|   height: 100%; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
| `);
 | ||||
| 
 | ||||
| const cssSpinnerBtn = styled('div', ` | ||||
|   --icon-color: ${theme.controlSecondaryFg}; | ||||
|   flex: 1 1 0px; | ||||
|   min-height: 0px; | ||||
|   position: relative; | ||||
|   cursor: pointer; | ||||
|   overflow: hidden; | ||||
|   &:hover { | ||||
|     --icon-color: ${theme.controlSecondaryHoverFg}; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssSpinnerTop = styled(icon, ` | ||||
|   position: absolute; | ||||
|   top: 0px; | ||||
| `);
 | ||||
| 
 | ||||
| const cssSpinnerBottom = styled(icon, ` | ||||
|   position: absolute; | ||||
|   bottom: 0px; | ||||
| `);
 | ||||
| 
 | ||||
| const cssModeSelect = styled(makeButtonSelect, ` | ||||
|   flex: 4 4 0px; | ||||
|   background-color: ${theme.inputBg}; | ||||
|  | ||||
| @ -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), | ||||
|     ]; | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -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, ` | ||||
|  | ||||
| @ -1,19 +1,44 @@ | ||||
| import * as commands from 'app/client/components/commands'; | ||||
| import { FieldRulesConfig } from 'app/client/components/Forms/FormConfig'; | ||||
| import { FormFieldRulesConfig } from 'app/client/components/Forms/FormConfig'; | ||||
| import { fromKoSave } from 'app/client/lib/fromKoSave'; | ||||
| import { makeT } from 'app/client/lib/localization'; | ||||
| import { DataRowModel } from 'app/client/models/DataRowModel'; | ||||
| import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec'; | ||||
| import { KoSaveableObservable } from 'app/client/models/modelUtil'; | ||||
| import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget'; | ||||
| import { fieldWithDefault, KoSaveableObservable } from 'app/client/models/modelUtil'; | ||||
| import { FormToggleFormat } from 'app/client/ui/FormAPI'; | ||||
| import { cssLabel, cssRow } from 'app/client/ui/RightPanelStyles'; | ||||
| import { buttonSelect } from 'app/client/ui2018/buttonSelect'; | ||||
| import { theme } from 'app/client/ui2018/cssVars'; | ||||
| import { dom, DomContents } from 'grainjs'; | ||||
| import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget'; | ||||
| import { dom, DomContents, makeTestId } from 'grainjs'; | ||||
| 
 | ||||
| const t = makeT('Toggle'); | ||||
| 
 | ||||
| const testId = makeTestId('test-toggle-'); | ||||
| 
 | ||||
| /** | ||||
|  * ToggleBase - The base class for toggle widgets, such as a checkbox or a switch. | ||||
|  */ | ||||
| abstract class ToggleBase extends NewAbstractWidget { | ||||
|   public buildFormConfigDom(): DomContents { | ||||
|     const format = fieldWithDefault<FormToggleFormat>( | ||||
|       this.field.widgetOptionsJson.prop('formToggleFormat'), | ||||
|       'switch' | ||||
|     ); | ||||
| 
 | ||||
|     return [ | ||||
|       dom.create(FieldRulesConfig, this.field), | ||||
|       cssLabel(t('Field Format')), | ||||
|       cssRow( | ||||
|         buttonSelect( | ||||
|           fromKoSave(format), | ||||
|           [ | ||||
|             {value: 'switch', label: t('Switch')}, | ||||
|             {value: 'checkbox', label: t('Checkbox')}, | ||||
|           ], | ||||
|           testId('form-field-format'), | ||||
|         ), | ||||
|       ), | ||||
|       dom.create(FormFieldRulesConfig, this.field), | ||||
|     ]; | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -154,6 +154,7 @@ export const typeDefs: any = { | ||||
|     widgets: { | ||||
|       TextBox: { | ||||
|         cons: 'TextBox', | ||||
|         formCons: 'Switch', | ||||
|         editCons: 'TextEditor', | ||||
|         icon: 'FieldTextbox', | ||||
|         options: { | ||||
|  | ||||
| @ -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); | ||||
|  | ||||
| @ -175,6 +175,21 @@ export async function firstDefined<T>(...list: Array<() => Promise<T>>): Promise | ||||
|   return undefined; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Returns the number repesentation of `value`, or `defaultVal` if it cannot | ||||
|  * be represented as a valid number. | ||||
|  */ | ||||
| export function numberOrDefault<T>(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. | ||||
|  */ | ||||
|  | ||||
| @ -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 refTableId = getReferencedTableId(col.type); | ||||
|           let refColId: string; | ||||
|           if (col.visibleCol) { | ||||
|             const refCol = Tables_column.getRecord(col.visibleCol); | ||||
|             if (!refCol) { return []; } | ||||
| 
 | ||||
|           const refCol = Tables_column.getRecord(refId); | ||||
|           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)); | ||||
|  | ||||
| @ -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'); | ||||
|     }); | ||||
| 
 | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user