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 {
 | 
					class ColumnsRenderer extends FormRenderer {
 | 
				
			||||||
  public render() {
 | 
					  public render() {
 | 
				
			||||||
    return css.columns(
 | 
					    return css.columns(
 | 
				
			||||||
      {style: `--grist-columns-count: ${this.children.length || 1}`},
 | 
					      {style: `--grist-columns-count: ${this._getColumnsCount()}`},
 | 
				
			||||||
      this.children.map((child) => child.render()),
 | 
					      this.children.map((child) => child.render()),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private _getColumnsCount() {
 | 
				
			||||||
 | 
					    return this.children.length || 1;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SubmitRenderer extends FormRenderer {
 | 
					class SubmitRenderer extends FormRenderer {
 | 
				
			||||||
@ -180,22 +184,7 @@ class SubmitRenderer extends FormRenderer {
 | 
				
			|||||||
              type: 'submit',
 | 
					              type: 'submit',
 | 
				
			||||||
              value: this.context.rootLayoutNode.submitText || 'Submit',
 | 
					              value: this.context.rootLayoutNode.submitText || 'Submit',
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
            dom.on('click', () => {
 | 
					            dom.on('click', () => validateRequiredLists()),
 | 
				
			||||||
              // 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');
 | 
					 | 
				
			||||||
              });
 | 
					 | 
				
			||||||
            }),
 | 
					 | 
				
			||||||
          )
 | 
					          )
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
@ -228,7 +217,7 @@ class FieldRenderer extends FormRenderer {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public render() {
 | 
					  public render() {
 | 
				
			||||||
    return css.field(this.renderer.render());
 | 
					    return this.renderer.render();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public reset() {
 | 
					  public reset() {
 | 
				
			||||||
@ -267,41 +256,120 @@ abstract class BaseFieldRenderer extends Disposable {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TextRenderer extends BaseFieldRenderer {
 | 
					class TextRenderer extends BaseFieldRenderer {
 | 
				
			||||||
  protected type = 'text';
 | 
					  protected inputType = 'text';
 | 
				
			||||||
  private _value = Observable.create(this, '');
 | 
					
 | 
				
			||||||
 | 
					  private _format = this.field.options.formTextFormat ?? 'singleline';
 | 
				
			||||||
 | 
					  private _lineCount = String(this.field.options.formTextLineCount || 3);
 | 
				
			||||||
 | 
					  private _value = Observable.create<string>(this, '');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public input() {
 | 
					  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(),
 | 
					        name: this.name(),
 | 
				
			||||||
        required: this.field.options.formRequired,
 | 
					        required: this.field.options.formRequired,
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      dom.prop('value', this._value),
 | 
					      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)),
 | 
					      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 {
 | 
					  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 {
 | 
					class DateRenderer extends TextRenderer {
 | 
				
			||||||
  protected type = 'date';
 | 
					  protected inputType = 'date';
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class DateTimeRenderer extends TextRenderer {
 | 
					class DateTimeRenderer extends TextRenderer {
 | 
				
			||||||
  protected type = 'datetime-local';
 | 
					  protected inputType = 'datetime-local';
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const SELECT_PLACEHOLDER = 'Select...';
 | 
					export const SELECT_PLACEHOLDER = 'Select...';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ChoiceRenderer extends BaseFieldRenderer  {
 | 
					class ChoiceRenderer extends BaseFieldRenderer  {
 | 
				
			||||||
  protected value = Observable.create<string>(this, '');
 | 
					  protected value: Observable<string>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private _choices: string[];
 | 
					  private _choices: string[];
 | 
				
			||||||
  private _selectElement: HTMLElement;
 | 
					  private _selectElement: HTMLElement;
 | 
				
			||||||
  private _ctl?: PopupControl<IPopupOptions>;
 | 
					  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) {
 | 
					  public constructor(field: FormField, context: FormRendererContext) {
 | 
				
			||||||
    super(field, context);
 | 
					    super(field, context);
 | 
				
			||||||
@ -310,24 +378,59 @@ class ChoiceRenderer extends BaseFieldRenderer  {
 | 
				
			|||||||
    if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
 | 
					    if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
 | 
				
			||||||
      this._choices = [];
 | 
					      this._choices = [];
 | 
				
			||||||
    } else {
 | 
					    } 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.
 | 
					      // Support for 1000 choices. TODO: make limit dynamic.
 | 
				
			||||||
      this._choices = choices.slice(0, 1000);
 | 
					      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() {
 | 
					  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(
 | 
					    return css.hybridSelect(
 | 
				
			||||||
      this._selectElement = css.select(
 | 
					      this._selectElement = css.select(
 | 
				
			||||||
        {name: this.name(), required: this.field.options.formRequired},
 | 
					        {name: this.name(), required: this.field.options.formRequired},
 | 
				
			||||||
        dom.prop('value', this.value),
 | 
					 | 
				
			||||||
        dom.on('input', (_e, elem) => this.value.set(elem.value)),
 | 
					        dom.on('input', (_e, elem) => this.value.set(elem.value)),
 | 
				
			||||||
        dom('option', {value: ''}, SELECT_PLACEHOLDER),
 | 
					        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({
 | 
					        dom.onKeyDown({
 | 
				
			||||||
 | 
					          Enter$: (ev) => this._maybeOpenSearchSelect(ev),
 | 
				
			||||||
          ' $': (ev) => this._maybeOpenSearchSelect(ev),
 | 
					          ' $': (ev) => this._maybeOpenSearchSelect(ev),
 | 
				
			||||||
          ArrowUp$: (ev) => this._maybeOpenSearchSelect(ev),
 | 
					          ArrowUp$: (ev) => this._maybeOpenSearchSelect(ev),
 | 
				
			||||||
          ArrowDown$: (ev) => this._maybeOpenSearchSelect(ev),
 | 
					          ArrowDown$: (ev) => this._maybeOpenSearchSelect(ev),
 | 
				
			||||||
 | 
					          Backspace$: () => this.value.set(''),
 | 
				
			||||||
        }),
 | 
					        }),
 | 
				
			||||||
 | 
					        preventSubmitOnEnter(),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
      dom.maybe(use => !use(isXSmallScreenObs()), () =>
 | 
					      dom.maybe(use => !use(isXSmallScreenObs()), () =>
 | 
				
			||||||
        css.searchSelect(
 | 
					        css.searchSelect(
 | 
				
			||||||
@ -359,8 +462,29 @@ class ChoiceRenderer extends BaseFieldRenderer  {
 | 
				
			|||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public resetInput(): void {
 | 
					  private _renderRadioInput() {
 | 
				
			||||||
    this.value.set('');
 | 
					    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) {
 | 
					  private _maybeOpenSearchSelect(ev: KeyboardEvent) {
 | 
				
			||||||
@ -375,8 +499,11 @@ class ChoiceRenderer extends BaseFieldRenderer  {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class BoolRenderer extends BaseFieldRenderer {
 | 
					class BoolRenderer extends BaseFieldRenderer {
 | 
				
			||||||
 | 
					  protected inputType = 'checkbox';
 | 
				
			||||||
  protected checked = Observable.create<boolean>(this, false);
 | 
					  protected checked = Observable.create<boolean>(this, false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private _format = this.field.options.formToggleFormat ?? 'switch';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public render() {
 | 
					  public render() {
 | 
				
			||||||
    return css.field(
 | 
					    return css.field(
 | 
				
			||||||
      dom('div', this.input()),
 | 
					      dom('div', this.input()),
 | 
				
			||||||
@ -384,16 +511,29 @@ class BoolRenderer extends BaseFieldRenderer {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public input() {
 | 
					  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('input',
 | 
				
			||||||
        dom.prop('checked', this.checked),
 | 
					        dom.prop('checked', this.checked),
 | 
				
			||||||
 | 
					        dom.prop('value', use => use(this.checked) ? '1' : '0'),
 | 
				
			||||||
        dom.on('change', (_e, elem) => this.checked.set(elem.checked)),
 | 
					        dom.on('change', (_e, elem) => this.checked.set(elem.checked)),
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
          type: 'checkbox',
 | 
					          type: this.inputType,
 | 
				
			||||||
          name: this.name(),
 | 
					          name: this.name(),
 | 
				
			||||||
          value: '1',
 | 
					 | 
				
			||||||
          required: this.field.options.formRequired,
 | 
					          required: this.field.options.formRequired,
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
					        preventSubmitOnEnter(),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
      css.gristSwitch(
 | 
					      css.gristSwitch(
 | 
				
			||||||
        css.gristSwitchSlider(),
 | 
					        css.gristSwitchSlider(),
 | 
				
			||||||
@ -406,8 +546,24 @@ class BoolRenderer extends BaseFieldRenderer {
 | 
				
			|||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public resetInput(): void {
 | 
					  private _renderCheckboxInput() {
 | 
				
			||||||
    this.checked.set(false);
 | 
					    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>
 | 
					    checked: Observable<string|null>
 | 
				
			||||||
  }> = this.autoDispose(obsArray());
 | 
					  }> = this.autoDispose(obsArray());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private _alignment = this.field.options.formOptionsAlignment ?? 'vertical';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public constructor(field: FormField, context: FormRendererContext) {
 | 
					  public constructor(field: FormField, context: FormRendererContext) {
 | 
				
			||||||
    super(field, context);
 | 
					    super(field, context);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -424,6 +582,13 @@ class ChoiceListRenderer extends BaseFieldRenderer  {
 | 
				
			|||||||
    if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
 | 
					    if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
 | 
				
			||||||
      choices = [];
 | 
					      choices = [];
 | 
				
			||||||
    } else {
 | 
					    } 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.
 | 
					      // Support for 30 choices. TODO: make limit dynamic.
 | 
				
			||||||
      choices = choices.slice(0, 30);
 | 
					      choices = choices.slice(0, 30);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -437,6 +602,7 @@ class ChoiceListRenderer extends BaseFieldRenderer  {
 | 
				
			|||||||
  public input() {
 | 
					  public input() {
 | 
				
			||||||
    const required = this.field.options.formRequired;
 | 
					    const required = this.field.options.formRequired;
 | 
				
			||||||
    return css.checkboxList(
 | 
					    return css.checkboxList(
 | 
				
			||||||
 | 
					      css.checkboxList.cls('-horizontal', this._alignment === 'horizontal'),
 | 
				
			||||||
      dom.cls('grist-checkbox-list'),
 | 
					      dom.cls('grist-checkbox-list'),
 | 
				
			||||||
      dom.cls('required', Boolean(required)),
 | 
					      dom.cls('required', Boolean(required)),
 | 
				
			||||||
      {name: this.name(), required},
 | 
					      {name: this.name(), required},
 | 
				
			||||||
@ -449,7 +615,8 @@ class ChoiceListRenderer extends BaseFieldRenderer  {
 | 
				
			|||||||
              type: 'checkbox',
 | 
					              type: 'checkbox',
 | 
				
			||||||
              name: `${this.name()}[]`,
 | 
					              name: `${this.name()}[]`,
 | 
				
			||||||
              value: checkbox.label,
 | 
					              value: checkbox.label,
 | 
				
			||||||
            }
 | 
					            },
 | 
				
			||||||
 | 
					            preventSubmitOnEnter(),
 | 
				
			||||||
          ),
 | 
					          ),
 | 
				
			||||||
          dom('span', checkbox.label),
 | 
					          dom('span', checkbox.label),
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
@ -471,12 +638,20 @@ class RefListRenderer extends BaseFieldRenderer {
 | 
				
			|||||||
    checked: Observable<string|null>
 | 
					    checked: Observable<string|null>
 | 
				
			||||||
  }> = this.autoDispose(obsArray());
 | 
					  }> = this.autoDispose(obsArray());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private _alignment = this.field.options.formOptionsAlignment ?? 'vertical';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public constructor(field: FormField, context: FormRendererContext) {
 | 
					  public constructor(field: FormField, context: FormRendererContext) {
 | 
				
			||||||
    super(field, context);
 | 
					    super(field, context);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const references = this.field.refValues ?? [];
 | 
					    const references = this.field.refValues ?? [];
 | 
				
			||||||
 | 
					    const sortOrder = this.field.options.formOptionsSortOrder;
 | 
				
			||||||
 | 
					    if (sortOrder !== 'default') {
 | 
				
			||||||
      // Sort by the second value, which is the display value.
 | 
					      // Sort by the second value, which is the display value.
 | 
				
			||||||
      references.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
 | 
					      references.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
 | 
				
			||||||
 | 
					      if (sortOrder === 'descending') {
 | 
				
			||||||
 | 
					        references.reverse();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
    // Support for 30 choices. TODO: make limit dynamic.
 | 
					    // Support for 30 choices. TODO: make limit dynamic.
 | 
				
			||||||
    references.splice(30);
 | 
					    references.splice(30);
 | 
				
			||||||
    this.checkboxes.set(references.map(reference => ({
 | 
					    this.checkboxes.set(references.map(reference => ({
 | 
				
			||||||
@ -488,6 +663,7 @@ class RefListRenderer extends BaseFieldRenderer {
 | 
				
			|||||||
  public input() {
 | 
					  public input() {
 | 
				
			||||||
    const required = this.field.options.formRequired;
 | 
					    const required = this.field.options.formRequired;
 | 
				
			||||||
    return css.checkboxList(
 | 
					    return css.checkboxList(
 | 
				
			||||||
 | 
					      css.checkboxList.cls('-horizontal', this._alignment === 'horizontal'),
 | 
				
			||||||
      dom.cls('grist-checkbox-list'),
 | 
					      dom.cls('grist-checkbox-list'),
 | 
				
			||||||
      dom.cls('required', Boolean(required)),
 | 
					      dom.cls('required', Boolean(required)),
 | 
				
			||||||
      {name: this.name(), required},
 | 
					      {name: this.name(), required},
 | 
				
			||||||
@ -501,7 +677,8 @@ class RefListRenderer extends BaseFieldRenderer {
 | 
				
			|||||||
              'data-grist-type': this.field.type,
 | 
					              'data-grist-type': this.field.type,
 | 
				
			||||||
              name: `${this.name()}[]`,
 | 
					              name: `${this.name()}[]`,
 | 
				
			||||||
              value: checkbox.value,
 | 
					              value: checkbox.value,
 | 
				
			||||||
            }
 | 
					            },
 | 
				
			||||||
 | 
					            preventSubmitOnEnter(),
 | 
				
			||||||
          ),
 | 
					          ),
 | 
				
			||||||
          dom('span', checkbox.label),
 | 
					          dom('span', checkbox.label),
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
@ -518,15 +695,58 @@ class RefListRenderer extends BaseFieldRenderer {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
class RefRenderer extends BaseFieldRenderer {
 | 
					class RefRenderer extends BaseFieldRenderer {
 | 
				
			||||||
  protected value = Observable.create(this, '');
 | 
					  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 _selectElement: HTMLElement;
 | 
				
			||||||
  private _ctl?: PopupControl<IPopupOptions>;
 | 
					  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);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public input() {
 | 
					 | 
				
			||||||
    const choices: [number|string, CellValue][] = this.field.refValues ?? [];
 | 
					    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.
 | 
					      // Sort by the second value, which is the display value.
 | 
				
			||||||
      choices.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
 | 
					      choices.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
 | 
				
			||||||
 | 
					      if (sortOrder === 'descending') {
 | 
				
			||||||
 | 
					        choices.reverse();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
    // Support for 1000 choices. TODO: make limit dynamic.
 | 
					    // Support for 1000 choices. TODO: make limit dynamic.
 | 
				
			||||||
    choices.splice(1000);
 | 
					    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() {
 | 
				
			||||||
 | 
					    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(
 | 
					    return css.hybridSelect(
 | 
				
			||||||
      this._selectElement = css.select(
 | 
					      this._selectElement = css.select(
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
@ -534,27 +754,37 @@ class RefRenderer extends BaseFieldRenderer {
 | 
				
			|||||||
          'data-grist-type': this.field.type,
 | 
					          'data-grist-type': this.field.type,
 | 
				
			||||||
          required: this.field.options.formRequired,
 | 
					          required: this.field.options.formRequired,
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        dom.prop('value', this.value),
 | 
					 | 
				
			||||||
        dom.on('input', (_e, elem) => this.value.set(elem.value)),
 | 
					        dom.on('input', (_e, elem) => this.value.set(elem.value)),
 | 
				
			||||||
        dom('option', {value: ''}, SELECT_PLACEHOLDER),
 | 
					        dom('option',
 | 
				
			||||||
        choices.map((choice) => dom('option', {value: String(choice[0])}, String(choice[1]))),
 | 
					          {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({
 | 
					        dom.onKeyDown({
 | 
				
			||||||
 | 
					          Enter$: (ev) => this._maybeOpenSearchSelect(ev),
 | 
				
			||||||
          ' $': (ev) => this._maybeOpenSearchSelect(ev),
 | 
					          ' $': (ev) => this._maybeOpenSearchSelect(ev),
 | 
				
			||||||
          ArrowUp$: (ev) => this._maybeOpenSearchSelect(ev),
 | 
					          ArrowUp$: (ev) => this._maybeOpenSearchSelect(ev),
 | 
				
			||||||
          ArrowDown$: (ev) => this._maybeOpenSearchSelect(ev),
 | 
					          ArrowDown$: (ev) => this._maybeOpenSearchSelect(ev),
 | 
				
			||||||
 | 
					          Backspace$: () => this.value.set(''),
 | 
				
			||||||
        }),
 | 
					        }),
 | 
				
			||||||
 | 
					        preventSubmitOnEnter(),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
      dom.maybe(use => !use(isXSmallScreenObs()), () =>
 | 
					      dom.maybe(use => !use(isXSmallScreenObs()), () =>
 | 
				
			||||||
        css.searchSelect(
 | 
					        css.searchSelect(
 | 
				
			||||||
          dom('div', dom.text(use => {
 | 
					          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);
 | 
					            return String(choice?.[1] || SELECT_PLACEHOLDER);
 | 
				
			||||||
          })),
 | 
					          })),
 | 
				
			||||||
          dropdownWithSearch<string>({
 | 
					          dropdownWithSearch<string>({
 | 
				
			||||||
            action: (value) => this.value.set(value),
 | 
					            action: (value) => this.value.set(value),
 | 
				
			||||||
            options: () => [
 | 
					            options: () => [
 | 
				
			||||||
              {label: SELECT_PLACEHOLDER, value: '', placeholder: true},
 | 
					              {label: SELECT_PLACEHOLDER, value: '', placeholder: true},
 | 
				
			||||||
              ...choices.map((choice) => ({
 | 
					              ...this._choices.map((choice) => ({
 | 
				
			||||||
                label: String(choice[1]),
 | 
					                label: String(choice[1]),
 | 
				
			||||||
                value: String(choice[0]),
 | 
					                value: String(choice[0]),
 | 
				
			||||||
              }),
 | 
					              }),
 | 
				
			||||||
@ -577,8 +807,29 @@ class RefRenderer extends BaseFieldRenderer {
 | 
				
			|||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public resetInput(): void {
 | 
					  private _renderRadioInput() {
 | 
				
			||||||
    this.value.set('');
 | 
					    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) {
 | 
					  private _maybeOpenSearchSelect(ev: KeyboardEvent) {
 | 
				
			||||||
@ -594,6 +845,8 @@ class RefRenderer extends BaseFieldRenderer {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const FieldRenderers = {
 | 
					const FieldRenderers = {
 | 
				
			||||||
  'Text': TextRenderer,
 | 
					  'Text': TextRenderer,
 | 
				
			||||||
 | 
					  'Numeric': NumericRenderer,
 | 
				
			||||||
 | 
					  'Int': NumericRenderer,
 | 
				
			||||||
  'Choice': ChoiceRenderer,
 | 
					  'Choice': ChoiceRenderer,
 | 
				
			||||||
  'Bool': BoolRenderer,
 | 
					  'Bool': BoolRenderer,
 | 
				
			||||||
  'ChoiceList': ChoiceListRenderer,
 | 
					  'ChoiceList': ChoiceListRenderer,
 | 
				
			||||||
@ -616,3 +869,36 @@ const FormRenderers = {
 | 
				
			|||||||
  'Separator': ParagraphRenderer,
 | 
					  'Separator': ParagraphRenderer,
 | 
				
			||||||
  'Header': 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 {colors, mediaXSmall, vars} from 'app/client/ui2018/cssVars';
 | 
				
			||||||
import {icon} from 'app/client/ui2018/icons';
 | 
					import {icon} from 'app/client/ui2018/icons';
 | 
				
			||||||
 | 
					import {numericSpinner} from 'app/client/widgets/NumericSpinner';
 | 
				
			||||||
import {styled} from 'grainjs';
 | 
					import {styled} from 'grainjs';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const label = styled('div', `
 | 
					export const label = styled('div', `
 | 
				
			||||||
@ -26,20 +27,23 @@ export const section = styled('div', `
 | 
				
			|||||||
  border-radius: 3px;
 | 
					  border-radius: 3px;
 | 
				
			||||||
  border: 1px solid ${colors.darkGrey};
 | 
					  border: 1px solid ${colors.darkGrey};
 | 
				
			||||||
  padding: 24px;
 | 
					  padding: 24px;
 | 
				
			||||||
  margin-top: 24px;
 | 
					  margin-top: 12px;
 | 
				
			||||||
 | 
					  margin-bottom: 24px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  & > div + div {
 | 
					  & > div + div {
 | 
				
			||||||
    margin-top: 16px;
 | 
					    margin-top: 8px;
 | 
				
			||||||
 | 
					    margin-bottom: 12px;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const columns = styled('div', `
 | 
					export const columns = styled('div', `
 | 
				
			||||||
  display: grid;
 | 
					  display: grid;
 | 
				
			||||||
  grid-template-columns: repeat(var(--grist-columns-count), 1fr);
 | 
					  grid-template-columns: repeat(var(--grist-columns-count), 1fr);
 | 
				
			||||||
  gap: 4px;
 | 
					  gap: 16px;
 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const submitButtons = styled('div', `
 | 
					export const submitButtons = styled('div', `
 | 
				
			||||||
 | 
					  margin-top: 16px;
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  justify-content: center;
 | 
					  justify-content: center;
 | 
				
			||||||
  column-gap: 8px;
 | 
					  column-gap: 8px;
 | 
				
			||||||
@ -100,32 +104,13 @@ export const submitButton = styled('div', `
 | 
				
			|||||||
export const field = styled('div', `
 | 
					export const field = styled('div', `
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  flex-direction: column;
 | 
					  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"] {
 | 
					  & input[type="checkbox"] {
 | 
				
			||||||
    -webkit-appearance: none;
 | 
					    -webkit-appearance: none;
 | 
				
			||||||
    -moz-appearance: none;
 | 
					    -moz-appearance: none;
 | 
				
			||||||
 | 
					    margin: 0;
 | 
				
			||||||
    padding: 0;
 | 
					    padding: 0;
 | 
				
			||||||
    flex-shrink: 0;
 | 
					    flex-shrink: 0;
 | 
				
			||||||
    display: inline-block;
 | 
					    display: inline-block;
 | 
				
			||||||
@ -195,19 +180,80 @@ export const field = styled('div', `
 | 
				
			|||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const error = styled('div', `
 | 
					export const error = styled('div', `
 | 
				
			||||||
 | 
					  margin-top: 16px;
 | 
				
			||||||
  text-align: center;
 | 
					  text-align: center;
 | 
				
			||||||
  color: ${colors.error};
 | 
					  color: ${colors.error};
 | 
				
			||||||
  min-height: 22px;
 | 
					  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', `
 | 
					export const toggle = styled('label', `
 | 
				
			||||||
  position: relative;
 | 
					  position: relative;
 | 
				
			||||||
  cursor: pointer;
 | 
					 | 
				
			||||||
  display: inline-flex;
 | 
					  display: inline-flex;
 | 
				
			||||||
  align-items: center;
 | 
					  margin-top: 8px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &:hover {
 | 
				
			||||||
 | 
					    --color: ${colors.hover};
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const toggleSwitch = styled(toggle, `
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  & input[type='checkbox'] {
 | 
					  & input[type='checkbox'] {
 | 
				
			||||||
 | 
					    margin: 0;
 | 
				
			||||||
    position: absolute;
 | 
					    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 {
 | 
					  & input[type='checkbox']:focus {
 | 
				
			||||||
    outline: none;
 | 
					    outline: none;
 | 
				
			||||||
@ -220,6 +266,8 @@ export const toggle = styled('label', `
 | 
				
			|||||||
export const toggleLabel = styled('span', `
 | 
					export const toggleLabel = styled('span', `
 | 
				
			||||||
  font-size: 13px;
 | 
					  font-size: 13px;
 | 
				
			||||||
  font-weight: 700;
 | 
					  font-weight: 700;
 | 
				
			||||||
 | 
					  line-height: 16px;
 | 
				
			||||||
 | 
					  overflow-wrap: anywhere;
 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const gristSwitchSlider = styled('div', `
 | 
					export const gristSwitchSlider = styled('div', `
 | 
				
			||||||
@ -233,10 +281,6 @@ export const gristSwitchSlider = styled('div', `
 | 
				
			|||||||
  border-radius: 17px;
 | 
					  border-radius: 17px;
 | 
				
			||||||
  -webkit-transition: background-color .4s;
 | 
					  -webkit-transition: background-color .4s;
 | 
				
			||||||
  transition: background-color .4s;
 | 
					  transition: background-color .4s;
 | 
				
			||||||
 | 
					 | 
				
			||||||
  &:hover {
 | 
					 | 
				
			||||||
    box-shadow: 0 0 1px #2196F3;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const gristSwitchCircle = styled('div', `
 | 
					export const gristSwitchCircle = styled('div', `
 | 
				
			||||||
@ -277,19 +321,67 @@ export const gristSwitch = styled('div', `
 | 
				
			|||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const checkboxList = styled('div', `
 | 
					export const checkboxList = styled('div', `
 | 
				
			||||||
  display: flex;
 | 
					  display: inline-flex;
 | 
				
			||||||
  flex-direction: column;
 | 
					  flex-direction: column;
 | 
				
			||||||
  gap: 4px;
 | 
					  gap: 8px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &-horizontal {
 | 
				
			||||||
 | 
					    flex-direction: row;
 | 
				
			||||||
 | 
					    flex-wrap: wrap;
 | 
				
			||||||
 | 
					    column-gap: 16px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const checkbox = styled('label', `
 | 
					export const checkbox = styled('label', `
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  font-size: 13px;
 | 
				
			||||||
 | 
					  line-height: 16px;
 | 
				
			||||||
 | 
					  gap: 8px;
 | 
				
			||||||
 | 
					  overflow-wrap: anywhere;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  & input {
 | 
				
			||||||
 | 
					    margin: 0px !important;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
  &:hover {
 | 
					  &:hover {
 | 
				
			||||||
    --color: ${colors.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', `
 | 
					export const hybridSelect = styled('div', `
 | 
				
			||||||
  position: relative;
 | 
					  position: relative;
 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
@ -303,7 +395,7 @@ export const select = styled('select', `
 | 
				
			|||||||
  outline: none;
 | 
					  outline: none;
 | 
				
			||||||
  background: white;
 | 
					  background: white;
 | 
				
			||||||
  line-height: inherit;
 | 
					  line-height: inherit;
 | 
				
			||||||
  height: 27px;
 | 
					  height: 29px;
 | 
				
			||||||
  flex: auto;
 | 
					  flex: auto;
 | 
				
			||||||
  width: 100%;
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -323,11 +415,11 @@ export const searchSelect = styled('div', `
 | 
				
			|||||||
  position: relative;
 | 
					  position: relative;
 | 
				
			||||||
  padding: 4px 8px;
 | 
					  padding: 4px 8px;
 | 
				
			||||||
  border-radius: 3px;
 | 
					  border-radius: 3px;
 | 
				
			||||||
  border: 1px solid ${colors.darkGrey};
 | 
					  outline: 1px solid ${colors.darkGrey};
 | 
				
			||||||
  font-size: 13px;
 | 
					  font-size: 13px;
 | 
				
			||||||
  background: white;
 | 
					  background: white;
 | 
				
			||||||
  line-height: inherit;
 | 
					  line-height: inherit;
 | 
				
			||||||
  height: 27px;
 | 
					  height: 29px;
 | 
				
			||||||
  flex: auto;
 | 
					  flex: auto;
 | 
				
			||||||
  width: 100%;
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -5,6 +5,7 @@ import {buildMenu} from 'app/client/components/Forms/Menu';
 | 
				
			|||||||
import {BoxModel} from 'app/client/components/Forms/Model';
 | 
					import {BoxModel} from 'app/client/components/Forms/Model';
 | 
				
			||||||
import * as style from 'app/client/components/Forms/styles';
 | 
					import * as style from 'app/client/components/Forms/styles';
 | 
				
			||||||
import {makeTestId} from 'app/client/lib/domUtils';
 | 
					import {makeTestId} from 'app/client/lib/domUtils';
 | 
				
			||||||
 | 
					import {makeT} from 'app/client/lib/localization';
 | 
				
			||||||
import {icon} from 'app/client/ui2018/icons';
 | 
					import {icon} from 'app/client/ui2018/icons';
 | 
				
			||||||
import * as menus from 'app/client/ui2018/menus';
 | 
					import * as menus from 'app/client/ui2018/menus';
 | 
				
			||||||
import {inlineStyle, not} from 'app/common/gutil';
 | 
					import {inlineStyle, not} from 'app/common/gutil';
 | 
				
			||||||
@ -13,6 +14,8 @@ import {v4 as uuidv4} from 'uuid';
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const testId = makeTestId('test-forms-');
 | 
					const testId = makeTestId('test-forms-');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const t = makeT('FormView');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class ColumnsModel extends BoxModel {
 | 
					export class ColumnsModel extends BoxModel {
 | 
				
			||||||
  private _columnCount = Computed.create(this, use => use(this.children).length);
 | 
					  private _columnCount = Computed.create(this, use => use(this.children).length);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -64,7 +67,11 @@ export class ColumnsModel extends BoxModel {
 | 
				
			|||||||
      cssPlaceholder(
 | 
					      cssPlaceholder(
 | 
				
			||||||
        testId('add'),
 | 
					        testId('add'),
 | 
				
			||||||
        icon('Plus'),
 | 
					        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('-add-button'),
 | 
				
			||||||
        style.cssColumn.cls('-drag-over', dragHover),
 | 
					        style.cssColumn.cls('-drag-over', dragHover),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -152,7 +159,7 @@ export class PlaceholderModel extends BoxModel {
 | 
				
			|||||||
      buildMenu({
 | 
					      buildMenu({
 | 
				
			||||||
        box: this,
 | 
					        box: this,
 | 
				
			||||||
        insertBox,
 | 
					        insertBox,
 | 
				
			||||||
        customItems: [menus.menuItem(removeColumn, menus.menuIcon('Remove'), 'Remove Column')],
 | 
					        customItems: [menus.menuItem(removeColumn, menus.menuIcon('Remove'), t('Remove Column'))],
 | 
				
			||||||
      }),
 | 
					      }),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      dom.on('contextmenu', (ev) => {
 | 
					      dom.on('contextmenu', (ev) => {
 | 
				
			||||||
@ -219,8 +226,8 @@ export class PlaceholderModel extends BoxModel {
 | 
				
			|||||||
      return box.parent.replace(box, childBox);
 | 
					      return box.parent.replace(box, childBox);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    function removeColumn() {
 | 
					    async function removeColumn() {
 | 
				
			||||||
      box.removeSelf();
 | 
					      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 {BoxModel, ignoreClick} from 'app/client/components/Forms/Model';
 | 
				
			||||||
import * as css from 'app/client/components/Forms/styles';
 | 
					import * as css from 'app/client/components/Forms/styles';
 | 
				
			||||||
import {stopEvent} from 'app/client/lib/domUtils';
 | 
					import {stopEvent} from 'app/client/lib/domUtils';
 | 
				
			||||||
 | 
					import {makeT} from 'app/client/lib/localization';
 | 
				
			||||||
import {refRecord} from 'app/client/models/DocModel';
 | 
					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 {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 {colors} from 'app/client/ui2018/cssVars';
 | 
				
			||||||
 | 
					import {cssRadioInput} from 'app/client/ui2018/radio';
 | 
				
			||||||
import {isBlankValue} from 'app/common/gristTypes';
 | 
					import {isBlankValue} from 'app/common/gristTypes';
 | 
				
			||||||
import {Constructor, not} from 'app/common/gutil';
 | 
					import {Constructor, not} from 'app/common/gutil';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
@ -22,13 +32,14 @@ import {
 | 
				
			|||||||
  MultiHolder,
 | 
					  MultiHolder,
 | 
				
			||||||
  observable,
 | 
					  observable,
 | 
				
			||||||
  Observable,
 | 
					  Observable,
 | 
				
			||||||
  styled,
 | 
					  toKo,
 | 
				
			||||||
  toKo
 | 
					 | 
				
			||||||
} from 'grainjs';
 | 
					} from 'grainjs';
 | 
				
			||||||
import * as ko from 'knockout';
 | 
					import * as ko from 'knockout';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const testId = makeTestId('test-forms-');
 | 
					const testId = makeTestId('test-forms-');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const t = makeT('FormView');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Container class for all fields.
 | 
					 * Container class for all fields.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
@ -86,9 +97,6 @@ export class FieldModel extends BoxModel {
 | 
				
			|||||||
      const field = use(this.field);
 | 
					      const field = use(this.field);
 | 
				
			||||||
      return Boolean(use(field.widgetOptionsJson.prop('formRequired')));
 | 
					      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.question.onWrite(value => {
 | 
				
			||||||
      this.field.peek().question.setAndSave(value).catch(reportError);
 | 
					      this.field.peek().question.setAndSave(value).catch(reportError);
 | 
				
			||||||
@ -152,6 +160,8 @@ export class FieldModel extends BoxModel {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export abstract class Question extends Disposable {
 | 
					export abstract class Question extends Disposable {
 | 
				
			||||||
 | 
					  protected field = this.model.field;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  constructor(public model: FieldModel) {
 | 
					  constructor(public model: FieldModel) {
 | 
				
			||||||
    super();
 | 
					    super();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@ -164,7 +174,7 @@ export abstract class Question extends Disposable {
 | 
				
			|||||||
    return css.cssQuestion(
 | 
					    return css.cssQuestion(
 | 
				
			||||||
      testId('question'),
 | 
					      testId('question'),
 | 
				
			||||||
      testType(this.model.colType),
 | 
					      testType(this.model.colType),
 | 
				
			||||||
      this.renderLabel(props, dom.style('margin-bottom', '5px')),
 | 
					      this.renderLabel(props),
 | 
				
			||||||
      this.renderInput(),
 | 
					      this.renderInput(),
 | 
				
			||||||
      css.cssQuestion.cls('-required', this.model.required),
 | 
					      css.cssQuestion.cls('-required', this.model.required),
 | 
				
			||||||
      ...args
 | 
					      ...args
 | 
				
			||||||
@ -223,7 +233,7 @@ export abstract class Question extends Disposable {
 | 
				
			|||||||
      css.cssRequiredWrapper(
 | 
					      css.cssRequiredWrapper(
 | 
				
			||||||
        testId('label'),
 | 
					        testId('label'),
 | 
				
			||||||
        // When in edit - hide * and change display from grid to display
 | 
					        // 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, () => [
 | 
					        dom.maybe(props.edit, () => [
 | 
				
			||||||
          element = css.cssEditableLabel(
 | 
					          element = css.cssEditableLabel(
 | 
				
			||||||
            controller,
 | 
					            controller,
 | 
				
			||||||
@ -264,36 +274,156 @@ export abstract class Question extends Disposable {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TextModel extends Question {
 | 
					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() {
 | 
					  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(
 | 
					    return css.cssInput(
 | 
				
			||||||
      dom.prop('name', u => u(u(this.model.field).colId)),
 | 
					      dom.prop('name', u => u(u(this.field).colId)),
 | 
				
			||||||
      {disabled: true},
 | 
					 | 
				
			||||||
      {type: 'text', tabIndex: "-1"},
 | 
					      {type: 'text', tabIndex: "-1"},
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private _renderMultiLineInput() {
 | 
				
			||||||
 | 
					    return css.cssTextArea(
 | 
				
			||||||
 | 
					      dom.prop('name', u => u(u(this.field).colId)),
 | 
				
			||||||
 | 
					      dom.prop('rows', this._rowCount),
 | 
				
			||||||
 | 
					      {tabIndex: "-1"},
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NumericModel extends Question {
 | 
				
			||||||
 | 
					  private _format = Computed.create<FormNumberFormat>(this, (use) => {
 | 
				
			||||||
 | 
					    const field = use(this.field);
 | 
				
			||||||
 | 
					    return use(field.widgetOptionsJson.prop('formNumberFormat')) ?? 'text';
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public renderInput() {
 | 
				
			||||||
 | 
					    return dom.domComputed(this._format, (format) => {
 | 
				
			||||||
 | 
					      switch (format) {
 | 
				
			||||||
 | 
					        case 'text': {
 | 
				
			||||||
 | 
					          return this._renderTextInput();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        case 'spinner': {
 | 
				
			||||||
 | 
					          return this._renderSpinnerInput();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private _renderTextInput() {
 | 
				
			||||||
 | 
					    return css.cssInput(
 | 
				
			||||||
 | 
					      dom.prop('name', u => u(u(this.field).colId)),
 | 
				
			||||||
 | 
					      {type: 'text', tabIndex: "-1"},
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private _renderSpinnerInput() {
 | 
				
			||||||
 | 
					    return css.cssSpinner(observable(''), {});
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ChoiceModel extends Question {
 | 
					class ChoiceModel extends Question {
 | 
				
			||||||
  protected choices: Computed<string[]> = Computed.create(this, use => {
 | 
					  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.
 | 
					      // Read choices from field.
 | 
				
			||||||
    const choices = use(use(this.model.field).widgetOptionsJson.prop('choices'));
 | 
					      const field = use(this.field);
 | 
				
			||||||
 | 
					      const choices = use(field.widgetOptionsJson.prop('choices'))?.slice() ?? [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Make sure it is an array of strings.
 | 
					      // Make sure it is an array of strings.
 | 
				
			||||||
      if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
 | 
					      if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
 | 
				
			||||||
        return [];
 | 
					        return [];
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
 | 
					        const sort = use(this._sortOrder);
 | 
				
			||||||
 | 
					        if (sort !== 'default') {
 | 
				
			||||||
 | 
					          choices.sort((a, b) => a.localeCompare(b));
 | 
				
			||||||
 | 
					          if (sort === 'descending') {
 | 
				
			||||||
 | 
					            choices.reverse();
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
        return choices;
 | 
					        return choices;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public renderInput(): HTMLElement {
 | 
					  public renderInput() {
 | 
				
			||||||
    const field = this.model.field;
 | 
					    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(
 | 
					    return css.cssSelect(
 | 
				
			||||||
      {tabIndex: "-1"},
 | 
					      {tabIndex: "-1"},
 | 
				
			||||||
      ignoreClick,
 | 
					      ignoreClick,
 | 
				
			||||||
      dom.prop('name', use => use(use(field).colId)),
 | 
					      dom.prop('name', use => use(use(this.field).colId)),
 | 
				
			||||||
      dom('option', SELECT_PLACEHOLDER, {value: ''}),
 | 
					      dom('option',
 | 
				
			||||||
      dom.forEach(this.choices, (choice) => dom('option', choice, {value: choice})),
 | 
					        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() {
 | 
					  public renderInput() {
 | 
				
			||||||
    const field = this.model.field;
 | 
					    const field = this.field;
 | 
				
			||||||
    return dom('div',
 | 
					    return css.cssCheckboxList(
 | 
				
			||||||
 | 
					      css.cssCheckboxList.cls('-horizontal', use => use(this.alignment) === 'horizontal'),
 | 
				
			||||||
      dom.prop('name', use => use(use(field).colId)),
 | 
					      dom.prop('name', use => use(use(field).colId)),
 | 
				
			||||||
      dom.forEach(this._choices, (choice) => css.cssCheckboxLabel(
 | 
					      dom.forEach(this._choices, (choice) => css.cssCheckboxLabel(
 | 
				
			||||||
        squareCheckbox(observable(false)),
 | 
					        css.cssCheckboxLabel.cls('-horizontal', use => use(this.alignment) === 'horizontal'),
 | 
				
			||||||
        choice
 | 
					        cssCheckboxSquare({type: 'checkbox'}),
 | 
				
			||||||
 | 
					        choice,
 | 
				
			||||||
      )),
 | 
					      )),
 | 
				
			||||||
      dom.maybe(use => use(this._choices).length === 0, () => [
 | 
					      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 {
 | 
					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: {
 | 
					  public override buildDom(props: {
 | 
				
			||||||
    edit: Observable<boolean>,
 | 
					    edit: Observable<boolean>,
 | 
				
			||||||
    overlay: Observable<boolean>,
 | 
					    overlay: Observable<boolean>,
 | 
				
			||||||
@ -329,22 +466,37 @@ class BoolModel extends Question {
 | 
				
			|||||||
    return css.cssQuestion(
 | 
					    return css.cssQuestion(
 | 
				
			||||||
      testId('question'),
 | 
					      testId('question'),
 | 
				
			||||||
      testType(this.model.colType),
 | 
					      testType(this.model.colType),
 | 
				
			||||||
      cssToggle(
 | 
					      css.cssToggle(
 | 
				
			||||||
        this.renderInput(),
 | 
					        this.renderInput(),
 | 
				
			||||||
        this.renderLabel(props, css.cssLabelInline.cls('')),
 | 
					        this.renderLabel(props, css.cssLabelInline.cls('')),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public override renderInput() {
 | 
					  public override renderInput() {
 | 
				
			||||||
    const value = Observable.create(this, true);
 | 
					    return dom.domComputed(this._format, (format) => {
 | 
				
			||||||
    return dom('div.widget_switch',
 | 
					      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.style('--grist-actual-cell-color', colors.lightGreen.toString()),
 | 
				
			||||||
      dom.cls('switch_on', value),
 | 
					      dom.cls('switch_transition'),
 | 
				
			||||||
      dom.cls('switch_transition', true),
 | 
					 | 
				
			||||||
      dom('div.switch_slider'),
 | 
					      dom('div.switch_slider'),
 | 
				
			||||||
      dom('div.switch_circle'),
 | 
					      dom('div.switch_circle'),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private _renderCheckboxInput() {
 | 
				
			||||||
 | 
					    return cssLabel(
 | 
				
			||||||
 | 
					      cssCheckboxSquare({type: 'checkbox'}),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class DateModel extends Question {
 | 
					class DateModel extends Question {
 | 
				
			||||||
@ -352,8 +504,8 @@ class DateModel extends Question {
 | 
				
			|||||||
    return dom('div',
 | 
					    return dom('div',
 | 
				
			||||||
      css.cssInput(
 | 
					      css.cssInput(
 | 
				
			||||||
        dom.prop('name', this.model.colId),
 | 
					        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',
 | 
					    return dom('div',
 | 
				
			||||||
      css.cssInput(
 | 
					      css.cssInput(
 | 
				
			||||||
        dom.prop('name', this.model.colId),
 | 
					        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%'),
 | 
					      dom.style('width', '100%'),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
@ -371,19 +523,38 @@ class DateTimeModel extends Question {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class RefListModel 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() {
 | 
					  public renderInput() {
 | 
				
			||||||
    return dom('div',
 | 
					    return css.cssCheckboxList(
 | 
				
			||||||
 | 
					      css.cssCheckboxList.cls('-horizontal', use => use(this.alignment) === 'horizontal'),
 | 
				
			||||||
      dom.prop('name', this.model.colId),
 | 
					      dom.prop('name', this.model.colId),
 | 
				
			||||||
      dom.forEach(this.options, (option) => css.cssCheckboxLabel(
 | 
					      dom.forEach(this.options, (option) => css.cssCheckboxLabel(
 | 
				
			||||||
        squareCheckbox(observable(false)),
 | 
					        squareCheckbox(observable(false)),
 | 
				
			||||||
        option.label,
 | 
					        option.label,
 | 
				
			||||||
      )),
 | 
					      )),
 | 
				
			||||||
      dom.maybe(use => use(this.options).length === 0, () => [
 | 
					      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() {
 | 
					  private _getOptions() {
 | 
				
			||||||
@ -394,39 +565,83 @@ class RefListModel extends Question {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    const colId = Computed.create(this, use => {
 | 
					    const colId = Computed.create(this, use => {
 | 
				
			||||||
      const dispColumnIdObs = use(use(this.model.column).visibleColModel);
 | 
					      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);
 | 
					    const observer = this.model.view.gristDoc.columnObserver(this, tableId, colId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return Computed.create(this, use => {
 | 
					    return Computed.create(this, use => {
 | 
				
			||||||
      return use(observer)
 | 
					      const sort = use(this._sortOrder);
 | 
				
			||||||
 | 
					      const values = use(observer)
 | 
				
			||||||
        .filter(([_id, value]) => !isBlankValue(value))
 | 
					        .filter(([_id, value]) => !isBlankValue(value))
 | 
				
			||||||
        .map(([id, value]) => ({label: String(value), value: String(id)}))
 | 
					        .map(([id, value]) => ({label: String(value), value: String(id)}));
 | 
				
			||||||
        .sort((a, b) => a.label.localeCompare(b.label))
 | 
					      if (sort !== 'default') {
 | 
				
			||||||
        .slice(0, 30); // TODO: make limit dynamic.
 | 
					        values.sort((a, b) => a.label.localeCompare(b.label));
 | 
				
			||||||
 | 
					        if (sort === 'descending') {
 | 
				
			||||||
 | 
					          values.reverse();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      return values.slice(0, 30);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class RefModel extends RefListModel {
 | 
					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() {
 | 
					  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(
 | 
					    return css.cssSelect(
 | 
				
			||||||
      {tabIndex: "-1"},
 | 
					      {tabIndex: "-1"},
 | 
				
			||||||
      ignoreClick,
 | 
					      ignoreClick,
 | 
				
			||||||
      dom.prop('name', this.model.colId),
 | 
					      dom.prop('name', this.model.colId),
 | 
				
			||||||
      dom('option', SELECT_PLACEHOLDER, {value: ''}),
 | 
					      dom('option',
 | 
				
			||||||
      dom.forEach(this.options, ({label, value}) => dom('option', label, {value})),
 | 
					        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 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> {
 | 
					function fieldConstructor(type: string): Constructor<Question> {
 | 
				
			||||||
  switch (type) {
 | 
					  switch (type) {
 | 
				
			||||||
@ -436,7 +651,7 @@ function fieldConstructor(type: string): Constructor<Question> {
 | 
				
			|||||||
    case 'ChoiceList': return ChoiceListModel;
 | 
					    case 'ChoiceList': return ChoiceListModel;
 | 
				
			||||||
    case 'Date': return DateModel;
 | 
					    case 'Date': return DateModel;
 | 
				
			||||||
    case 'DateTime': return DateTimeModel;
 | 
					    case 'DateTime': return DateTimeModel;
 | 
				
			||||||
    case 'Int': return IntModel;
 | 
					    case 'Int': return NumericModel;
 | 
				
			||||||
    case 'Numeric': return NumericModel;
 | 
					    case 'Numeric': return NumericModel;
 | 
				
			||||||
    case 'Ref': return RefModel;
 | 
					    case 'Ref': return RefModel;
 | 
				
			||||||
    case 'RefList': return RefListModel;
 | 
					    case 'RefList': return RefListModel;
 | 
				
			||||||
@ -451,12 +666,3 @@ function fieldConstructor(type: string): Constructor<Question> {
 | 
				
			|||||||
function testType(value: BindableValue<string>) {
 | 
					function testType(value: BindableValue<string>) {
 | 
				
			||||||
  return dom('input', {type: 'hidden'}, dom.prop('value', value), testId('type'));
 | 
					  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 {fromKoSave} from 'app/client/lib/fromKoSave';
 | 
				
			||||||
import {makeT} from 'app/client/lib/localization';
 | 
					import {makeT} from 'app/client/lib/localization';
 | 
				
			||||||
import {ViewFieldRec} from 'app/client/models/DocModel';
 | 
					import {ViewFieldRec} from 'app/client/models/DocModel';
 | 
				
			||||||
import {KoSaveableObservable} from 'app/client/models/modelUtil';
 | 
					import {fieldWithDefault} from 'app/client/models/modelUtil';
 | 
				
			||||||
import {cssLabel, cssRow, cssSeparator} from 'app/client/ui/RightPanelStyles';
 | 
					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 {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
 | 
				
			||||||
import {testId} from 'app/client/ui2018/cssVars';
 | 
					import {select} from 'app/client/ui2018/menus';
 | 
				
			||||||
import {Disposable} from 'grainjs';
 | 
					import {Disposable, dom, makeTestId} from 'grainjs';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const t = makeT('FormConfig');
 | 
					const t = makeT('FormConfig');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class FieldRulesConfig extends Disposable {
 | 
					const testId = makeTestId('test-form-');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class FormSelectConfig extends Disposable {
 | 
				
			||||||
  constructor(private _field: ViewFieldRec) {
 | 
					  constructor(private _field: ViewFieldRec) {
 | 
				
			||||||
    super();
 | 
					    super();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public buildDom() {
 | 
					  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 [
 | 
					    return [
 | 
				
			||||||
      cssSeparator(),
 | 
					      cssSeparator(),
 | 
				
			||||||
      cssLabel(t('Field rules')),
 | 
					      cssLabel(t('Field Rules')),
 | 
				
			||||||
      cssRow(labeledSquareCheckbox(
 | 
					      cssRow(labeledSquareCheckbox(
 | 
				
			||||||
        fromKoSave(requiredField),
 | 
					        fromKoSave(requiredField),
 | 
				
			||||||
        t('Required field'),
 | 
					        t('Required field'),
 | 
				
			||||||
 | 
				
			|||||||
@ -16,6 +16,7 @@ import {logTelemetryEvent} from 'app/client/lib/telemetry';
 | 
				
			|||||||
import DataTableModel from 'app/client/models/DataTableModel';
 | 
					import DataTableModel from 'app/client/models/DataTableModel';
 | 
				
			||||||
import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
 | 
					import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
 | 
				
			||||||
import {reportError} from 'app/client/models/errors';
 | 
					import {reportError} from 'app/client/models/errors';
 | 
				
			||||||
 | 
					import {jsonObservable, SaveableObjObservable} from 'app/client/models/modelUtil';
 | 
				
			||||||
import {ShareRec} from 'app/client/models/entities/ShareRec';
 | 
					import {ShareRec} from 'app/client/models/entities/ShareRec';
 | 
				
			||||||
import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec';
 | 
					import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec';
 | 
				
			||||||
import {docUrl, urlState} from 'app/client/models/gristUrlState';
 | 
					import {docUrl, urlState} from 'app/client/models/gristUrlState';
 | 
				
			||||||
@ -55,7 +56,8 @@ export class FormView extends Disposable {
 | 
				
			|||||||
  protected bundle: (clb: () => Promise<void>) => Promise<void>;
 | 
					  protected bundle: (clb: () => Promise<void>) => Promise<void>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private _formFields: Computed<ViewFieldRec[]>;
 | 
					  private _formFields: Computed<ViewFieldRec[]>;
 | 
				
			||||||
  private _autoLayout: Computed<FormLayoutNode>;
 | 
					  private _layoutSpec: SaveableObjObservable<FormLayoutNode>;
 | 
				
			||||||
 | 
					  private _layout: Computed<FormLayoutNode>;
 | 
				
			||||||
  private _root: BoxModel;
 | 
					  private _root: BoxModel;
 | 
				
			||||||
  private _savedLayout: any;
 | 
					  private _savedLayout: any;
 | 
				
			||||||
  private _saving: boolean = false;
 | 
					  private _saving: boolean = false;
 | 
				
			||||||
@ -67,7 +69,7 @@ export class FormView extends Disposable {
 | 
				
			|||||||
  private _showPublishedMessage: Observable<boolean>;
 | 
					  private _showPublishedMessage: Observable<boolean>;
 | 
				
			||||||
  private _isOwner: boolean;
 | 
					  private _isOwner: boolean;
 | 
				
			||||||
  private _openingForm: Observable<boolean>;
 | 
					  private _openingForm: Observable<boolean>;
 | 
				
			||||||
  private _formElement: HTMLElement;
 | 
					  private _formEditorBodyElement: HTMLElement;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
 | 
					  public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
 | 
				
			||||||
    BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false});
 | 
					    BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false});
 | 
				
			||||||
@ -134,28 +136,30 @@ export class FormView extends Disposable {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    this._formFields = Computed.create(this, use => {
 | 
					    this._formFields = Computed.create(this, use => {
 | 
				
			||||||
      const fields = use(use(this.viewSection.viewFields).getObservable());
 | 
					      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 fields = use(this._formFields);
 | 
				
			||||||
      const layout = use(this.viewSection.layoutSpecObj);
 | 
					      const layoutSpec = use(this._layoutSpec);
 | 
				
			||||||
      if (!layout || !layout.id) {
 | 
					      const patchedLayout = patchLayoutSpec(layoutSpec, new Set(fields.map(f => f.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'); }
 | 
					      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 () => {
 | 
					      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) {
 | 
					        if (clb) {
 | 
				
			||||||
          await clb();
 | 
					          await clb();
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@ -163,7 +167,7 @@ export class FormView extends Disposable {
 | 
				
			|||||||
      });
 | 
					      });
 | 
				
			||||||
    }, this));
 | 
					    }, this));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    this._autoLayout.addListener((v) => {
 | 
					    this._layout.addListener((v) => {
 | 
				
			||||||
      if (this._saving) {
 | 
					      if (this._saving) {
 | 
				
			||||||
        console.warn('Layout changed while saving');
 | 
					        console.warn('Layout changed while saving');
 | 
				
			||||||
        return;
 | 
					        return;
 | 
				
			||||||
@ -421,9 +425,9 @@ export class FormView extends Disposable {
 | 
				
			|||||||
  public buildDom() {
 | 
					  public buildDom() {
 | 
				
			||||||
    return style.cssFormView(
 | 
					    return style.cssFormView(
 | 
				
			||||||
      testId('editor'),
 | 
					      testId('editor'),
 | 
				
			||||||
      style.cssFormEditBody(
 | 
					      this._formEditorBodyElement = style.cssFormEditBody(
 | 
				
			||||||
        style.cssFormContainer(
 | 
					        style.cssFormContainer(
 | 
				
			||||||
          this._formElement = dom('div', dom.forEach(this._root.children, (child) => {
 | 
					          dom('div', dom.forEach(this._root.children, (child) => {
 | 
				
			||||||
            if (!child) {
 | 
					            if (!child) {
 | 
				
			||||||
              return dom('div', 'Empty node');
 | 
					              return dom('div', 'Empty node');
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
@ -433,9 +437,9 @@ export class FormView extends Disposable {
 | 
				
			|||||||
            }
 | 
					            }
 | 
				
			||||||
            return element;
 | 
					            return element;
 | 
				
			||||||
          })),
 | 
					          })),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
      this._buildPublisher(),
 | 
					      this._buildPublisher(),
 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
      ),
 | 
					 | 
				
			||||||
      dom.on('click', () => this.selectedBox.set(null)),
 | 
					      dom.on('click', () => this.selectedBox.set(null)),
 | 
				
			||||||
      dom.maybe(this.gristDoc.docPageModel.isReadonly, () => style.cssFormDisabledOverlay()),
 | 
					      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 nothing has changed, don't bother.
 | 
				
			||||||
      if (isEqual(newVersion, this._savedLayout)) { return; }
 | 
					      if (isEqual(newVersion, this._savedLayout)) { return; }
 | 
				
			||||||
      this._savedLayout = newVersion;
 | 
					      this._savedLayout = newVersion;
 | 
				
			||||||
      await this.viewSection.layoutSpecObj.setAndSave(newVersion);
 | 
					      await this._layoutSpec.setAndSave(newVersion);
 | 
				
			||||||
    } finally {
 | 
					    } finally {
 | 
				
			||||||
      this._saving = false;
 | 
					      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() {
 | 
					  private _getEstimatedFormHeightPx() {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      // Form content height.
 | 
					      // Form height.
 | 
				
			||||||
      this._formElement.scrollHeight +
 | 
					      this._formEditorBodyElement.scrollHeight +
 | 
				
			||||||
      // Plus top/bottom page padding.
 | 
					      // Minus "+" button height in each section.
 | 
				
			||||||
      (2 * 52) +
 | 
					      (-32 * this._getSectionCount()) +
 | 
				
			||||||
      // Plus top/bottom form padding.
 | 
					      // Plus form footer height (visible only in the preview and published form).
 | 
				
			||||||
      (2 * 20) +
 | 
					 | 
				
			||||||
      // Plus minimum form error height.
 | 
					 | 
				
			||||||
      38 +
 | 
					 | 
				
			||||||
      // Plus form footer height.
 | 
					 | 
				
			||||||
      64
 | 
					      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() {
 | 
					  private async _resetForm() {
 | 
				
			||||||
    this.selectedBox.set(null);
 | 
					    this.selectedBox.set(null);
 | 
				
			||||||
    await this.gristDoc.docData.bundleActions('Reset form', async () => {
 | 
					    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);
 | 
					      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
 | 
					// Getting an ES6 class to work with old-style multiple base classes takes a little hacking. Credits: ./ChartView.ts
 | 
				
			||||||
defaults(FormView.prototype, BaseView.prototype);
 | 
					defaults(FormView.prototype, BaseView.prototype);
 | 
				
			||||||
Object.assign(FormView.prototype, BackboneEvents);
 | 
					Object.assign(FormView.prototype, BackboneEvents);
 | 
				
			||||||
 | 
				
			|||||||
@ -1,7 +1,17 @@
 | 
				
			|||||||
import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer';
 | 
					import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer';
 | 
				
			||||||
import * as elements from 'app/client/components/Forms/elements';
 | 
					import * as elements from 'app/client/components/Forms/elements';
 | 
				
			||||||
import {FormView} from 'app/client/components/Forms/FormView';
 | 
					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>;
 | 
					type Callback = () => Promise<void>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -186,7 +196,7 @@ export abstract class BoxModel extends Disposable {
 | 
				
			|||||||
    return this._props.hasOwnProperty(name);
 | 
					    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'); }
 | 
					    if (!this.parent) { throw new Error('Cannot save detached box'); }
 | 
				
			||||||
    return this.parent.save(before);
 | 
					    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 { BoxModel } from "app/client/components/Forms/Model";
 | 
				
			||||||
import { makeTestId } from "app/client/lib/domUtils";
 | 
					import { makeTestId } from "app/client/lib/domUtils";
 | 
				
			||||||
import { bigPrimaryButton } from "app/client/ui2018/buttons";
 | 
					import { bigPrimaryButton } from "app/client/ui2018/buttons";
 | 
				
			||||||
@ -9,8 +10,14 @@ export class SubmitModel extends BoxModel {
 | 
				
			|||||||
    const text = this.view.viewSection.layoutSpecObj.prop('submitText');
 | 
					    const text = this.view.viewSection.layoutSpecObj.prop('submitText');
 | 
				
			||||||
    return dom(
 | 
					    return dom(
 | 
				
			||||||
      "div",
 | 
					      "div",
 | 
				
			||||||
      { style: "text-align: center; margin-top: 20px;" },
 | 
					      css.error(testId("error")),
 | 
				
			||||||
      bigPrimaryButton(dom.text(use => use(text) || 'Submit'), testId("submit"))
 | 
					      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 {textarea} from 'app/client/ui/inputs';
 | 
				
			||||||
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
 | 
					import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
 | 
				
			||||||
import {basicButton, basicButtonLink, textButton} from 'app/client/ui2018/buttons';
 | 
					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 {colors, theme} from 'app/client/ui2018/cssVars';
 | 
				
			||||||
import {icon} from 'app/client/ui2018/icons';
 | 
					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 {BindableValue, dom, DomElementArg, IDomArgs, Observable, styled, subscribeBindable} from 'grainjs';
 | 
				
			||||||
import {marked} from 'marked';
 | 
					import {marked} from 'marked';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -14,7 +16,6 @@ export const cssFormView = styled('div.flexauto.flexvbox', `
 | 
				
			|||||||
  align-items: center;
 | 
					  align-items: center;
 | 
				
			||||||
  justify-content: space-between;
 | 
					  justify-content: space-between;
 | 
				
			||||||
  position: relative;
 | 
					  position: relative;
 | 
				
			||||||
  background-color: ${theme.leftPanelBg};
 | 
					 | 
				
			||||||
  overflow: auto;
 | 
					  overflow: auto;
 | 
				
			||||||
  min-height: 100%;
 | 
					  min-height: 100%;
 | 
				
			||||||
  width: 100%;
 | 
					  width: 100%;
 | 
				
			||||||
@ -22,7 +23,6 @@ export const cssFormView = styled('div.flexauto.flexvbox', `
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const cssFormContainer = styled('div', `
 | 
					export const cssFormContainer = styled('div', `
 | 
				
			||||||
  background-color: ${theme.mainPanelBg};
 | 
					  background-color: ${theme.mainPanelBg};
 | 
				
			||||||
  border: 1px solid ${theme.modalBorderDark};
 | 
					 | 
				
			||||||
  color: ${theme.text};
 | 
					  color: ${theme.text};
 | 
				
			||||||
  width: 600px;
 | 
					  width: 600px;
 | 
				
			||||||
  align-self: center;
 | 
					  align-self: center;
 | 
				
			||||||
@ -31,10 +31,8 @@ export const cssFormContainer = styled('div', `
 | 
				
			|||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  flex-direction: column;
 | 
					  flex-direction: column;
 | 
				
			||||||
  max-width: calc(100% - 32px);
 | 
					  max-width: calc(100% - 32px);
 | 
				
			||||||
  padding-top: 20px;
 | 
					 | 
				
			||||||
  padding-left: 48px;
 | 
					 | 
				
			||||||
  padding-right: 48px;
 | 
					 | 
				
			||||||
  gap: 8px;
 | 
					  gap: 8px;
 | 
				
			||||||
 | 
					  line-height: 1.42857143;
 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const cssFieldEditor = styled('div.hover_border.field_editor', `
 | 
					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;
 | 
					  margin-bottom: 4px;
 | 
				
			||||||
  --hover-visible: hidden;
 | 
					  --hover-visible: hidden;
 | 
				
			||||||
  transition: transform 0.2s ease-in-out;
 | 
					  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:not(:has(.hover_border:hover),&-cut) {
 | 
				
			||||||
    --hover-visible: visible;
 | 
					    --hover-visible: visible;
 | 
				
			||||||
    outline: 1px solid ${theme.controlPrimaryBg};
 | 
					    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', `
 | 
					export const cssSection = styled('div', `
 | 
				
			||||||
  position: relative;
 | 
					  position: relative;
 | 
				
			||||||
  color: ${theme.text};
 | 
					  color: ${theme.text};
 | 
				
			||||||
  margin: 0px auto;
 | 
					  margin: 0px auto;
 | 
				
			||||||
  min-height: 50px;
 | 
					  min-height: 50px;
 | 
				
			||||||
  .${cssFormView.className}-preview & {
 | 
					`);
 | 
				
			||||||
    background: transparent;
 | 
					
 | 
				
			||||||
    border-radius: unset;
 | 
					export const cssCheckboxList = styled('div', `
 | 
				
			||||||
    padding: 0px;
 | 
					  display: flex;
 | 
				
			||||||
    min-height: auto;
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 8px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &-horizontal {
 | 
				
			||||||
 | 
					    flex-direction: row;
 | 
				
			||||||
 | 
					    flex-wrap: wrap;
 | 
				
			||||||
 | 
					    column-gap: 16px;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const cssCheckboxLabel = styled('label', `
 | 
					export const cssCheckboxLabel = styled(cssLabel, `
 | 
				
			||||||
  font-size: 15px;
 | 
					  font-size: 13px;
 | 
				
			||||||
 | 
					  line-height: 16px;
 | 
				
			||||||
  font-weight: normal;
 | 
					  font-weight: normal;
 | 
				
			||||||
  user-select: none;
 | 
					  user-select: none;
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  align-items: center;
 | 
					 | 
				
			||||||
  gap: 8px;
 | 
					  gap: 8px;
 | 
				
			||||||
  margin: 0px;
 | 
					  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 {
 | 
					export function textbox(obs: Observable<string|undefined>, ...args: DomElementArg[]): HTMLInputElement {
 | 
				
			||||||
  return dom('input',
 | 
					  return dom('input',
 | 
				
			||||||
    dom.prop('value', u => u(obs) || ''),
 | 
					    dom.prop('value', u => u(obs) || ''),
 | 
				
			||||||
@ -118,11 +124,14 @@ export function textbox(obs: Observable<string|undefined>, ...args: DomElementAr
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const cssQuestion = styled('div', `
 | 
					export const cssQuestion = styled('div', `
 | 
				
			||||||
 | 
					  height: 100%;
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  justify-content: space-between;
 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const cssRequiredWrapper = styled('div', `
 | 
					export const cssRequiredWrapper = styled('div', `
 | 
				
			||||||
  margin-bottom: 8px;
 | 
					  margin: 8px 0px;
 | 
				
			||||||
  min-height: 16px;
 | 
					  min-height: 16px;
 | 
				
			||||||
  &-required {
 | 
					  &-required {
 | 
				
			||||||
    display: grid;
 | 
					    display: grid;
 | 
				
			||||||
@ -148,7 +157,7 @@ export const cssRenderedLabel = styled('div', `
 | 
				
			|||||||
  min-height: 16px;
 | 
					  min-height: 16px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  color: ${theme.mediumText};
 | 
					  color: ${theme.mediumText};
 | 
				
			||||||
  font-size: 11px;
 | 
					  font-size: 13px;
 | 
				
			||||||
  line-height: 16px;
 | 
					  line-height: 16px;
 | 
				
			||||||
  font-weight: 700;
 | 
					  font-weight: 700;
 | 
				
			||||||
  white-space: pre-wrap;
 | 
					  white-space: pre-wrap;
 | 
				
			||||||
@ -186,17 +195,9 @@ export const cssEditableLabel = styled(textarea, `
 | 
				
			|||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const cssLabelInline = styled('div', `
 | 
					export const cssLabelInline = styled('div', `
 | 
				
			||||||
  margin-bottom: 0px;
 | 
					  line-height: 16px;
 | 
				
			||||||
  & .${cssRenderedLabel.className} {
 | 
					  margin: 0px;
 | 
				
			||||||
    color: ${theme.mediumText};
 | 
					  overflow-wrap: anywhere;
 | 
				
			||||||
    font-size: 15px;
 | 
					 | 
				
			||||||
    font-weight: normal;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  & .${cssEditableLabel.className} {
 | 
					 | 
				
			||||||
    color: ${colors.darkText};
 | 
					 | 
				
			||||||
    font-size: 15px;
 | 
					 | 
				
			||||||
    font-weight: normal;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const cssDesc = styled('div', `
 | 
					export const cssDesc = styled('div', `
 | 
				
			||||||
@ -211,15 +212,19 @@ export const cssDesc = styled('div', `
 | 
				
			|||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const cssInput = styled('input', `
 | 
					export const cssInput = styled('input', `
 | 
				
			||||||
  background-color: ${theme.inputDisabledBg};
 | 
					  background-color: ${theme.inputBg};
 | 
				
			||||||
  font-size: inherit;
 | 
					  font-size: inherit;
 | 
				
			||||||
  height: 27px;
 | 
					  height: 29px;
 | 
				
			||||||
  padding: 4px 8px;
 | 
					  padding: 4px 8px;
 | 
				
			||||||
  border: 1px solid ${theme.inputBorder};
 | 
					  border: 1px solid ${theme.inputBorder};
 | 
				
			||||||
  border-radius: 3px;
 | 
					  border-radius: 3px;
 | 
				
			||||||
  outline: none;
 | 
					  outline: none;
 | 
				
			||||||
  pointer-events: none;
 | 
					  pointer-events: none;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &:disabled {
 | 
				
			||||||
 | 
					    color: ${theme.inputDisabledFg};
 | 
				
			||||||
 | 
					    background-color: ${theme.inputDisabledBg};
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
  &-invalid {
 | 
					  &-invalid {
 | 
				
			||||||
    color: ${theme.inputInvalid};
 | 
					    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', `
 | 
					export const cssSelect = styled('select', `
 | 
				
			||||||
  flex: auto;
 | 
					  flex: auto;
 | 
				
			||||||
  width: 100%;
 | 
					  width: 100%;
 | 
				
			||||||
  background-color: ${theme.inputDisabledBg};
 | 
					  background-color: ${theme.inputBg};
 | 
				
			||||||
  font-size: inherit;
 | 
					  font-size: inherit;
 | 
				
			||||||
  height: 27px;
 | 
					  height: 27px;
 | 
				
			||||||
  padding: 4px 8px;
 | 
					  padding: 4px 8px;
 | 
				
			||||||
@ -241,8 +273,34 @@ export const cssSelect = styled('select', `
 | 
				
			|||||||
  pointer-events: none;
 | 
					  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', `
 | 
					export const cssSelectedOverlay = styled('div._cssSelectedOverlay', `
 | 
				
			||||||
@ -253,10 +311,6 @@ export const cssSelectedOverlay = styled('div._cssSelectedOverlay', `
 | 
				
			|||||||
  .${cssFieldEditor.className}-selected > & {
 | 
					  .${cssFieldEditor.className}-selected > & {
 | 
				
			||||||
    opacity: 1;
 | 
					    opacity: 1;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					 | 
				
			||||||
  .${cssFormView.className}-preview & {
 | 
					 | 
				
			||||||
    display: none;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const cssPlusButton = styled('div', `
 | 
					export const cssPlusButton = styled('div', `
 | 
				
			||||||
@ -288,22 +342,12 @@ export const cssPlusIcon = styled(icon, `
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const cssColumns = styled('div', `
 | 
					export const cssColumns = styled('div', `
 | 
				
			||||||
  --css-columns-count: 2;
 | 
					 | 
				
			||||||
  display: grid;
 | 
					  display: grid;
 | 
				
			||||||
  grid-template-columns: repeat(var(--css-columns-count), 1fr) 32px;
 | 
					  grid-template-columns: repeat(var(--css-columns-count), 1fr) 32px;
 | 
				
			||||||
  gap: 8px;
 | 
					  gap: 8px;
 | 
				
			||||||
  padding: 8px 4px;
 | 
					  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', `
 | 
					export const cssColumn = styled('div', `
 | 
				
			||||||
  position: relative;
 | 
					  position: relative;
 | 
				
			||||||
  &-empty, &-add-button {
 | 
					  &-empty, &-add-button {
 | 
				
			||||||
@ -336,21 +380,6 @@ export const cssColumn = styled('div', `
 | 
				
			|||||||
  &-drag-over {
 | 
					  &-drag-over {
 | 
				
			||||||
    outline: 2px dashed ${theme.controlPrimaryBg};
 | 
					    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', `
 | 
					export const cssButtonGroup = styled('div', `
 | 
				
			||||||
@ -511,16 +540,13 @@ export const cssPreview = styled('iframe', `
 | 
				
			|||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const cssSwitcher = styled('div', `
 | 
					export const cssSwitcher = styled('div', `
 | 
				
			||||||
  flex-shrink: 0;
 | 
					  border-top: 1px solid ${theme.menuBorder};
 | 
				
			||||||
  margin-top: 24px;
 | 
					  width: 100%;
 | 
				
			||||||
  border-top: 1px solid ${theme.modalBorder};
 | 
					 | 
				
			||||||
  margin-left: -48px;
 | 
					 | 
				
			||||||
  margin-right: -48px;
 | 
					 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const cssSwitcherMessage = styled('div', `
 | 
					export const cssSwitcherMessage = styled('div', `
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  padding: 0px 16px 0px 16px;
 | 
					  padding: 8px 16px;
 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const cssSwitcherMessageBody = styled('div', `
 | 
					export const cssSwitcherMessageBody = styled('div', `
 | 
				
			||||||
@ -528,7 +554,7 @@ export const cssSwitcherMessageBody = styled('div', `
 | 
				
			|||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  justify-content: center;
 | 
					  justify-content: center;
 | 
				
			||||||
  align-items: center;
 | 
					  align-items: center;
 | 
				
			||||||
  padding: 10px 32px;
 | 
					  padding: 8px 16px;
 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const cssSwitcherMessageDismissButton = styled('div', `
 | 
					export const cssSwitcherMessageDismissButton = styled('div', `
 | 
				
			||||||
@ -551,8 +577,7 @@ export const cssParagraph = styled('div', `
 | 
				
			|||||||
export const cssFormEditBody = styled('div', `
 | 
					export const cssFormEditBody = styled('div', `
 | 
				
			||||||
  width: 100%;
 | 
					  width: 100%;
 | 
				
			||||||
  overflow: auto;
 | 
					  overflow: auto;
 | 
				
			||||||
  padding-top: 52px;
 | 
					  padding: 20px;
 | 
				
			||||||
  padding-bottom: 24px;
 | 
					 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const cssRemoveButton = styled('div', `
 | 
					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 * as DocConfigTab from 'app/client/components/DocConfigTab';
 | 
				
			||||||
import {Drafts} from "app/client/components/Drafts";
 | 
					import {Drafts} from "app/client/components/Drafts";
 | 
				
			||||||
import {EditorMonitor} from "app/client/components/EditorMonitor";
 | 
					import {EditorMonitor} from "app/client/components/EditorMonitor";
 | 
				
			||||||
 | 
					import {buildDefaultFormLayout} from 'app/client/components/Forms/FormView';
 | 
				
			||||||
import GridView from 'app/client/components/GridView';
 | 
					import GridView from 'app/client/components/GridView';
 | 
				
			||||||
import {importFromFile, selectAndImport} from 'app/client/components/Importer';
 | 
					import {importFromFile, selectAndImport} from 'app/client/components/Importer';
 | 
				
			||||||
import {RawDataPage, RawDataPopup} from 'app/client/components/RawDataPage';
 | 
					import {RawDataPage, RawDataPopup} from 'app/client/components/RawDataPage';
 | 
				
			||||||
@ -946,6 +947,9 @@ export class GristDoc extends DisposableWithEvents {
 | 
				
			|||||||
    if (val.type === 'chart') {
 | 
					    if (val.type === 'chart') {
 | 
				
			||||||
      await this._ensureOneNumericSeries(result.sectionRef);
 | 
					      await this._ensureOneNumericSeries(result.sectionRef);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					    if (val.type === 'form') {
 | 
				
			||||||
 | 
					      await this._setDefaultFormLayoutSpec(result.sectionRef);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
    await this.saveLink(val.link, result.sectionRef);
 | 
					    await this.saveLink(val.link, result.sectionRef);
 | 
				
			||||||
    return result;
 | 
					    return result;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@ -962,36 +966,43 @@ export class GristDoc extends DisposableWithEvents {
 | 
				
			|||||||
      },
 | 
					      },
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let viewRef: IDocPage;
 | 
				
			||||||
 | 
					    let sectionRef: number | undefined;
 | 
				
			||||||
 | 
					    await this.docData.bundleActions('Add new page', async () => {
 | 
				
			||||||
      if (val.table === 'New Table') {
 | 
					      if (val.table === 'New Table') {
 | 
				
			||||||
        const name = await this._promptForName();
 | 
					        const name = await this._promptForName();
 | 
				
			||||||
        if (name === undefined) {
 | 
					        if (name === undefined) {
 | 
				
			||||||
          return;
 | 
					          return;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      let newViewId: IDocPage;
 | 
					 | 
				
			||||||
        if (val.type === WidgetType.Table) {
 | 
					        if (val.type === WidgetType.Table) {
 | 
				
			||||||
          const result = await this.docData.sendAction(['AddEmptyTable', name]);
 | 
					          const result = await this.docData.sendAction(['AddEmptyTable', name]);
 | 
				
			||||||
        newViewId = result.views[0].id;
 | 
					          viewRef = result.views[0].id;
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
          // This will create a new table and page.
 | 
					          // This will create a new table and page.
 | 
				
			||||||
          const result = await this.docData.sendAction(
 | 
					          const result = await this.docData.sendAction(
 | 
				
			||||||
            ['CreateViewSection', /* new table */0, 0, val.type, null, name]
 | 
					            ['CreateViewSection', /* new table */0, 0, val.type, null, name]
 | 
				
			||||||
          );
 | 
					          );
 | 
				
			||||||
        newViewId = result.viewRef;
 | 
					          [viewRef, sectionRef] = [result.viewRef, result.sectionRef];
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      await this.openDocPage(newViewId);
 | 
					 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
      let result: any;
 | 
					        const result = await this.docData.sendAction(
 | 
				
			||||||
      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]
 | 
					          ['CreateViewSection', val.table, 0, val.type, val.summarize ? val.columns : null, null]
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
 | 
					        [viewRef, sectionRef] = [result.viewRef, result.sectionRef];
 | 
				
			||||||
        if (val.type === 'chart') {
 | 
					        if (val.type === 'chart') {
 | 
				
			||||||
          await this._ensureOneNumericSeries(result.sectionRef);
 | 
					          await this._ensureOneNumericSeries(sectionRef!);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      if (val.type === 'form') {
 | 
				
			||||||
 | 
					        await this._setDefaultFormLayoutSpec(sectionRef!);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
      await this.openDocPage(result.viewRef);
 | 
					
 | 
				
			||||||
 | 
					    await this.openDocPage(viewRef!);
 | 
				
			||||||
 | 
					    if (sectionRef) {
 | 
				
			||||||
      // The newly-added section should be given focus.
 | 
					      // The newly-added section should be given focus.
 | 
				
			||||||
      this.viewModel.activeSectionId(result.sectionRef);
 | 
					      this.viewModel.activeSectionId(sectionRef);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
 | 
					    this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -999,7 +1010,6 @@ export class GristDoc extends DisposableWithEvents {
 | 
				
			|||||||
      this._handleNewAttachedCustomWidget(val.type).catch(reportError);
 | 
					      this._handleNewAttachedCustomWidget(val.type).catch(reportError);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
   * Opens a dialog to upload one or multiple files as tables and then switches to the first table's
 | 
					   * Opens a dialog to upload one or multiple files as tables and then switches to the first table's
 | 
				
			||||||
@ -1425,6 +1435,8 @@ export class GristDoc extends DisposableWithEvents {
 | 
				
			|||||||
    const toggle = () => !refreshed.isDisposed() && refreshed.set(refreshed.get() + 1);
 | 
					    const toggle = () => !refreshed.isDisposed() && refreshed.set(refreshed.get() + 1);
 | 
				
			||||||
    const holder = Holder.create(owner);
 | 
					    const holder = Holder.create(owner);
 | 
				
			||||||
    const listener = (tab: TableModel) => {
 | 
					    const listener = (tab: TableModel) => {
 | 
				
			||||||
 | 
					      if (tab.tableData.tableId === '') { return; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Now subscribe to any data change in that table.
 | 
					      // Now subscribe to any data change in that table.
 | 
				
			||||||
      const subs = MultiHolder.create(holder);
 | 
					      const subs = MultiHolder.create(holder);
 | 
				
			||||||
      subs.autoDispose(tab.tableData.dataLoadedEmitter.addListener(toggle));
 | 
					      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() {
 | 
					  private _handleTriggerQueueOverflowMessage() {
 | 
				
			||||||
    this.listenTo(this, 'webhookOverflowError', (err: any) => {
 | 
					    this.listenTo(this, 'webhookOverflowError', (err: any) => {
 | 
				
			||||||
      this.app.topAppModel.notifier.createNotification({
 | 
					      this.app.topAppModel.notifier.createNotification({
 | 
				
			||||||
 | 
				
			|||||||
@ -41,13 +41,52 @@ export interface FormField {
 | 
				
			|||||||
  refValues: [number, CellValue][] | null;
 | 
					  refValues: [number, CellValue][] | null;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface FormFieldOptions {
 | 
					export interface FormFieldOptions {
 | 
				
			||||||
  /** True if the field is required to submit the form. */
 | 
					  /** Choices for a Choice or Choice List field. */
 | 
				
			||||||
  formRequired?: boolean;
 | 
					 | 
				
			||||||
  /** Populated with a list of options. Only set if the field `type` is a Choice/Reference Liste. */
 | 
					 | 
				
			||||||
  choices?: string[];
 | 
					  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 {
 | 
					export interface FormAPI {
 | 
				
			||||||
  getForm(options: GetFormOptions): Promise<Form>;
 | 
					  getForm(options: GetFormOptions): Promise<Form>;
 | 
				
			||||||
  createRecord(options: CreateRecordOptions): Promise<void>;
 | 
					  createRecord(options: CreateRecordOptions): Promise<void>;
 | 
				
			||||||
 | 
				
			|||||||
@ -1,36 +1,144 @@
 | 
				
			|||||||
import {makeT} from 'app/client/lib/localization';
 | 
					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 {icon} from 'app/client/ui2018/icons';
 | 
				
			||||||
import {commonUrls} from 'app/common/gristUrls';
 | 
					import {commonUrls} from 'app/common/gristUrls';
 | 
				
			||||||
import {DomContents, makeTestId} from 'grainjs';
 | 
					import {DomContents, DomElementArg, styled} from 'grainjs';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const t = makeT('FormContainer');
 | 
					const t = makeT('FormContainer');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const testId = makeTestId('test-form-');
 | 
					export function buildFormMessagePage(buildBody: () => DomContents, ...args: DomElementArg[]) {
 | 
				
			||||||
 | 
					  return cssFormMessagePage(
 | 
				
			||||||
export function buildFormContainer(buildBody: () => DomContents) {
 | 
					    cssFormMessage(
 | 
				
			||||||
  return css.formContainer(
 | 
					      cssFormMessageBody(
 | 
				
			||||||
    css.form(
 | 
					 | 
				
			||||||
      css.formBody(
 | 
					 | 
				
			||||||
        buildBody(),
 | 
					        buildBody(),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
      css.formFooter(
 | 
					      cssFormMessageFooter(
 | 
				
			||||||
        css.poweredByGrist(
 | 
					        buildFormFooter(),
 | 
				
			||||||
          css.poweredByGristLink(
 | 
					      ),
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					    ...args,
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function buildFormFooter() {
 | 
				
			||||||
 | 
					  return [
 | 
				
			||||||
 | 
					    cssPoweredByGrist(
 | 
				
			||||||
 | 
					      cssPoweredByGristLink(
 | 
				
			||||||
        {href: commonUrls.forms, target: '_blank'},
 | 
					        {href: commonUrls.forms, target: '_blank'},
 | 
				
			||||||
        t('Powered by'),
 | 
					        t('Powered by'),
 | 
				
			||||||
            css.gristLogo(),
 | 
					        cssGristLogo(),
 | 
				
			||||||
      )
 | 
					      )
 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
        css.buildForm(
 | 
					    cssBuildForm(
 | 
				
			||||||
          css.buildFormLink(
 | 
					      cssBuildFormLink(
 | 
				
			||||||
        {href: commonUrls.forms, target: '_blank'},
 | 
					        {href: commonUrls.forms, target: '_blank'},
 | 
				
			||||||
        t('Build your own form'),
 | 
					        t('Build your own form'),
 | 
				
			||||||
        icon('Expand'),
 | 
					        icon('Expand'),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
      ),
 | 
					  ];
 | 
				
			||||||
    ),
 | 
					 | 
				
			||||||
    testId('container'),
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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 {makeT} from 'app/client/lib/localization';
 | 
				
			||||||
import {buildFormContainer} from 'app/client/ui/FormContainer';
 | 
					import {
 | 
				
			||||||
import * as css from 'app/client/ui/FormPagesCss';
 | 
					  buildFormMessagePage,
 | 
				
			||||||
 | 
					  cssFormMessageImage,
 | 
				
			||||||
 | 
					  cssFormMessageImageContainer,
 | 
				
			||||||
 | 
					  cssFormMessageText,
 | 
				
			||||||
 | 
					} from 'app/client/ui/FormContainer';
 | 
				
			||||||
import {getPageTitleSuffix} from 'app/common/gristUrls';
 | 
					import {getPageTitleSuffix} from 'app/common/gristUrls';
 | 
				
			||||||
import {getGristConfig} from 'app/common/urlUtils';
 | 
					import {getGristConfig} from 'app/common/urlUtils';
 | 
				
			||||||
import {Disposable, makeTestId} from 'grainjs';
 | 
					import {Disposable, makeTestId, styled} from 'grainjs';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const testId = makeTestId('test-form-');
 | 
					const testId = makeTestId('test-form-');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -16,11 +20,20 @@ export class FormErrorPage extends Disposable {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public buildDom() {
 | 
					  public buildDom() {
 | 
				
			||||||
    return buildFormContainer(() => [
 | 
					    return buildFormMessagePage(() => [
 | 
				
			||||||
      css.formErrorMessageImageContainer(css.formErrorMessageImage({
 | 
					      cssFormErrorMessageImageContainer(
 | 
				
			||||||
        src: 'img/form-error.svg',
 | 
					        cssFormErrorMessageImage({src: 'img/form-error.svg'}),
 | 
				
			||||||
      })),
 | 
					      ),
 | 
				
			||||||
      css.formMessageText(this._message, testId('error-text')),
 | 
					      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 {handleSubmit, TypedFormData} from 'app/client/lib/formUtils';
 | 
				
			||||||
import {makeT} from 'app/client/lib/localization';
 | 
					import {makeT} from 'app/client/lib/localization';
 | 
				
			||||||
import {FormModel, FormModelImpl} from 'app/client/models/FormModel';
 | 
					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 {FormErrorPage} from 'app/client/ui/FormErrorPage';
 | 
				
			||||||
import * as css from 'app/client/ui/FormPagesCss';
 | 
					 | 
				
			||||||
import {FormSuccessPage} from 'app/client/ui/FormSuccessPage';
 | 
					import {FormSuccessPage} from 'app/client/ui/FormSuccessPage';
 | 
				
			||||||
import {colors} from 'app/client/ui2018/cssVars';
 | 
					import {colors} from 'app/client/ui2018/cssVars';
 | 
				
			||||||
import {ApiError} from 'app/common/ApiError';
 | 
					import {ApiError} from 'app/common/ApiError';
 | 
				
			||||||
import {getPageTitleSuffix} from 'app/common/gristUrls';
 | 
					import {getPageTitleSuffix} from 'app/common/gristUrls';
 | 
				
			||||||
import {getGristConfig} from 'app/common/urlUtils';
 | 
					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 t = makeT('FormPage');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const testId = makeTestId('test-form-');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class FormPage extends Disposable {
 | 
					export class FormPage extends Disposable {
 | 
				
			||||||
  private readonly _model: FormModel = new FormModelImpl();
 | 
					  private readonly _model: FormModel = new FormModelImpl();
 | 
				
			||||||
  private readonly _error = Observable.create<string|null>(this, null);
 | 
					  private readonly _error = Observable.create<string|null>(this, null);
 | 
				
			||||||
@ -30,7 +31,7 @@ export class FormPage extends Disposable {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public buildDom() {
 | 
					  public buildDom() {
 | 
				
			||||||
    return css.pageContainer(
 | 
					    return cssPageContainer(
 | 
				
			||||||
      dom.domComputed(use => {
 | 
					      dom.domComputed(use => {
 | 
				
			||||||
        const error = use(this._model.error);
 | 
					        const error = use(this._model.error);
 | 
				
			||||||
        if (error) { return dom.create(FormErrorPage, error); }
 | 
					        if (error) { return dom.create(FormErrorPage, error); }
 | 
				
			||||||
@ -38,12 +39,12 @@ export class FormPage extends Disposable {
 | 
				
			|||||||
        const submitted = use(this._model.submitted);
 | 
					        const submitted = use(this._model.submitted);
 | 
				
			||||||
        if (submitted) { return dom.create(FormSuccessPage, this._model); }
 | 
					        if (submitted) { return dom.create(FormSuccessPage, this._model); }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return this._buildFormDom();
 | 
					        return this._buildFormPageDom();
 | 
				
			||||||
      }),
 | 
					      }),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private _buildFormDom() {
 | 
					  private _buildFormPageDom() {
 | 
				
			||||||
    return dom.domComputed(use => {
 | 
					    return dom.domComputed(use => {
 | 
				
			||||||
      const form = use(this._model.form);
 | 
					      const form = use(this._model.form);
 | 
				
			||||||
      const rootLayoutNode = use(this._model.formLayout);
 | 
					      const rootLayoutNode = use(this._model.formLayout);
 | 
				
			||||||
@ -56,8 +57,10 @@ export class FormPage extends Disposable {
 | 
				
			|||||||
        error: this._error,
 | 
					        error: this._error,
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      return buildFormContainer(() =>
 | 
					      return dom('div',
 | 
				
			||||||
        cssForm(
 | 
					        cssForm(
 | 
				
			||||||
 | 
					          cssFormBody(
 | 
				
			||||||
 | 
					            cssFormContent(
 | 
				
			||||||
              dom.autoDispose(formRenderer),
 | 
					              dom.autoDispose(formRenderer),
 | 
				
			||||||
              formRenderer.render(),
 | 
					              formRenderer.render(),
 | 
				
			||||||
              handleSubmit(this._model.submitting,
 | 
					              handleSubmit(this._model.submitting,
 | 
				
			||||||
@ -66,6 +69,12 @@ export class FormPage extends Disposable {
 | 
				
			|||||||
                (e) => this._handleFormError(e),
 | 
					                (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 cssPageContainer = styled('div', `
 | 
				
			||||||
const cssForm = styled('form', `
 | 
					  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};
 | 
					  color: ${colors.dark};
 | 
				
			||||||
  font-size: 15px;
 | 
					  font-size: 15px;
 | 
				
			||||||
  line-height: 1.42857143;
 | 
					  line-height: 1.42857143;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  & > div + div {
 | 
					 | 
				
			||||||
    margin-top: 16px;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  & h1,
 | 
					  & h1,
 | 
				
			||||||
  & h2,
 | 
					  & h2,
 | 
				
			||||||
  & h3,
 | 
					  & h3,
 | 
				
			||||||
  & h4,
 | 
					  & h4,
 | 
				
			||||||
  & h5,
 | 
					  & h5,
 | 
				
			||||||
  & h6 {
 | 
					  & h6 {
 | 
				
			||||||
    margin: 4px 0px;
 | 
					    margin: 8px 0px 12px 0px;
 | 
				
			||||||
    font-weight: normal;
 | 
					    font-weight: normal;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  & h1 {
 | 
					  & h1 {
 | 
				
			||||||
@ -149,3 +176,8 @@ const cssForm = styled('form', `
 | 
				
			|||||||
    margin: 4px 0px;
 | 
					    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 {makeT} from 'app/client/lib/localization';
 | 
				
			||||||
import {FormModel } from 'app/client/models/FormModel';
 | 
					import {FormModel} from 'app/client/models/FormModel';
 | 
				
			||||||
import {buildFormContainer} from 'app/client/ui/FormContainer';
 | 
					import {
 | 
				
			||||||
import * as css from 'app/client/ui/FormPagesCss';
 | 
					  buildFormMessagePage,
 | 
				
			||||||
 | 
					  cssFormMessageImage,
 | 
				
			||||||
 | 
					  cssFormMessageImageContainer,
 | 
				
			||||||
 | 
					  cssFormMessageText,
 | 
				
			||||||
 | 
					} from 'app/client/ui/FormContainer';
 | 
				
			||||||
import {vars} from 'app/client/ui2018/cssVars';
 | 
					import {vars} from 'app/client/ui2018/cssVars';
 | 
				
			||||||
import {getPageTitleSuffix} from 'app/common/gristUrls';
 | 
					import {getPageTitleSuffix} from 'app/common/gristUrls';
 | 
				
			||||||
import {getGristConfig} from 'app/common/urlUtils';
 | 
					import {getGristConfig} from 'app/common/urlUtils';
 | 
				
			||||||
@ -28,20 +32,20 @@ export class FormSuccessPage extends Disposable {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public buildDom() {
 | 
					  public buildDom() {
 | 
				
			||||||
    return buildFormContainer(() => [
 | 
					    return buildFormMessagePage(() => [
 | 
				
			||||||
      css.formSuccessMessageImageContainer(css.formSuccessMessageImage({
 | 
					      cssFormSuccessMessageImageContainer(
 | 
				
			||||||
        src: 'img/form-success.svg',
 | 
					        cssFormSuccessMessageImage({src: 'img/form-success.svg'}),
 | 
				
			||||||
      })),
 | 
					      ),
 | 
				
			||||||
      css.formMessageText(dom.text(this._successText), testId('success-text')),
 | 
					      cssFormMessageText(dom.text(this._successText), testId('success-page-text')),
 | 
				
			||||||
      dom.maybe(this._showNewResponseButton, () =>
 | 
					      dom.maybe(this._showNewResponseButton, () =>
 | 
				
			||||||
        cssFormButtons(
 | 
					        cssFormButtons(
 | 
				
			||||||
          cssFormNewResponseButton(
 | 
					          cssFormNewResponseButton(
 | 
				
			||||||
            'Submit new response',
 | 
					            t('Submit new response'),
 | 
				
			||||||
            dom.on('click', () => this._handleClickNewResponseButton()),
 | 
					            dom.on('click', () => this._handleClickNewResponseButton()),
 | 
				
			||||||
          ),
 | 
					          ),
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
    ]);
 | 
					    ], testId('success-page'));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private async _handleClickNewResponseButton() {
 | 
					  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', `
 | 
					const cssFormButtons = styled('div', `
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  justify-content: center;
 | 
					  justify-content: center;
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +1,6 @@
 | 
				
			|||||||
import {theme, vars} from 'app/client/ui2018/cssVars';
 | 
					import {theme, vars} from 'app/client/ui2018/cssVars';
 | 
				
			||||||
import {icon} from 'app/client/ui2018/icons';
 | 
					import {icon} from 'app/client/ui2018/icons';
 | 
				
			||||||
 | 
					import {numericSpinner} from 'app/client/widgets/NumericSpinner';
 | 
				
			||||||
import {styled} from 'grainjs';
 | 
					import {styled} from 'grainjs';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const cssIcon = styled(icon, `
 | 
					export const cssIcon = styled(icon, `
 | 
				
			||||||
@ -89,3 +90,7 @@ export const cssPinButton = styled('div', `
 | 
				
			|||||||
    background-color: ${theme.hover};
 | 
					    background-color: ${theme.hover};
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const cssNumericSpinner = styled(numericSpinner, `
 | 
				
			||||||
 | 
					  height: 28px;
 | 
				
			||||||
 | 
					`);
 | 
				
			||||||
 | 
				
			|||||||
@ -23,6 +23,7 @@ export const cssLabel = styled('label', `
 | 
				
			|||||||
  display: inline-flex;
 | 
					  display: inline-flex;
 | 
				
			||||||
  min-width: 0px;
 | 
					  min-width: 0px;
 | 
				
			||||||
  margin-bottom: 0px;
 | 
					  margin-bottom: 0px;
 | 
				
			||||||
 | 
					  flex-shrink: 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  outline: none;
 | 
					  outline: none;
 | 
				
			||||||
  user-select: 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 {DataRowModel} from 'app/client/models/DataRowModel';
 | 
				
			||||||
import {testId} from 'app/client/ui2018/cssVars';
 | 
					import {testId} from 'app/client/ui2018/cssVars';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  ChoiceOptionsByName,
 | 
					  ChoiceOptionsByName,
 | 
				
			||||||
  ChoiceTextBox,
 | 
					  ChoiceTextBox,
 | 
				
			||||||
} from 'app/client/widgets/ChoiceTextBox';
 | 
					} from 'app/client/widgets/ChoiceTextBox';
 | 
				
			||||||
 | 
					import {choiceToken} from 'app/client/widgets/ChoiceToken';
 | 
				
			||||||
import {CellValue} from 'app/common/DocActions';
 | 
					import {CellValue} from 'app/common/DocActions';
 | 
				
			||||||
import {decodeObject} from 'app/plugin/objtypes';
 | 
					import {decodeObject} from 'app/plugin/objtypes';
 | 
				
			||||||
import {dom, styled} from 'grainjs';
 | 
					import {dom, styled} from 'grainjs';
 | 
				
			||||||
import {choiceToken} from 'app/client/widgets/ChoiceToken';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * ChoiceListCell - A cell that renders a list of choice tokens.
 | 
					 * 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', `
 | 
					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 {makeT} from 'app/client/lib/localization';
 | 
				
			||||||
import {DataRowModel} from 'app/client/models/DataRowModel';
 | 
					import {DataRowModel} from 'app/client/models/DataRowModel';
 | 
				
			||||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
 | 
					import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
 | 
				
			||||||
@ -76,7 +81,7 @@ export class ChoiceTextBox extends NTextBox {
 | 
				
			|||||||
  public buildConfigDom() {
 | 
					  public buildConfigDom() {
 | 
				
			||||||
    return [
 | 
					    return [
 | 
				
			||||||
      super.buildConfigDom(),
 | 
					      super.buildConfigDom(),
 | 
				
			||||||
      this._buildChoicesConfigDom(),
 | 
					      this.buildChoicesConfigDom(),
 | 
				
			||||||
    ];
 | 
					    ];
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -86,14 +91,16 @@ export class ChoiceTextBox extends NTextBox {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  public buildFormConfigDom() {
 | 
					  public buildFormConfigDom() {
 | 
				
			||||||
    return [
 | 
					    return [
 | 
				
			||||||
      this._buildChoicesConfigDom(),
 | 
					      this.buildChoicesConfigDom(),
 | 
				
			||||||
      super.buildFormConfigDom(),
 | 
					      dom.create(FormSelectConfig, this.field),
 | 
				
			||||||
 | 
					      dom.create(FormOptionsSortConfig, this.field),
 | 
				
			||||||
 | 
					      dom.create(FormFieldRulesConfig, this.field),
 | 
				
			||||||
    ];
 | 
					    ];
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public buildFormTransformConfigDom() {
 | 
					  public buildFormTransformConfigDom() {
 | 
				
			||||||
    return [
 | 
					    return [
 | 
				
			||||||
      this._buildChoicesConfigDom(),
 | 
					      this.buildChoicesConfigDom(),
 | 
				
			||||||
    ];
 | 
					    ];
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -113,7 +120,7 @@ export class ChoiceTextBox extends NTextBox {
 | 
				
			|||||||
    return this.field.config.updateChoices(renames, options);
 | 
					    return this.field.config.updateChoices(renames, options);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private _buildChoicesConfigDom() {
 | 
					  protected buildChoicesConfigDom() {
 | 
				
			||||||
    const disabled = Computed.create(null,
 | 
					    const disabled = Computed.create(null,
 | 
				
			||||||
      use => use(this.field.disableModify)
 | 
					      use => use(this.field.disableModify)
 | 
				
			||||||
        || use(use(this.field.column).disableEditData)
 | 
					        || use(use(this.field.column).disableEditData)
 | 
				
			||||||
 | 
				
			|||||||
@ -6,7 +6,7 @@ var kd = require('../lib/koDom');
 | 
				
			|||||||
var kf = require('../lib/koForm');
 | 
					var kf = require('../lib/koForm');
 | 
				
			||||||
var AbstractWidget = require('./AbstractWidget');
 | 
					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 {fromKoSave} = require('app/client/lib/fromKoSave');
 | 
				
			||||||
const {alignmentSelect, cssButtonSelect} = require('app/client/ui2018/buttonSelect');
 | 
					const {alignmentSelect, cssButtonSelect} = require('app/client/ui2018/buttonSelect');
 | 
				
			||||||
const {cssLabel, cssRow} = require('app/client/ui/RightPanelStyles');
 | 
					const {cssLabel, cssRow} = require('app/client/ui/RightPanelStyles');
 | 
				
			||||||
@ -82,7 +82,7 @@ DateTextBox.prototype.buildTransformConfigDom = function() {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
DateTextBox.prototype.buildFormConfigDom = function() {
 | 
					DateTextBox.prototype.buildFormConfigDom = function() {
 | 
				
			||||||
  return [
 | 
					  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 _widgetCons: ko.Computed<{create: (...args: any[]) => NewAbstractWidget}>;
 | 
				
			||||||
  private readonly _docModel: DocModel;
 | 
					  private readonly _docModel: DocModel;
 | 
				
			||||||
  private readonly _readonly: Computed<boolean>;
 | 
					  private readonly _readonly: Computed<boolean>;
 | 
				
			||||||
 | 
					  private readonly _isForm: ko.Computed<boolean>;
 | 
				
			||||||
  private readonly _comments: ko.Computed<boolean>;
 | 
					  private readonly _comments: ko.Computed<boolean>;
 | 
				
			||||||
  private readonly _showRefConfigPopup: ko.Observable<boolean>;
 | 
					  private readonly _showRefConfigPopup: ko.Observable<boolean>;
 | 
				
			||||||
  private readonly _isEditorActive = Observable.create(this, false);
 | 
					  private readonly _isEditorActive = Observable.create(this, false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec,
 | 
					  public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec,
 | 
				
			||||||
                     private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) {
 | 
					                     private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) {
 | 
				
			||||||
    super();
 | 
					    super();
 | 
				
			||||||
@ -128,9 +127,13 @@ export class FieldBuilder extends Disposable {
 | 
				
			|||||||
    this._readonly = Computed.create(this, (use) =>
 | 
					    this._readonly = Computed.create(this, (use) =>
 | 
				
			||||||
      use(gristDoc.isReadonly) || use(field.disableEditData) || Boolean(this._options.isPreview));
 | 
					      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.
 | 
					    // Observable with a list of available types.
 | 
				
			||||||
    this._availableTypes = Computed.create(this, (use) => {
 | 
					    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 isFormula = use(this.origColumn.isFormula);
 | 
				
			||||||
      const types: Array<IOptionFull<string>> = [];
 | 
					      const types: Array<IOptionFull<string>> = [];
 | 
				
			||||||
      _.each(UserType.typeDefs, (def: any, key: string|number) => {
 | 
					      _.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.
 | 
					    // Returns the constructor for the widget, and only notifies subscribers on changes.
 | 
				
			||||||
    this._widgetCons = this.autoDispose(koUtil.withKoUtils(ko.computed(() => {
 | 
					    this._widgetCons = this.autoDispose(koUtil.withKoUtils(ko.computed(() => {
 | 
				
			||||||
      return UserTypeImpl.getWidgetConstructor(this.options().widget,
 | 
					      if (this._isForm()) {
 | 
				
			||||||
                                               this._readOnlyPureType());
 | 
					        return UserTypeImpl.getFormWidgetConstructor(this.options().widget, this._readOnlyPureType());
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        return UserTypeImpl.getWidgetConstructor(this.options().widget, this._readOnlyPureType());
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    })).onlyNotifyUnequal());
 | 
					    })).onlyNotifyUnequal());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Computed builder for the widget.
 | 
					    // 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 { fromKoSave } from 'app/client/lib/fromKoSave';
 | 
				
			||||||
 | 
					import { makeT } from 'app/client/lib/localization';
 | 
				
			||||||
import { DataRowModel } from 'app/client/models/DataRowModel';
 | 
					import { DataRowModel } from 'app/client/models/DataRowModel';
 | 
				
			||||||
import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
 | 
					import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
 | 
				
			||||||
import { cssRow } from 'app/client/ui/RightPanelStyles';
 | 
					import { fieldWithDefault } from 'app/client/models/modelUtil';
 | 
				
			||||||
import { alignmentSelect, cssButtonSelect, makeButtonSelect } from 'app/client/ui2018/buttonSelect';
 | 
					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 { testId } from 'app/client/ui2018/cssVars';
 | 
				
			||||||
import { makeLinks } from 'app/client/ui2018/links';
 | 
					import { makeLinks } from 'app/client/ui2018/links';
 | 
				
			||||||
import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget';
 | 
					import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget';
 | 
				
			||||||
import { Computed, dom, DomContents, fromKo, Observable } from 'grainjs';
 | 
					import { Computed, dom, DomContents, fromKo, Observable } from 'grainjs';
 | 
				
			||||||
import { makeT } from 'app/client/lib/localization';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const t = makeT('NTextBox');
 | 
					const t = makeT('NTextBox');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -60,8 +62,42 @@ export class NTextBox extends NewAbstractWidget {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public buildFormConfigDom(): DomContents {
 | 
					  public buildFormConfigDom(): DomContents {
 | 
				
			||||||
 | 
					    const format = fieldWithDefault<FormTextFormat>(
 | 
				
			||||||
 | 
					      this.field.widgetOptionsJson.prop('formTextFormat'),
 | 
				
			||||||
 | 
					      'singleline'
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    const lineCount = fieldWithDefault<number|"">(
 | 
				
			||||||
 | 
					      this.field.widgetOptionsJson.prop('formTextLineCount'),
 | 
				
			||||||
 | 
					      ''
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return [
 | 
					    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.
 | 
					 * 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 {makeT} from 'app/client/lib/localization';
 | 
				
			||||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
 | 
					import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
 | 
				
			||||||
import {reportError} from 'app/client/models/errors';
 | 
					import {reportError} from 'app/client/models/errors';
 | 
				
			||||||
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
 | 
					import {fieldWithDefault} from 'app/client/models/modelUtil';
 | 
				
			||||||
import {cssButtonSelect, ISelectorOption, makeButtonSelect} from 'app/client/ui2018/buttonSelect';
 | 
					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 {testId, theme} from 'app/client/ui2018/cssVars';
 | 
				
			||||||
import {icon} from 'app/client/ui2018/icons';
 | 
					 | 
				
			||||||
import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker';
 | 
					import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker';
 | 
				
			||||||
import {NTextBox} from 'app/client/widgets/NTextBox';
 | 
					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 {buildNumberFormat, NumberFormatOptions, NumMode, NumSign} from 'app/common/NumberFormat';
 | 
				
			||||||
import {BindableValue, Computed, dom, DomContents, DomElementArg,
 | 
					import {Computed, dom, DomContents, fromKo, MultiHolder, styled} from 'grainjs';
 | 
				
			||||||
        fromKo, MultiHolder, Observable, styled} from 'grainjs';
 | 
					 | 
				
			||||||
import * as LocaleCurrency from 'locale-currency';
 | 
					import * as LocaleCurrency from 'locale-currency';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
const t = makeT('NumericTextBox');
 | 
					const t = makeT('NumericTextBox');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const modeOptions: Array<ISelectorOption<NumMode>> = [
 | 
					const modeOptions: Array<ISelectorOption<NumMode>> = [
 | 
				
			||||||
  {value: 'currency', label: '$'},
 | 
					  {value: 'currency', label: '$'},
 | 
				
			||||||
  {value: 'decimal', label: ','},
 | 
					  {value: 'decimal', label: ','},
 | 
				
			||||||
@ -75,9 +77,10 @@ export class NumericTextBox extends NTextBox {
 | 
				
			|||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Prepare setters for the UI elements.
 | 
					    // Prepare setters for the UI elements.
 | 
				
			||||||
    // Min/max fraction digits may range from 0 to 20; other values are invalid.
 | 
					    // If defined, `val` will be a floating point number between 0 and 20; make sure it's
 | 
				
			||||||
    const setMinDecimals = (val?: number) => setSave('decimals', val && clamp(val, 0, 20));
 | 
					    // saved as an integer.
 | 
				
			||||||
    const setMaxDecimals = (val?: number) => setSave('maxDecimals', val && clamp(val, 0, 20));
 | 
					    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.
 | 
					    // Mode and Sign behave as toggles: clicking a selected on deselects it.
 | 
				
			||||||
    const setMode = (val: NumMode) => setSave('numMode', val !== numMode.get() ? val : undefined);
 | 
					    const setMode = (val: NumMode) => setSave('numMode', val !== numMode.get() ? val : undefined);
 | 
				
			||||||
    const setSign = (val: NumSign) => setSave('numSign', val !== numSign.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')),
 | 
					      cssLabel(t('Decimals')),
 | 
				
			||||||
      cssRow(
 | 
					      cssRow(
 | 
				
			||||||
        decimals('min', minDecimals, defaultMin, setMinDecimals, disabled, testId('numeric-min-decimals')),
 | 
					        cssNumericSpinner(
 | 
				
			||||||
        decimals('max', maxDecimals, defaultMax, setMaxDecimals, disabled, testId('numeric-max-decimals')),
 | 
					          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 {
 | 
					  public buildFormConfigDom(): DomContents {
 | 
				
			||||||
  return value !== null && value !== undefined ? Number(value) : def;
 | 
					    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.
 | 
					// 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 {};
 | 
					  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, `
 | 
					const cssModeSelect = styled(makeButtonSelect, `
 | 
				
			||||||
  flex: 4 4 0px;
 | 
					  flex: 4 4 0px;
 | 
				
			||||||
  background-color: ${theme.inputBg};
 | 
					  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 {makeT} from 'app/client/lib/localization';
 | 
				
			||||||
import {DataRowModel} from 'app/client/models/DataRowModel';
 | 
					import {DataRowModel} from 'app/client/models/DataRowModel';
 | 
				
			||||||
import {TableRec} from 'app/client/models/DocModel';
 | 
					import {TableRec} from 'app/client/models/DocModel';
 | 
				
			||||||
@ -72,7 +77,9 @@ export class Reference extends NTextBox {
 | 
				
			|||||||
  public buildFormConfigDom() {
 | 
					  public buildFormConfigDom() {
 | 
				
			||||||
    return [
 | 
					    return [
 | 
				
			||||||
      this.buildTransformConfigDom(),
 | 
					      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 {DataRowModel} from 'app/client/models/DataRowModel';
 | 
				
			||||||
import {urlState} from 'app/client/models/gristUrlState';
 | 
					import {urlState} from 'app/client/models/gristUrlState';
 | 
				
			||||||
import {testId, theme} from 'app/client/ui2018/cssVars';
 | 
					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, `
 | 
					const cssRefIcon = styled(icon, `
 | 
				
			||||||
 | 
				
			|||||||
@ -1,19 +1,44 @@
 | 
				
			|||||||
import * as commands from 'app/client/components/commands';
 | 
					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 { DataRowModel } from 'app/client/models/DataRowModel';
 | 
				
			||||||
import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
 | 
					import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
 | 
				
			||||||
import { KoSaveableObservable } from 'app/client/models/modelUtil';
 | 
					import { fieldWithDefault, KoSaveableObservable } from 'app/client/models/modelUtil';
 | 
				
			||||||
import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget';
 | 
					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 { 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.
 | 
					 * ToggleBase - The base class for toggle widgets, such as a checkbox or a switch.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
abstract class ToggleBase extends NewAbstractWidget {
 | 
					abstract class ToggleBase extends NewAbstractWidget {
 | 
				
			||||||
  public buildFormConfigDom(): DomContents {
 | 
					  public buildFormConfigDom(): DomContents {
 | 
				
			||||||
 | 
					    const format = fieldWithDefault<FormToggleFormat>(
 | 
				
			||||||
 | 
					      this.field.widgetOptionsJson.prop('formToggleFormat'),
 | 
				
			||||||
 | 
					      'switch'
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return [
 | 
					    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: {
 | 
					    widgets: {
 | 
				
			||||||
      TextBox: {
 | 
					      TextBox: {
 | 
				
			||||||
        cons: 'TextBox',
 | 
					        cons: 'TextBox',
 | 
				
			||||||
 | 
					        formCons: 'Switch',
 | 
				
			||||||
        editCons: 'TextEditor',
 | 
					        editCons: 'TextEditor',
 | 
				
			||||||
        icon: 'FieldTextbox',
 | 
					        icon: 'FieldTextbox',
 | 
				
			||||||
        options: {
 | 
					        options: {
 | 
				
			||||||
 | 
				
			|||||||
@ -65,6 +65,12 @@ export function getWidgetConstructor(widget: string, type: string): WidgetConstr
 | 
				
			|||||||
  return nameToWidget[config.cons as keyof typeof nameToWidget] as any;
 | 
					  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 */
 | 
					/** return a good class to instantiate for editing a widget/type combination */
 | 
				
			||||||
export function getEditorConstructor(widget: string, type: string): typeof NewBaseEditor {
 | 
					export function getEditorConstructor(widget: string, type: string): typeof NewBaseEditor {
 | 
				
			||||||
  const {config} = getWidgetConfiguration(widget, type as GristType);
 | 
					  const {config} = getWidgetConfiguration(widget, type as GristType);
 | 
				
			||||||
 | 
				
			|||||||
@ -175,6 +175,21 @@ export async function firstDefined<T>(...list: Array<() => Promise<T>>): Promise
 | 
				
			|||||||
  return undefined;
 | 
					  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.
 | 
					 * Parses json and returns the result, or returns defaultVal if parsing fails.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
				
			|||||||
@ -12,7 +12,13 @@ import {
 | 
				
			|||||||
  UserAction
 | 
					  UserAction
 | 
				
			||||||
} from 'app/common/DocActions';
 | 
					} from 'app/common/DocActions';
 | 
				
			||||||
import {DocData} from 'app/common/DocData';
 | 
					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 {INITIAL_FIELDS_COUNT} from "app/common/Forms";
 | 
				
			||||||
import {buildUrlId, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls";
 | 
					import {buildUrlId, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls";
 | 
				
			||||||
import {isAffirmative, safeJsonParse, timeoutReached} from "app/common/gutil";
 | 
					import {isAffirmative, safeJsonParse, timeoutReached} from "app/common/gutil";
 | 
				
			||||||
@ -260,9 +266,15 @@ export class DocWorkerApi {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    function asRecords(
 | 
					    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) => {
 | 
					      const fieldNames = Object.keys(columnData).filter((k) => {
 | 
				
			||||||
        if (k === "id") {
 | 
					        if (!opts?.includeId && k === "id") {
 | 
				
			||||||
          return false;
 | 
					          return false;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        if (
 | 
					        if (
 | 
				
			||||||
@ -1451,9 +1463,8 @@ export class DocWorkerApi {
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Cache the table reads based on tableId. We are caching only the promise, not the result.
 | 
					        // Cache the table reads based on tableId. We are caching only the promise, not the result.
 | 
				
			||||||
        const table = _.memoize(
 | 
					        const table = _.memoize((tableId: string) =>
 | 
				
			||||||
          (tableId: string) => readTable(req, activeDoc, tableId, {}, {}).then(r => asRecords(r))
 | 
					          readTable(req, activeDoc, tableId, {}, {}).then(r => asRecords(r, {includeId: true})));
 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const getTableValues = async (tableId: string, colId: string) => {
 | 
					        const getTableValues = async (tableId: string, colId: string) => {
 | 
				
			||||||
          const records = await table(tableId);
 | 
					          const records = await table(tableId);
 | 
				
			||||||
@ -1463,19 +1474,17 @@ export class DocWorkerApi {
 | 
				
			|||||||
        const Tables = activeDoc.docData.getMetaTable('_grist_Tables');
 | 
					        const Tables = activeDoc.docData.getMetaTable('_grist_Tables');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const getRefTableValues = async (col: MetaRowRecord<'_grist_Tables_column'>) => {
 | 
					        const getRefTableValues = async (col: MetaRowRecord<'_grist_Tables_column'>) => {
 | 
				
			||||||
          const refId = col.visibleCol;
 | 
					          const refTableId = getReferencedTableId(col.type);
 | 
				
			||||||
          if (!refId) { return [] as any; }
 | 
					          let refColId: string;
 | 
				
			||||||
 | 
					          if (col.visibleCol) {
 | 
				
			||||||
          const refCol = Tables_column.getRecord(refId);
 | 
					            const refCol = Tables_column.getRecord(col.visibleCol);
 | 
				
			||||||
            if (!refCol) { return []; }
 | 
					            if (!refCol) { return []; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          const refTable = Tables.getRecord(refCol.parentId);
 | 
					            refColId = refCol.colId as string;
 | 
				
			||||||
          if (!refTable) { return []; }
 | 
					          } else {
 | 
				
			||||||
 | 
					            refColId = 'id';
 | 
				
			||||||
          const refTableId = refTable.tableId as string;
 | 
					          }
 | 
				
			||||||
          const refColId = refCol.colId as string;
 | 
					          if (!refTableId || typeof refTableId !== 'string' || !refColId) { return []; }
 | 
				
			||||||
          if (!refTableId || !refColId) { return () => []; }
 | 
					 | 
				
			||||||
          if (typeof refTableId !== 'string' || typeof refColId !== 'string') { return []; }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
          const values = await getTableValues(refTableId, refColId);
 | 
					          const values = await getTableValues(refTableId, refColId);
 | 
				
			||||||
          return values.filter(([_id, value]) => !isBlankValue(value));
 | 
					          return values.filter(([_id, value]) => !isBlankValue(value));
 | 
				
			||||||
 | 
				
			|||||||
@ -5,7 +5,7 @@ import * as gu from 'test/nbrowser/gristUtils';
 | 
				
			|||||||
import {setupTestSuite} from 'test/nbrowser/testUtils';
 | 
					import {setupTestSuite} from 'test/nbrowser/testUtils';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
describe('FormView', function() {
 | 
					describe('FormView', function() {
 | 
				
			||||||
  this.timeout('90s');
 | 
					  this.timeout('2m');
 | 
				
			||||||
  gu.bigScreen();
 | 
					  gu.bigScreen();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  let api: UserAPI;
 | 
					  let api: UserAPI;
 | 
				
			||||||
@ -80,9 +80,9 @@ describe('FormView', function() {
 | 
				
			|||||||
  async function waitForConfirm() {
 | 
					  async function waitForConfirm() {
 | 
				
			||||||
    await gu.waitForServer();
 | 
					    await gu.waitForServer();
 | 
				
			||||||
    await gu.waitToPass(async () => {
 | 
					    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(
 | 
					      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.'
 | 
					        '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);
 | 
					    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() {
 | 
					  describe('on personal site', async function() {
 | 
				
			||||||
    before(async function() {
 | 
					    before(async function() {
 | 
				
			||||||
      const session = await gu.session().login();
 | 
					      const session = await gu.session().login();
 | 
				
			||||||
@ -157,7 +163,7 @@ describe('FormView', function() {
 | 
				
			|||||||
      await removeForm();
 | 
					      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');
 | 
					      const formUrl = await createFormWith('Text');
 | 
				
			||||||
      // We are in a new window.
 | 
					      // We are in a new window.
 | 
				
			||||||
      await gu.onNewTab(async () => {
 | 
					      await gu.onNewTab(async () => {
 | 
				
			||||||
@ -170,6 +176,7 @@ describe('FormView', function() {
 | 
				
			|||||||
        assert.equal(await driver.find('input[name="D"]').value(), '');
 | 
					        assert.equal(await driver.find('input[name="D"]').value(), '');
 | 
				
			||||||
        await driver.find('input[name="D"]').click();
 | 
					        await driver.find('input[name="D"]').click();
 | 
				
			||||||
        await gu.sendKeys('Hello World');
 | 
					        await gu.sendKeys('Hello World');
 | 
				
			||||||
 | 
					        await assertSubmitOnEnterIsDisabled();
 | 
				
			||||||
        await driver.find('input[type="submit"]').click();
 | 
					        await driver.find('input[type="submit"]').click();
 | 
				
			||||||
        await waitForConfirm();
 | 
					        await waitForConfirm();
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
@ -178,7 +185,32 @@ describe('FormView', function() {
 | 
				
			|||||||
      await removeForm();
 | 
					      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');
 | 
					      const formUrl = await createFormWith('Numeric');
 | 
				
			||||||
      // We are in a new window.
 | 
					      // We are in a new window.
 | 
				
			||||||
      await gu.onNewTab(async () => {
 | 
					      await gu.onNewTab(async () => {
 | 
				
			||||||
@ -191,6 +223,38 @@ describe('FormView', function() {
 | 
				
			|||||||
        assert.equal(await driver.find('input[name="D"]').value(), '');
 | 
					        assert.equal(await driver.find('input[name="D"]').value(), '');
 | 
				
			||||||
        await driver.find('input[name="D"]').click();
 | 
					        await driver.find('input[name="D"]').click();
 | 
				
			||||||
        await gu.sendKeys('1984');
 | 
					        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 driver.find('input[type="submit"]').click();
 | 
				
			||||||
        await waitForConfirm();
 | 
					        await waitForConfirm();
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
@ -212,6 +276,7 @@ describe('FormView', function() {
 | 
				
			|||||||
        assert.equal(await driver.find('input[name="D"]').getAttribute('value'), '');
 | 
					        assert.equal(await driver.find('input[name="D"]').getAttribute('value'), '');
 | 
				
			||||||
        await driver.find('input[name="D"]').click();
 | 
					        await driver.find('input[name="D"]').click();
 | 
				
			||||||
        await gu.sendKeys('01012000');
 | 
					        await gu.sendKeys('01012000');
 | 
				
			||||||
 | 
					        await assertSubmitOnEnterIsDisabled();
 | 
				
			||||||
        await driver.find('input[type="submit"]').click();
 | 
					        await driver.find('input[type="submit"]').click();
 | 
				
			||||||
        await waitForConfirm();
 | 
					        await waitForConfirm();
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
@ -220,17 +285,14 @@ describe('FormView', function() {
 | 
				
			|||||||
      await removeForm();
 | 
					      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');
 | 
					      const formUrl = await createFormWith('Choice');
 | 
				
			||||||
      // Add some options.
 | 
					      // Add some options.
 | 
				
			||||||
      await gu.openColumnPanel();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await gu.choicesEditor.edit();
 | 
					      await gu.choicesEditor.edit();
 | 
				
			||||||
      await gu.choicesEditor.add('Foo');
 | 
					      await gu.choicesEditor.add('Foo');
 | 
				
			||||||
      await gu.choicesEditor.add('Bar');
 | 
					      await gu.choicesEditor.add('Bar');
 | 
				
			||||||
      await gu.choicesEditor.add('Baz');
 | 
					      await gu.choicesEditor.add('Baz');
 | 
				
			||||||
      await gu.choicesEditor.save();
 | 
					      await gu.choicesEditor.save();
 | 
				
			||||||
      await gu.toggleSidePanel('right', 'close');
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // We need to press view, as form is not saved yet.
 | 
					      // We need to press view, as form is not saved yet.
 | 
				
			||||||
      await gu.scrollActiveViewTop();
 | 
					      await gu.scrollActiveViewTop();
 | 
				
			||||||
@ -256,6 +318,12 @@ describe('FormView', function() {
 | 
				
			|||||||
        assert.equal(await driver.find('select[name="D"]').value(), '');
 | 
					        assert.equal(await driver.find('select[name="D"]').value(), '');
 | 
				
			||||||
        await driver.find('.test-form-search-select').click();
 | 
					        await driver.find('.test-form-search-select').click();
 | 
				
			||||||
        await driver.findContent('.test-sd-searchable-list-item', 'Bar').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 driver.find('input[type="submit"]').click();
 | 
				
			||||||
        await waitForConfirm();
 | 
					        await waitForConfirm();
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
@ -263,7 +331,41 @@ describe('FormView', function() {
 | 
				
			|||||||
      await removeForm();
 | 
					      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);
 | 
					      const formUrl = await createFormWith('Integer', true);
 | 
				
			||||||
      // We are in a new window.
 | 
					      // We are in a new window.
 | 
				
			||||||
      await gu.onNewTab(async () => {
 | 
					      await gu.onNewTab(async () => {
 | 
				
			||||||
@ -276,6 +378,7 @@ describe('FormView', function() {
 | 
				
			|||||||
        assert.equal(await driver.find('input[name="D"]').value(), '');
 | 
					        assert.equal(await driver.find('input[name="D"]').value(), '');
 | 
				
			||||||
        await driver.find('input[name="D"]').click();
 | 
					        await driver.find('input[name="D"]').click();
 | 
				
			||||||
        await gu.sendKeys('1984');
 | 
					        await gu.sendKeys('1984');
 | 
				
			||||||
 | 
					        await assertSubmitOnEnterIsDisabled();
 | 
				
			||||||
        await driver.find('input[type="submit"]').click();
 | 
					        await driver.find('input[type="submit"]').click();
 | 
				
			||||||
        await waitForConfirm();
 | 
					        await waitForConfirm();
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
@ -284,7 +387,38 @@ describe('FormView', function() {
 | 
				
			|||||||
      await removeForm();
 | 
					      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);
 | 
					      const formUrl = await createFormWith('Toggle', true);
 | 
				
			||||||
      // We are in a new window.
 | 
					      // We are in a new window.
 | 
				
			||||||
      await gu.onNewTab(async () => {
 | 
					      await gu.onNewTab(async () => {
 | 
				
			||||||
@ -295,6 +429,39 @@ describe('FormView', function() {
 | 
				
			|||||||
        await driver.find('.test-modal-confirm').click();
 | 
					        await driver.find('.test-modal-confirm').click();
 | 
				
			||||||
        assert.equal(await driver.find('input[name="D"]').getAttribute('checked'), null);
 | 
					        assert.equal(await driver.find('input[name="D"]').getAttribute('checked'), null);
 | 
				
			||||||
        await driver.find('input[name="D"]').findClosest("label").click();
 | 
					        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 driver.find('input[type="submit"]').click();
 | 
				
			||||||
        await waitForConfirm();
 | 
					        await waitForConfirm();
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
@ -334,6 +501,7 @@ describe('FormView', function() {
 | 
				
			|||||||
        assert.equal(await driver.find('input[name="D[]"][value="Bar"]').getAttribute('checked'), null);
 | 
					        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="Foo"]').click();
 | 
				
			||||||
        await driver.find('input[name="D[]"][value="Baz"]').click();
 | 
					        await driver.find('input[name="D[]"][value="Baz"]').click();
 | 
				
			||||||
 | 
					        await assertSubmitOnEnterIsDisabled();
 | 
				
			||||||
        await driver.find('input[type="submit"]').click();
 | 
					        await driver.find('input[type="submit"]').click();
 | 
				
			||||||
        await waitForConfirm();
 | 
					        await waitForConfirm();
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
@ -342,7 +510,7 @@ describe('FormView', function() {
 | 
				
			|||||||
      await removeForm();
 | 
					      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);
 | 
					      const formUrl = await createFormWith('Reference', true);
 | 
				
			||||||
      // Add some options.
 | 
					      // Add some options.
 | 
				
			||||||
      await gu.openColumnPanel();
 | 
					      await gu.openColumnPanel();
 | 
				
			||||||
@ -353,22 +521,21 @@ describe('FormView', function() {
 | 
				
			|||||||
        ['AddRecord', 'Table1', null, {A: 'Bar'}], // id 2
 | 
					        ['AddRecord', 'Table1', null, {A: 'Bar'}], // id 2
 | 
				
			||||||
        ['AddRecord', 'Table1', null, {A: 'Baz'}], // id 3
 | 
					        ['AddRecord', 'Table1', null, {A: 'Baz'}], // id 3
 | 
				
			||||||
      ]);
 | 
					      ]);
 | 
				
			||||||
      await gu.toggleSidePanel('right', 'close');
 | 
					 | 
				
			||||||
      // We are in a new window.
 | 
					      // We are in a new window.
 | 
				
			||||||
      await gu.onNewTab(async () => {
 | 
					      await gu.onNewTab(async () => {
 | 
				
			||||||
        await driver.get(formUrl);
 | 
					        await driver.get(formUrl);
 | 
				
			||||||
        await driver.findWait('select[name="D"]', 2000);
 | 
					        await driver.findWait('select[name="D"]', 2000);
 | 
				
			||||||
        assert.deepEqual(
 | 
					        assert.deepEqual(
 | 
				
			||||||
          await driver.findAll('select[name="D"] option', e => e.getText()),
 | 
					          await driver.findAll('select[name="D"] option', e => e.getText()),
 | 
				
			||||||
          ['Select...', ...['Bar', 'Baz', 'Foo']]
 | 
					          ['Select...', 'Foo', 'Bar', 'Baz']
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
        assert.deepEqual(
 | 
					        assert.deepEqual(
 | 
				
			||||||
          await driver.findAll('select[name="D"] option', e => e.value()),
 | 
					          await driver.findAll('select[name="D"] option', e => e.value()),
 | 
				
			||||||
          ['', ...['2', '3', '1']]
 | 
					          ['', '1', '2', '3']
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
        await driver.find('.test-form-search-select').click();
 | 
					        await driver.find('.test-form-search-select').click();
 | 
				
			||||||
        assert.deepEqual(
 | 
					        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);
 | 
					        await gu.sendKeys('Baz', Key.ENTER);
 | 
				
			||||||
        assert.equal(await driver.find('select[name="D"]').value(), '3');
 | 
					        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(), '');
 | 
					        assert.equal(await driver.find('select[name="D"]').value(), '');
 | 
				
			||||||
        await driver.find('.test-form-search-select').click();
 | 
					        await driver.find('.test-form-search-select').click();
 | 
				
			||||||
        await driver.findContent('.test-sd-searchable-list-item', 'Bar').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 driver.find('input[type="submit"]').click();
 | 
				
			||||||
        await waitForConfirm();
 | 
					        await waitForConfirm();
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
@ -393,8 +605,6 @@ describe('FormView', function() {
 | 
				
			|||||||
    it('can submit a form with RefList field', async function() {
 | 
					    it('can submit a form with RefList field', async function() {
 | 
				
			||||||
      const formUrl = await createFormWith('Reference List', true);
 | 
					      const formUrl = await createFormWith('Reference List', true);
 | 
				
			||||||
      // Add some options.
 | 
					      // Add some options.
 | 
				
			||||||
      await gu.openColumnPanel();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await gu.setRefShowColumn('A');
 | 
					      await gu.setRefShowColumn('A');
 | 
				
			||||||
      // Add 3 records to this table (it is now empty).
 | 
					      // Add 3 records to this table (it is now empty).
 | 
				
			||||||
      await gu.sendActions([
 | 
					      await gu.sendActions([
 | 
				
			||||||
@ -416,6 +626,7 @@ describe('FormView', function() {
 | 
				
			|||||||
        assert.equal(await driver.find('input[name="D[]"][value="1"]').getAttribute('checked'), null);
 | 
					        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="1"]').click();
 | 
				
			||||||
        await driver.find('input[name="D[]"][value="2"]').click();
 | 
					        await driver.find('input[name="D[]"][value="2"]').click();
 | 
				
			||||||
 | 
					        await assertSubmitOnEnterIsDisabled();
 | 
				
			||||||
        await driver.find('input[type="submit"]').click();
 | 
					        await driver.find('input[type="submit"]').click();
 | 
				
			||||||
        await waitForConfirm();
 | 
					        await waitForConfirm();
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
@ -542,9 +753,9 @@ describe('FormView', function() {
 | 
				
			|||||||
      await gu.waitForServer();
 | 
					      await gu.waitForServer();
 | 
				
			||||||
      await gu.onNewTab(async () => {
 | 
					      await gu.onNewTab(async () => {
 | 
				
			||||||
        await driver.get(formUrl);
 | 
					        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(
 | 
					        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.'
 | 
					          'Oops! This form is no longer published.'
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
@ -739,8 +950,8 @@ describe('FormView', function() {
 | 
				
			|||||||
      // Now B is selected.
 | 
					      // Now B is selected.
 | 
				
			||||||
      assert.equal(await selectedLabel(), 'B');
 | 
					      assert.equal(await selectedLabel(), 'B');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Click on the edit button.
 | 
					      // Click the blank space above the submit button.
 | 
				
			||||||
      await driver.find('.test-forms-submit').click();
 | 
					      await driver.find('.test-forms-error').click();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Now nothing is selected.
 | 
					      // Now nothing is selected.
 | 
				
			||||||
      assert.isFalse(await isSelected(), 'Something is selected');
 | 
					      assert.isFalse(await isSelected(), 'Something is selected');
 | 
				
			||||||
@ -825,7 +1036,6 @@ describe('FormView', function() {
 | 
				
			|||||||
      assert.deepEqual(await hiddenColumns(), []);
 | 
					      assert.deepEqual(await hiddenColumns(), []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Now hide it using Delete key.
 | 
					      // Now hide it using Delete key.
 | 
				
			||||||
      await driver.find('.test-forms-submit').click();
 | 
					 | 
				
			||||||
      await question('Choice').click();
 | 
					      await question('Choice').click();
 | 
				
			||||||
      await gu.sendKeys(Key.DELETE);
 | 
					      await gu.sendKeys(Key.DELETE);
 | 
				
			||||||
      await gu.waitForServer();
 | 
					      await gu.waitForServer();
 | 
				
			||||||
@ -833,8 +1043,20 @@ describe('FormView', function() {
 | 
				
			|||||||
      // It should be hidden again.
 | 
					      // It should be hidden again.
 | 
				
			||||||
      assert.deepEqual(await hiddenColumns(), ['Choice']);
 | 
					      assert.deepEqual(await hiddenColumns(), ['Choice']);
 | 
				
			||||||
      assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
 | 
					      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');
 | 
					      await gu.toggleSidePanel('right', 'close');
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user