mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(core) Form kanban tasks
Summary: - Open all links in a new tab - Excluding not filled columns (to fix trigger formulas) - Fixed Ref/RefList submission - Removing redundant type definitions for Box - Adding header menu item - Default empty values in select control Test Plan: Updated Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D4166
This commit is contained in:
		
							parent
							
								
									007c4492dc
								
							
						
					
					
						commit
						95c0441d84
					
				@ -1,10 +1,11 @@
 | 
				
			|||||||
import {buildEditor} from 'app/client/components/Forms/Editor';
 | 
					import {buildEditor} from 'app/client/components/Forms/Editor';
 | 
				
			||||||
import {buildMenu} from 'app/client/components/Forms/Menu';
 | 
					import {buildMenu} from 'app/client/components/Forms/Menu';
 | 
				
			||||||
import {Box, BoxModel} from 'app/client/components/Forms/Model';
 | 
					import {BoxModel} from 'app/client/components/Forms/Model';
 | 
				
			||||||
import * as style from 'app/client/components/Forms/styles';
 | 
					import * as style from 'app/client/components/Forms/styles';
 | 
				
			||||||
import {makeTestId} from 'app/client/lib/domUtils';
 | 
					import {makeTestId} from 'app/client/lib/domUtils';
 | 
				
			||||||
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 {Box} from 'app/common/Forms';
 | 
				
			||||||
import {inlineStyle, not} from 'app/common/gutil';
 | 
					import {inlineStyle, not} from 'app/common/gutil';
 | 
				
			||||||
import {bundleChanges, Computed, dom, IDomArgs, MultiHolder, Observable, styled} from 'grainjs';
 | 
					import {bundleChanges, Computed, dom, IDomArgs, MultiHolder, Observable, styled} from 'grainjs';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -30,8 +31,7 @@ export class ColumnsModel extends BoxModel {
 | 
				
			|||||||
    if (!this.parent) { throw new Error('No parent'); }
 | 
					    if (!this.parent) { throw new Error('No parent'); }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // We need to remove it from the parent, so find it first.
 | 
					    // We need to remove it from the parent, so find it first.
 | 
				
			||||||
    const droppedId = dropped.id;
 | 
					    const droppedRef = dropped.id ? this.root().get(dropped.id) : null;
 | 
				
			||||||
    const droppedRef = this.root().get(droppedId);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Now we simply insert it after this box.
 | 
					    // Now we simply insert it after this box.
 | 
				
			||||||
    droppedRef?.removeSelf();
 | 
					    droppedRef?.removeSelf();
 | 
				
			||||||
@ -165,6 +165,10 @@ export class PlaceholderModel extends BoxModel {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Paragraph(text: string, alignment?: 'left'|'right'|'center'): Box {
 | 
				
			||||||
 | 
					  return {type: 'Paragraph', text, alignment};
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function Placeholder(): Box {
 | 
					export function Placeholder(): Box {
 | 
				
			||||||
  return {type: 'Placeholder'};
 | 
					  return {type: 'Placeholder'};
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -1,12 +1,13 @@
 | 
				
			|||||||
import {buildEditor} from 'app/client/components/Forms/Editor';
 | 
					import {buildEditor} from 'app/client/components/Forms/Editor';
 | 
				
			||||||
import {FormView} from 'app/client/components/Forms/FormView';
 | 
					import {FormView} from 'app/client/components/Forms/FormView';
 | 
				
			||||||
import {Box, BoxModel, ignoreClick} from 'app/client/components/Forms/Model';
 | 
					import {BoxModel, ignoreClick} from 'app/client/components/Forms/Model';
 | 
				
			||||||
import * as css from 'app/client/components/Forms/styles';
 | 
					import * as css from 'app/client/components/Forms/styles';
 | 
				
			||||||
import {stopEvent} from 'app/client/lib/domUtils';
 | 
					import {stopEvent} from 'app/client/lib/domUtils';
 | 
				
			||||||
import {refRecord} from 'app/client/models/DocModel';
 | 
					import {refRecord} from 'app/client/models/DocModel';
 | 
				
			||||||
import {autoGrow} from 'app/client/ui/forms';
 | 
					import {autoGrow} from 'app/client/ui/forms';
 | 
				
			||||||
import {squareCheckbox} from 'app/client/ui2018/checkbox';
 | 
					import {squareCheckbox} from 'app/client/ui2018/checkbox';
 | 
				
			||||||
import {colors} from 'app/client/ui2018/cssVars';
 | 
					import {colors} from 'app/client/ui2018/cssVars';
 | 
				
			||||||
 | 
					import {Box} from 'app/common/Forms';
 | 
				
			||||||
import {Constructor} from 'app/common/gutil';
 | 
					import {Constructor} from 'app/common/gutil';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  BindableValue,
 | 
					  BindableValue,
 | 
				
			||||||
@ -63,7 +64,11 @@ export class FieldModel extends BoxModel {
 | 
				
			|||||||
   * Field row id.
 | 
					   * Field row id.
 | 
				
			||||||
   */
 | 
					   */
 | 
				
			||||||
  public get leaf() {
 | 
					  public get leaf() {
 | 
				
			||||||
    return this.props['leaf'] as Observable<number>;
 | 
					    return this.prop('leaf') as Observable<number>;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public get required() {
 | 
				
			||||||
 | 
					    return this.prop('formRequired', false) as Observable<boolean|undefined>;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
@ -260,41 +265,47 @@ class TextModel extends Question {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ChoiceModel extends Question {
 | 
					class ChoiceModel extends Question {
 | 
				
			||||||
  public renderInput() {
 | 
					  protected choices: Computed<string[]> = Computed.create(this, use => {
 | 
				
			||||||
    const field = this.model.field;
 | 
					    // Read choices from field.
 | 
				
			||||||
    const choices: Computed<string[]> = Computed.create(this, use => {
 | 
					    const list = use(use(use(this.model.field).origCol).widgetOptionsJson.prop('choices')) || [];
 | 
				
			||||||
      return use(use(use(field).origCol).widgetOptionsJson.prop('choices')) || [];
 | 
					
 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
    const typedChoices = Computed.create(this, use => {
 | 
					 | 
				
			||||||
      const value = use(choices);
 | 
					 | 
				
			||||||
    // Make sure it is array of strings.
 | 
					    // Make sure it is array of strings.
 | 
				
			||||||
      if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
 | 
					    if (!Array.isArray(list) || list.some((v) => typeof v !== 'string')) {
 | 
				
			||||||
      return [];
 | 
					      return [];
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
      return value;
 | 
					    return list;
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  protected choicesWithEmpty = Computed.create(this, use => {
 | 
				
			||||||
 | 
					    const list = Array.from(use(this.choices));
 | 
				
			||||||
 | 
					    // Add empty choice if not present.
 | 
				
			||||||
 | 
					    if (list.length === 0 || list[0] !== '') {
 | 
				
			||||||
 | 
					      list.unshift('');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return list;
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public renderInput(): HTMLElement {
 | 
				
			||||||
 | 
					    const field = this.model.field;
 | 
				
			||||||
    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(field).colId)),
 | 
				
			||||||
      dom.forEach(typedChoices, (choice) => dom('option', choice, {value: choice})),
 | 
					      dom.forEach(this.choicesWithEmpty, (choice) => dom('option', choice, {value: choice})),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ChoiceListModel extends Question {
 | 
					class ChoiceListModel extends ChoiceModel {
 | 
				
			||||||
  public renderInput() {
 | 
					  public renderInput() {
 | 
				
			||||||
    const field = this.model.field;
 | 
					    const field = this.model.field;
 | 
				
			||||||
    const choices: Computed<string[]> = Computed.create(this, use => {
 | 
					 | 
				
			||||||
      return use(use(use(field).origCol).widgetOptionsJson.prop('choices')) || [];
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
    return dom('div',
 | 
					    return dom('div',
 | 
				
			||||||
      dom.prop('name', use => use(use(field).colId)),
 | 
					      dom.prop('name', use => use(use(field).colId)),
 | 
				
			||||||
      dom.forEach(choices, (choice) => css.cssCheckboxLabel(
 | 
					      dom.forEach(this.choices, (choice) => css.cssCheckboxLabel(
 | 
				
			||||||
        squareCheckbox(observable(false)),
 | 
					        squareCheckbox(observable(false)),
 | 
				
			||||||
        choice
 | 
					        choice
 | 
				
			||||||
      )),
 | 
					      )),
 | 
				
			||||||
      dom.maybe(use => use(choices).length === 0, () => [
 | 
					      dom.maybe(use => use(this.choices).length === 0, () => [
 | 
				
			||||||
        dom('div', 'No choices defined'),
 | 
					        dom('div', 'No choices defined'),
 | 
				
			||||||
      ]),
 | 
					      ]),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
@ -393,12 +404,19 @@ class RefListModel extends Question {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class RefModel extends RefListModel {
 | 
					class RefModel extends RefListModel {
 | 
				
			||||||
 | 
					  protected withEmpty = Computed.create(this, use => {
 | 
				
			||||||
 | 
					    const list = Array.from(use(this.choices));
 | 
				
			||||||
 | 
					    // Add empty choice if not present.
 | 
				
			||||||
 | 
					    list.unshift([0, '']);
 | 
				
			||||||
 | 
					    return list;
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public renderInput() {
 | 
					  public renderInput() {
 | 
				
			||||||
    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.forEach(this.choices, (choice) => dom('option', String(choice[1] ?? ''), {value: String(choice[0])})),
 | 
					      dom.forEach(this.withEmpty, (choice) => dom('option', String(choice[1] ?? ''), {value: String(choice[0])})),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -3,7 +3,7 @@ import * as commands from 'app/client/components/commands';
 | 
				
			|||||||
import {Cursor} from 'app/client/components/Cursor';
 | 
					import {Cursor} from 'app/client/components/Cursor';
 | 
				
			||||||
import * as components from 'app/client/components/Forms/elements';
 | 
					import * as components from 'app/client/components/Forms/elements';
 | 
				
			||||||
import {NewBox} from 'app/client/components/Forms/Menu';
 | 
					import {NewBox} from 'app/client/components/Forms/Menu';
 | 
				
			||||||
import {Box, BoxModel, BoxType, LayoutModel, parseBox, Place} from 'app/client/components/Forms/Model';
 | 
					import {BoxModel, LayoutModel, parseBox, Place} from 'app/client/components/Forms/Model';
 | 
				
			||||||
import * as style from 'app/client/components/Forms/styles';
 | 
					import * as style from 'app/client/components/Forms/styles';
 | 
				
			||||||
import {GristDoc} from 'app/client/components/GristDoc';
 | 
					import {GristDoc} from 'app/client/components/GristDoc';
 | 
				
			||||||
import {copyToClipboard} from 'app/client/lib/clipboardUtils';
 | 
					import {copyToClipboard} from 'app/client/lib/clipboardUtils';
 | 
				
			||||||
@ -20,7 +20,7 @@ import {showTransientTooltip} from 'app/client/ui/tooltips';
 | 
				
			|||||||
import {cssButton} from 'app/client/ui2018/buttons';
 | 
					import {cssButton} from 'app/client/ui2018/buttons';
 | 
				
			||||||
import {icon} from 'app/client/ui2018/icons';
 | 
					import {icon} from 'app/client/ui2018/icons';
 | 
				
			||||||
import {confirmModal} from 'app/client/ui2018/modals';
 | 
					import {confirmModal} from 'app/client/ui2018/modals';
 | 
				
			||||||
import {INITIAL_FIELDS_COUNT} from "app/common/Forms";
 | 
					import {Box, BoxType, INITIAL_FIELDS_COUNT} from "app/common/Forms";
 | 
				
			||||||
import {Events as BackboneEvents} from 'backbone';
 | 
					import {Events as BackboneEvents} from 'backbone';
 | 
				
			||||||
import {Computed, dom, Holder, IDomArgs, Observable} from 'grainjs';
 | 
					import {Computed, dom, Holder, IDomArgs, Observable} from 'grainjs';
 | 
				
			||||||
import defaults from 'lodash/defaults';
 | 
					import defaults from 'lodash/defaults';
 | 
				
			||||||
 | 
				
			|||||||
@ -1,12 +1,13 @@
 | 
				
			|||||||
import {allCommands} from 'app/client/components/commands';
 | 
					import {allCommands} from 'app/client/components/commands';
 | 
				
			||||||
 | 
					import * as components from 'app/client/components/Forms/elements';
 | 
				
			||||||
import {FormView} from 'app/client/components/Forms/FormView';
 | 
					import {FormView} from 'app/client/components/Forms/FormView';
 | 
				
			||||||
import {BoxModel, BoxType, Place} from 'app/client/components/Forms/Model';
 | 
					import {BoxModel, Place} from 'app/client/components/Forms/Model';
 | 
				
			||||||
import {makeTestId, stopEvent} from 'app/client/lib/domUtils';
 | 
					import {makeTestId, stopEvent} from 'app/client/lib/domUtils';
 | 
				
			||||||
import {FocusLayer} from 'app/client/lib/FocusLayer';
 | 
					import {FocusLayer} from 'app/client/lib/FocusLayer';
 | 
				
			||||||
import {makeT} from 'app/client/lib/localization';
 | 
					import {makeT} from 'app/client/lib/localization';
 | 
				
			||||||
import {getColumnTypes as getNewColumnTypes} from 'app/client/ui/GridViewMenus';
 | 
					import {getColumnTypes as getNewColumnTypes} from 'app/client/ui/GridViewMenus';
 | 
				
			||||||
import * as menus from 'app/client/ui2018/menus';
 | 
					import * as menus from 'app/client/ui2018/menus';
 | 
				
			||||||
import * as components from 'app/client/components/Forms/elements';
 | 
					import {BoxType} from 'app/common/Forms';
 | 
				
			||||||
import {Computed, dom, IDomArgs, MultiHolder} from 'grainjs';
 | 
					import {Computed, dom, IDomArgs, MultiHolder} from 'grainjs';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const t = makeT('FormView');
 | 
					const t = makeT('FormView');
 | 
				
			||||||
@ -140,8 +141,9 @@ export function buildMenu(props: Props, ...args: IDomArgs<HTMLElement>): IDomArg
 | 
				
			|||||||
          ]),
 | 
					          ]),
 | 
				
			||||||
          menus.menuDivider(),
 | 
					          menus.menuDivider(),
 | 
				
			||||||
          menus.menuSubHeader(t('Building blocks')),
 | 
					          menus.menuSubHeader(t('Building blocks')),
 | 
				
			||||||
          menus.menuItem(where(struct('Columns')), menus.menuIcon('Columns'), t("Columns")),
 | 
					          menus.menuItem(where(struct('Header')), menus.menuIcon('Headband'), t("Header")),
 | 
				
			||||||
          menus.menuItem(where(struct('Paragraph')), menus.menuIcon('Paragraph'), t("Paragraph")),
 | 
					          menus.menuItem(where(struct('Paragraph')), menus.menuIcon('Paragraph'), t("Paragraph")),
 | 
				
			||||||
 | 
					          menus.menuItem(where(struct('Columns')), menus.menuIcon('Columns'), t("Columns")),
 | 
				
			||||||
          menus.menuItem(where(struct('Separator')), menus.menuIcon('Separator'), t("Separator")),
 | 
					          menus.menuItem(where(struct('Separator')), menus.menuIcon('Separator'), t("Separator")),
 | 
				
			||||||
        ];
 | 
					        ];
 | 
				
			||||||
      };
 | 
					      };
 | 
				
			||||||
 | 
				
			|||||||
@ -1,22 +1,10 @@
 | 
				
			|||||||
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 {Box, BoxType} from 'app/common/Forms';
 | 
				
			||||||
import {bundleChanges, Computed, Disposable, dom, IDomArgs, MutableObsArray, obsArray, Observable} from 'grainjs';
 | 
					import {bundleChanges, Computed, Disposable, dom, IDomArgs, MutableObsArray, obsArray, Observable} from 'grainjs';
 | 
				
			||||||
import {v4 as uuidv4} from 'uuid';
 | 
					import {v4 as uuidv4} from 'uuid';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type Callback = () => Promise<void>;
 | 
					type Callback = () => Promise<void>;
 | 
				
			||||||
export type BoxType =   'Paragraph' | 'Section' | 'Columns' | 'Submit'
 | 
					 | 
				
			||||||
                      | 'Placeholder' | 'Layout' | 'Field' | 'Label'
 | 
					 | 
				
			||||||
                      | 'Separator'
 | 
					 | 
				
			||||||
                      ;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Box model is a JSON that represents a form element. Every element can be converted to this element and every
 | 
					 | 
				
			||||||
 * ViewModel should be able to read it and built itself from it.
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
export interface Box extends Record<string, any> {
 | 
					 | 
				
			||||||
  type: BoxType,
 | 
					 | 
				
			||||||
  children?: Array<Box>,
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * A place where to insert a box.
 | 
					 * A place where to insert a box.
 | 
				
			||||||
@ -59,10 +47,6 @@ export abstract class BoxModel extends Disposable {
 | 
				
			|||||||
   * List of children boxes.
 | 
					   * List of children boxes.
 | 
				
			||||||
   */
 | 
					   */
 | 
				
			||||||
  public children: MutableObsArray<BoxModel>;
 | 
					  public children: MutableObsArray<BoxModel>;
 | 
				
			||||||
  /**
 | 
					 | 
				
			||||||
   * Any other dynamically added properties (that are not concrete fields in the derived classes)
 | 
					 | 
				
			||||||
   */
 | 
					 | 
				
			||||||
  public props: Record<string, Observable<any>> = {};
 | 
					 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
   * Publicly exposed state if the element was just cut.
 | 
					   * Publicly exposed state if the element was just cut.
 | 
				
			||||||
   * TODO: this should be moved to FormView, as this model doesn't care about that.
 | 
					   * TODO: this should be moved to FormView, as this model doesn't care about that.
 | 
				
			||||||
@ -70,6 +54,11 @@ export abstract class BoxModel extends Disposable {
 | 
				
			|||||||
  public cut = Observable.create(this, false);
 | 
					  public cut = Observable.create(this, false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public selected: Observable<boolean>;
 | 
					  public selected: Observable<boolean>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Any other dynamically added properties (that are not concrete fields in the derived classes)
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  private _props: Record<string, Observable<any>> = {};
 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
   * Don't use it directly, use the BoxModel.new factory method instead.
 | 
					   * Don't use it directly, use the BoxModel.new factory method instead.
 | 
				
			||||||
   */
 | 
					   */
 | 
				
			||||||
@ -163,7 +152,7 @@ export abstract class BoxModel extends Disposable {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
    // We need to remove it from the parent, so find it first.
 | 
					    // We need to remove it from the parent, so find it first.
 | 
				
			||||||
    const droppedId = dropped.id;
 | 
					    const droppedId = dropped.id;
 | 
				
			||||||
    const droppedRef = this.root().get(droppedId);
 | 
					    const droppedRef = droppedId ? this.root().get(droppedId) : null;
 | 
				
			||||||
    if (droppedRef) {
 | 
					    if (droppedRef) {
 | 
				
			||||||
      droppedRef.removeSelf();
 | 
					      droppedRef.removeSelf();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -171,14 +160,14 @@ export abstract class BoxModel extends Disposable {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public prop(name: string, defaultValue?: any) {
 | 
					  public prop(name: string, defaultValue?: any) {
 | 
				
			||||||
    if (!this.props[name]) {
 | 
					    if (!this._props[name]) {
 | 
				
			||||||
      this.props[name] = Observable.create(this, defaultValue ?? null);
 | 
					      this._props[name] = Observable.create(this, defaultValue ?? null);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    return this.props[name];
 | 
					    return this._props[name];
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public hasProp(name: string) {
 | 
					  public hasProp(name: string) {
 | 
				
			||||||
    return this.props.hasOwnProperty(name);
 | 
					    return this._props.hasOwnProperty(name);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public async save(before?: () => Promise<void>): Promise<void> {
 | 
					  public async save(before?: () => Promise<void>): Promise<void> {
 | 
				
			||||||
@ -306,7 +295,8 @@ export abstract class BoxModel extends Disposable {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Update all properties of self.
 | 
					    // Update all properties of self.
 | 
				
			||||||
    for (const key in boxDef) {
 | 
					    for (const someKey in boxDef) {
 | 
				
			||||||
 | 
					      const key = someKey as keyof Box;
 | 
				
			||||||
      // Skip some keys.
 | 
					      // Skip some keys.
 | 
				
			||||||
      if (key === 'id' || key === 'type' || key === 'children') { continue; }
 | 
					      if (key === 'id' || key === 'type' || key === 'children') { continue; }
 | 
				
			||||||
      // Skip any inherited properties.
 | 
					      // Skip any inherited properties.
 | 
				
			||||||
@ -347,7 +337,7 @@ export abstract class BoxModel extends Disposable {
 | 
				
			|||||||
      id: this.id,
 | 
					      id: this.id,
 | 
				
			||||||
      type: this.type,
 | 
					      type: this.type,
 | 
				
			||||||
      children: this.children.get().map(child => child?.toJSON() || null),
 | 
					      children: this.children.get().map(child => child?.toJSON() || null),
 | 
				
			||||||
      ...(Object.fromEntries(Object.entries(this.props).map(([key, val]) => [key, val.get()]))),
 | 
					      ...(Object.fromEntries(Object.entries(this._props).map(([key, val]) => [key, val.get()]))),
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,9 +1,10 @@
 | 
				
			|||||||
import * as style from './styles';
 | 
					import * as style from './styles';
 | 
				
			||||||
import {buildEditor} from 'app/client/components/Forms/Editor';
 | 
					import {buildEditor} from 'app/client/components/Forms/Editor';
 | 
				
			||||||
import {buildMenu} from 'app/client/components/Forms/Menu';
 | 
					import {buildMenu} from 'app/client/components/Forms/Menu';
 | 
				
			||||||
import {Box, BoxModel} from 'app/client/components/Forms/Model';
 | 
					import {BoxModel} from 'app/client/components/Forms/Model';
 | 
				
			||||||
import {dom, styled} from 'grainjs';
 | 
					 | 
				
			||||||
import {makeTestId} from 'app/client/lib/domUtils';
 | 
					import {makeTestId} from 'app/client/lib/domUtils';
 | 
				
			||||||
 | 
					import {Box} from 'app/common/Forms';
 | 
				
			||||||
 | 
					import {dom, styled} from 'grainjs';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const testId = makeTestId('test-forms-');
 | 
					const testId = makeTestId('test-forms-');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -53,8 +54,7 @@ export class SectionModel extends BoxModel {
 | 
				
			|||||||
      return null;
 | 
					      return null;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    // We need to remove it from the parent, so find it first.
 | 
					    // We need to remove it from the parent, so find it first.
 | 
				
			||||||
    const droppedId = dropped.id;
 | 
					    const droppedRef = dropped.id ? this.root().get(dropped.id) : null;
 | 
				
			||||||
    const droppedRef = this.root().get(droppedId);
 | 
					 | 
				
			||||||
    if (droppedRef) {
 | 
					    if (droppedRef) {
 | 
				
			||||||
      droppedRef.removeSelf();
 | 
					      droppedRef.removeSelf();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +1,5 @@
 | 
				
			|||||||
import {Columns, Placeholder} from 'app/client/components/Forms/Columns';
 | 
					import {Columns, Paragraph, Placeholder} from 'app/client/components/Forms/Columns';
 | 
				
			||||||
import {Box, BoxType} from 'app/client/components/Forms/Model';
 | 
					import {Box, BoxType} from 'app/common/Forms';
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Add any other element you whish to use in the form here.
 | 
					 * Add any other element you whish to use in the form here.
 | 
				
			||||||
 * FormView will look for any exported BoxModel derived class in format `type` + `Model`, and use It
 | 
					 * FormView will look for any exported BoxModel derived class in format `type` + `Model`, and use It
 | 
				
			||||||
@ -16,10 +16,8 @@ export function defaultElement(type: BoxType): Box {
 | 
				
			|||||||
  switch(type) {
 | 
					  switch(type) {
 | 
				
			||||||
    case 'Columns': return Columns();
 | 
					    case 'Columns': return Columns();
 | 
				
			||||||
    case 'Placeholder': return Placeholder();
 | 
					    case 'Placeholder': return Placeholder();
 | 
				
			||||||
    case 'Separator': return {
 | 
					    case 'Separator': return Paragraph('---');
 | 
				
			||||||
      type: 'Paragraph',
 | 
					    case 'Header': return Paragraph('## **Header**', 'center');
 | 
				
			||||||
      text: '---',
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
    default: return {type};
 | 
					    default: return {type};
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -33,7 +33,3 @@ export function PERMITTED_CUSTOM_WIDGETS(): Observable<string[]> {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
  return G.window.PERMITTED_CUSTOM_WIDGETS;
 | 
					  return G.window.PERMITTED_CUSTOM_WIDGETS;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					 | 
				
			||||||
export function GRIST_FORMS_FEATURE() {
 | 
					 | 
				
			||||||
  return Boolean(getGristConfig().experimentalPlugins);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -3,7 +3,7 @@ import {GristDoc} from 'app/client/components/GristDoc';
 | 
				
			|||||||
import {makeT} from 'app/client/lib/localization';
 | 
					import {makeT} from 'app/client/lib/localization';
 | 
				
			||||||
import {reportError} from 'app/client/models/AppModel';
 | 
					import {reportError} from 'app/client/models/AppModel';
 | 
				
			||||||
import {ColumnRec, TableRec, ViewSectionRec} from 'app/client/models/DocModel';
 | 
					import {ColumnRec, TableRec, ViewSectionRec} from 'app/client/models/DocModel';
 | 
				
			||||||
import {GRIST_FORMS_FEATURE, PERMITTED_CUSTOM_WIDGETS} from "app/client/models/features";
 | 
					import {PERMITTED_CUSTOM_WIDGETS} from "app/client/models/features";
 | 
				
			||||||
import {GristTooltips} from 'app/client/ui/GristTooltips';
 | 
					import {GristTooltips} from 'app/client/ui/GristTooltips';
 | 
				
			||||||
import {linkId, NoLink} from 'app/client/ui/selectBy';
 | 
					import {linkId, NoLink} from 'app/client/ui/selectBy';
 | 
				
			||||||
import {overflowTooltip, withInfoTooltip} from 'app/client/ui/tooltips';
 | 
					import {overflowTooltip, withInfoTooltip} from 'app/client/ui/tooltips';
 | 
				
			||||||
@ -98,21 +98,17 @@ export interface IOptions extends ISelectOptions {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const testId = makeTestId('test-wselect-');
 | 
					const testId = makeTestId('test-wselect-');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function maybeForms(): Array<'form'> {
 | 
					 | 
				
			||||||
  return GRIST_FORMS_FEATURE() ? ['form'] : [];
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// The picker disables some choices that do not make much sense. This function return the list of
 | 
					// The picker disables some choices that do not make much sense. This function return the list of
 | 
				
			||||||
// compatible types given the tableId and whether user is creating a new page or not.
 | 
					// compatible types given the tableId and whether user is creating a new page or not.
 | 
				
			||||||
function getCompatibleTypes(tableId: TableRef, isNewPage: boolean|undefined): IWidgetType[] {
 | 
					function getCompatibleTypes(tableId: TableRef, isNewPage: boolean|undefined): IWidgetType[] {
 | 
				
			||||||
  if (tableId !== 'New Table') {
 | 
					  if (tableId !== 'New Table') {
 | 
				
			||||||
    return ['record', 'single', 'detail', 'chart', 'custom', 'custom.calendar', ...maybeForms()];
 | 
					    return ['record', 'single', 'detail', 'chart', 'custom', 'custom.calendar', 'form'];
 | 
				
			||||||
  } else if (isNewPage) {
 | 
					  } else if (isNewPage) {
 | 
				
			||||||
    // New view + new table means we'll be switching to the primary view.
 | 
					    // New view + new table means we'll be switching to the primary view.
 | 
				
			||||||
    return ['record', ...maybeForms()];
 | 
					    return ['record', 'form'];
 | 
				
			||||||
  } else {
 | 
					  } else {
 | 
				
			||||||
    // The type 'chart' makes little sense when creating a new table.
 | 
					    // The type 'chart' makes little sense when creating a new table.
 | 
				
			||||||
    return ['record', 'single', 'detail', ...maybeForms()];
 | 
					    return ['record', 'single', 'detail', 'form'];
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -275,7 +271,7 @@ const permittedCustomWidgets: IAttachedCustomWidget[] = PERMITTED_CUSTOM_WIDGETS
 | 
				
			|||||||
const finalListOfCustomWidgetToShow =  permittedCustomWidgets.filter(a=>
 | 
					const finalListOfCustomWidgetToShow =  permittedCustomWidgets.filter(a=>
 | 
				
			||||||
  registeredCustomWidgets.includes(a));
 | 
					  registeredCustomWidgets.includes(a));
 | 
				
			||||||
const sectionTypes: IWidgetType[] = [
 | 
					const sectionTypes: IWidgetType[] = [
 | 
				
			||||||
  'record', 'single', 'detail', ...maybeForms(), 'chart', ...finalListOfCustomWidgetToShow, 'custom'
 | 
					  'record', 'single', 'detail', 'form', 'chart', ...finalListOfCustomWidgetToShow, 'custom'
 | 
				
			||||||
];
 | 
					];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -67,7 +67,8 @@ import {
 | 
				
			|||||||
  MultiHolder,
 | 
					  MultiHolder,
 | 
				
			||||||
  Observable,
 | 
					  Observable,
 | 
				
			||||||
  styled,
 | 
					  styled,
 | 
				
			||||||
  subscribe
 | 
					  subscribe,
 | 
				
			||||||
 | 
					  toKo
 | 
				
			||||||
} from 'grainjs';
 | 
					} from 'grainjs';
 | 
				
			||||||
import * as ko from 'knockout';
 | 
					import * as ko from 'knockout';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -955,12 +956,25 @@ export class RightPanel extends Disposable {
 | 
				
			|||||||
      return vsi && vsi.activeFieldBuilder();
 | 
					      return vsi && vsi.activeFieldBuilder();
 | 
				
			||||||
    }));
 | 
					    }));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const formView = owner.autoDispose(ko.computed(() => {
 | 
					    // Sorry for the acrobatics below, but grainjs are not reentred when the active section changes.
 | 
				
			||||||
 | 
					    const viewInstance = owner.autoDispose(ko.computed(() => {
 | 
				
			||||||
      const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();
 | 
					      const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();
 | 
				
			||||||
      return (vsi ?? null) as FormView|null;
 | 
					      if (!vsi || vsi.isDisposed() || !toKo(ko, this._isForm)) { return null; }
 | 
				
			||||||
 | 
					      return vsi;
 | 
				
			||||||
    }));
 | 
					    }));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const selectedBox = Computed.create(owner, (use) => use(formView) && use(use(formView)!.selectedBox));
 | 
					    const formView = owner.autoDispose(ko.computed(() => {
 | 
				
			||||||
 | 
					      const view = viewInstance() as unknown as FormView;
 | 
				
			||||||
 | 
					      if (!view || !view.selectedBox) { return null; }
 | 
				
			||||||
 | 
					      return view;
 | 
				
			||||||
 | 
					    }));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const selectedBox = owner.autoDispose(ko.pureComputed(() => {
 | 
				
			||||||
 | 
					      const view = formView();
 | 
				
			||||||
 | 
					      if (!view) { return null; }
 | 
				
			||||||
 | 
					      const box = toKo(ko, view.selectedBox)();
 | 
				
			||||||
 | 
					      return box;
 | 
				
			||||||
 | 
					    }));
 | 
				
			||||||
    const selectedField = Computed.create(owner, (use) => {
 | 
					    const selectedField = Computed.create(owner, (use) => {
 | 
				
			||||||
      const box = use(selectedBox);
 | 
					      const box = use(selectedBox);
 | 
				
			||||||
      if (!box) { return null; }
 | 
					      if (!box) { return null; }
 | 
				
			||||||
@ -983,33 +997,38 @@ export class RightPanel extends Disposable {
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return cssSection(
 | 
					    return domAsync(imports.loadViewPane().then(() => buildConfigContainer(cssSection(
 | 
				
			||||||
      // Field config.
 | 
					      // Field config.
 | 
				
			||||||
      dom.maybe(selectedField, (field) => {
 | 
					      dom.maybeOwned(selectedField, (scope, field) => {
 | 
				
			||||||
        const requiredField = field.widgetOptionsJson.prop('formRequired');
 | 
					        const requiredField = field.widgetOptionsJson.prop('formRequired');
 | 
				
			||||||
        // V2 thing.
 | 
					        // V2 thing.
 | 
				
			||||||
        // const hiddenField = field.widgetOptionsJson.prop('formHidden');
 | 
					        // const hiddenField = field.widgetOptionsJson.prop('formHidden');
 | 
				
			||||||
        const defaultField = field.widgetOptionsJson.prop('formDefault');
 | 
					        const defaultField = field.widgetOptionsJson.prop('formDefault');
 | 
				
			||||||
        const toComputed = (obs: typeof defaultField) => {
 | 
					        const toComputed = (obs: typeof defaultField) => {
 | 
				
			||||||
          const result = Computed.create(null, (use) => use(obs));
 | 
					          const result = Computed.create(scope, (use) => use(obs));
 | 
				
			||||||
          result.onWrite(val => obs.setAndSave(val));
 | 
					          result.onWrite(val => obs.setAndSave(val));
 | 
				
			||||||
          return result;
 | 
					          return result;
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
 | 
					        const fieldTitle = field.widgetOptionsJson.prop('question');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return [
 | 
					        return [
 | 
				
			||||||
          cssLabel(t("Field title")),
 | 
					          cssLabel(t("Field title")),
 | 
				
			||||||
          cssRow(
 | 
					          cssRow(
 | 
				
			||||||
            cssTextInput(
 | 
					            cssTextInput(
 | 
				
			||||||
              fromKo(field.label),
 | 
					              fromKo(fieldTitle),
 | 
				
			||||||
              (val) => field.displayLabel.saveOnly(val),
 | 
					              (val) => fieldTitle.saveOnly(val).catch(reportError),
 | 
				
			||||||
              dom.prop('readonly', use => use(field.disableModify)),
 | 
					              dom.prop('readonly', use => use(field.disableModify)),
 | 
				
			||||||
 | 
					              dom.prop('placeholder', use => use(field.displayLabel) || use(field.colId)),
 | 
				
			||||||
 | 
					              testId('field-title'),
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
          ),
 | 
					          ),
 | 
				
			||||||
          cssLabel(t("Table column name")),
 | 
					          cssLabel(t("Table column name")),
 | 
				
			||||||
          cssRow(
 | 
					          cssRow(
 | 
				
			||||||
            cssTextInput(
 | 
					            cssTextInput(
 | 
				
			||||||
              fromKo(field.colId),
 | 
					              fromKo(field.displayLabel),
 | 
				
			||||||
              (val) => field.column().colId.saveOnly(val),
 | 
					              (val) => field.displayLabel.saveOnly(val).catch(reportError),
 | 
				
			||||||
              dom.prop('readonly', use => use(field.disableModify)),
 | 
					              dom.prop('readonly', use => use(field.disableModify)),
 | 
				
			||||||
 | 
					              testId('field-label'),
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
          ),
 | 
					          ),
 | 
				
			||||||
          // TODO: this is for V1 as it requires full cell editor here.
 | 
					          // TODO: this is for V1 as it requires full cell editor here.
 | 
				
			||||||
@ -1038,7 +1057,11 @@ export class RightPanel extends Disposable {
 | 
				
			|||||||
          ]),
 | 
					          ]),
 | 
				
			||||||
          cssSeparator(),
 | 
					          cssSeparator(),
 | 
				
			||||||
          cssLabel(t("Field rules")),
 | 
					          cssLabel(t("Field rules")),
 | 
				
			||||||
          cssRow(labeledSquareCheckbox(toComputed(requiredField), t("Required field")),),
 | 
					          cssRow(labeledSquareCheckbox(
 | 
				
			||||||
 | 
					            toComputed(requiredField),
 | 
				
			||||||
 | 
					            t("Required field"),
 | 
				
			||||||
 | 
					            testId('field-required'),
 | 
				
			||||||
 | 
					          )),
 | 
				
			||||||
          // V2 thing
 | 
					          // V2 thing
 | 
				
			||||||
          // cssRow(labeledSquareCheckbox(toComputed(hiddenField), t("Hidden field")),),
 | 
					          // cssRow(labeledSquareCheckbox(toComputed(hiddenField), t("Hidden field")),),
 | 
				
			||||||
        ];
 | 
					        ];
 | 
				
			||||||
@ -1071,7 +1094,7 @@ export class RightPanel extends Disposable {
 | 
				
			|||||||
      dom.maybe(u => !u(selectedColumn) && !u(selectedBox), () => [
 | 
					      dom.maybe(u => !u(selectedColumn) && !u(selectedBox), () => [
 | 
				
			||||||
        cssLabel(t('Layout')),
 | 
					        cssLabel(t('Layout')),
 | 
				
			||||||
      ])
 | 
					      ])
 | 
				
			||||||
    );
 | 
					    ))));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -2,7 +2,6 @@ import {hooks} from 'app/client/Hooks';
 | 
				
			|||||||
import {makeT} from 'app/client/lib/localization';
 | 
					import {makeT} from 'app/client/lib/localization';
 | 
				
			||||||
import {allCommands} from 'app/client/components/commands';
 | 
					import {allCommands} from 'app/client/components/commands';
 | 
				
			||||||
import {ViewSectionRec} from 'app/client/models/DocModel';
 | 
					import {ViewSectionRec} from 'app/client/models/DocModel';
 | 
				
			||||||
import {GRIST_FORMS_FEATURE} from 'app/client/models/features';
 | 
					 | 
				
			||||||
import {urlState} from 'app/client/models/gristUrlState';
 | 
					import {urlState} from 'app/client/models/gristUrlState';
 | 
				
			||||||
import {testId} from 'app/client/ui2018/cssVars';
 | 
					import {testId} from 'app/client/ui2018/cssVars';
 | 
				
			||||||
import {menuDivider, menuItemCmd, menuItemLink} from 'app/client/ui2018/menus';
 | 
					import {menuDivider, menuItemCmd, menuItemLink} from 'app/client/ui2018/menus';
 | 
				
			||||||
@ -96,7 +95,7 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
 | 
				
			|||||||
      menuItemCmd(allCommands.viewTabOpen, t("Widget options"), testId('widget-options')),
 | 
					      menuItemCmd(allCommands.viewTabOpen, t("Widget options"), testId('widget-options')),
 | 
				
			||||||
      menuItemCmd(allCommands.sortFilterTabOpen, t("Advanced Sort & Filter"), dom.hide(viewSection.isRecordCard)),
 | 
					      menuItemCmd(allCommands.sortFilterTabOpen, t("Advanced Sort & Filter"), dom.hide(viewSection.isRecordCard)),
 | 
				
			||||||
      menuItemCmd(allCommands.dataSelectionTabOpen, t("Data selection"), dom.hide(viewSection.isRecordCard)),
 | 
					      menuItemCmd(allCommands.dataSelectionTabOpen, t("Data selection"), dom.hide(viewSection.isRecordCard)),
 | 
				
			||||||
      !GRIST_FORMS_FEATURE() ? null : menuItemCmd(allCommands.createForm, t("Create a form"), dom.show(isTable)),
 | 
					      menuItemCmd(allCommands.createForm, t("Create a form"), dom.show(isTable)),
 | 
				
			||||||
    ]),
 | 
					    ]),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    menuDivider(dom.hide(viewSection.isRecordCard)),
 | 
					    menuDivider(dom.hide(viewSection.isRecordCard)),
 | 
				
			||||||
 | 
				
			|||||||
@ -79,6 +79,7 @@ export type IconName = "ChartArea" |
 | 
				
			|||||||
  "FunctionResult" |
 | 
					  "FunctionResult" |
 | 
				
			||||||
  "GreenArrow" |
 | 
					  "GreenArrow" |
 | 
				
			||||||
  "Grow" |
 | 
					  "Grow" |
 | 
				
			||||||
 | 
					  "Headband" |
 | 
				
			||||||
  "Heart" |
 | 
					  "Heart" |
 | 
				
			||||||
  "Help" |
 | 
					  "Help" |
 | 
				
			||||||
  "Home" |
 | 
					  "Home" |
 | 
				
			||||||
@ -234,6 +235,7 @@ export const IconList: IconName[] = ["ChartArea",
 | 
				
			|||||||
  "FunctionResult",
 | 
					  "FunctionResult",
 | 
				
			||||||
  "GreenArrow",
 | 
					  "GreenArrow",
 | 
				
			||||||
  "Grow",
 | 
					  "Grow",
 | 
				
			||||||
 | 
					  "Headband",
 | 
				
			||||||
  "Heart",
 | 
					  "Heart",
 | 
				
			||||||
  "Help",
 | 
					  "Help",
 | 
				
			||||||
  "Home",
 | 
					  "Home",
 | 
				
			||||||
 | 
				
			|||||||
@ -12,8 +12,10 @@ import {marked} from 'marked';
 | 
				
			|||||||
/**
 | 
					/**
 | 
				
			||||||
 * All allowed boxes.
 | 
					 * All allowed boxes.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' | 'Placeholder' | 'Layout' | 'Field' |
 | 
					export type BoxType =   'Paragraph' | 'Section' | 'Columns' | 'Submit'
 | 
				
			||||||
 'Label';
 | 
					                      | 'Placeholder' | 'Layout' | 'Field' | 'Label'
 | 
				
			||||||
 | 
					                      | 'Separator' | 'Header'
 | 
				
			||||||
 | 
					                      ;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Number of fields to show in the form by default.
 | 
					 * Number of fields to show in the form by default.
 | 
				
			||||||
@ -24,7 +26,7 @@ export const INITIAL_FIELDS_COUNT = 9;
 | 
				
			|||||||
 * Box model is a JSON that represents a form element. Every element can be converted to this element and every
 | 
					 * Box model is a JSON that represents a form element. Every element can be converted to this element and every
 | 
				
			||||||
 * ViewModel should be able to read it and built itself from it.
 | 
					 * ViewModel should be able to read it and built itself from it.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export interface Box extends Record<string, any> {
 | 
					export interface Box {
 | 
				
			||||||
  type: BoxType,
 | 
					  type: BoxType,
 | 
				
			||||||
  children?: Array<Box>,
 | 
					  children?: Array<Box>,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -33,6 +35,18 @@ export interface Box extends Record<string, any> {
 | 
				
			|||||||
  successURL?: string,
 | 
					  successURL?: string,
 | 
				
			||||||
  successText?: string,
 | 
					  successText?: string,
 | 
				
			||||||
  anotherResponse?: boolean,
 | 
					  anotherResponse?: boolean,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Unique ID of the field, used only in UI.
 | 
				
			||||||
 | 
					  id?: string,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Some properties used by fields and stored in the column/field.
 | 
				
			||||||
 | 
					  formRequired?: boolean,
 | 
				
			||||||
 | 
					  // Used by Label and Paragraph.
 | 
				
			||||||
 | 
					  text?: string,
 | 
				
			||||||
 | 
					  // Used by Paragraph.
 | 
				
			||||||
 | 
					  alignment?: string,
 | 
				
			||||||
 | 
					  // Used by Field.
 | 
				
			||||||
 | 
					  leaf?: number,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
@ -83,10 +97,9 @@ export class RenderBox {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
class Label extends RenderBox {
 | 
					class Label extends RenderBox {
 | 
				
			||||||
  public override async toHTML() {
 | 
					  public override async toHTML() {
 | 
				
			||||||
    const text = this.box['text'];
 | 
					    const text = this.box.text || '';
 | 
				
			||||||
    const cssClass = this.box['cssClass'] || '';
 | 
					 | 
				
			||||||
    return `
 | 
					    return `
 | 
				
			||||||
      <div class="grist-label ${cssClass}">${text || ''}</div>
 | 
					      <div class="grist-label">${text || ''}</div>
 | 
				
			||||||
    `;
 | 
					    `;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@ -160,7 +173,7 @@ class Field extends RenderBox {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public async toHTML() {
 | 
					  public async toHTML() {
 | 
				
			||||||
    const field = this.ctx.field(this.box['leaf']);
 | 
					    const field = this.box.leaf ? this.ctx.field(this.box.leaf) : null;
 | 
				
			||||||
    if (!field) {
 | 
					    if (!field) {
 | 
				
			||||||
      return `<div class="grist-field">Field not found</div>`;
 | 
					      return `<div class="grist-field">Field not found</div>`;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -232,6 +245,8 @@ class Choice extends BaseQuestion  {
 | 
				
			|||||||
  public input(field: FieldModel, context: RenderContext): string {
 | 
					  public input(field: FieldModel, context: RenderContext): string {
 | 
				
			||||||
    const required = field.options.formRequired ? 'required' : '';
 | 
					    const required = field.options.formRequired ? 'required' : '';
 | 
				
			||||||
    const choices: string[] = field.options.choices || [];
 | 
					    const choices: string[] = field.options.choices || [];
 | 
				
			||||||
 | 
					    // Insert empty option.
 | 
				
			||||||
 | 
					    choices.unshift('');
 | 
				
			||||||
    return `
 | 
					    return `
 | 
				
			||||||
      <select name='${field.colId}' ${required} >
 | 
					      <select name='${field.colId}' ${required} >
 | 
				
			||||||
        ${choices.map((choice) => `<option value='${choice}'>${choice}</option>`).join('')}
 | 
					        ${choices.map((choice) => `<option value='${choice}'>${choice}</option>`).join('')}
 | 
				
			||||||
@ -272,7 +287,7 @@ class ChoiceList extends BaseQuestion  {
 | 
				
			|||||||
    const required = field.options.formRequired ? 'required' : '';
 | 
					    const required = field.options.formRequired ? 'required' : '';
 | 
				
			||||||
    const choices: string[] = field.options.choices || [];
 | 
					    const choices: string[] = field.options.choices || [];
 | 
				
			||||||
    return `
 | 
					    return `
 | 
				
			||||||
      <div name='${field.colId}' class='grist-choice-list ${required}'>
 | 
					      <div name='${field.colId}' class='grist-choice-list grist-checkbox-list ${required}'>
 | 
				
			||||||
        ${choices.map((choice) => `
 | 
					        ${choices.map((choice) => `
 | 
				
			||||||
          <label>
 | 
					          <label>
 | 
				
			||||||
            <input type='checkbox' name='${field.colId}[]' value='${choice}' />
 | 
					            <input type='checkbox' name='${field.colId}[]' value='${choice}' />
 | 
				
			||||||
@ -288,16 +303,20 @@ class ChoiceList extends BaseQuestion  {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
class RefList extends BaseQuestion {
 | 
					class RefList extends BaseQuestion {
 | 
				
			||||||
  public async input(field: FieldModel, context: RenderContext) {
 | 
					  public async input(field: FieldModel, context: RenderContext) {
 | 
				
			||||||
 | 
					    const required = field.options.formRequired ? 'required' : '';
 | 
				
			||||||
    const choices: [number, CellValue][] = (await field.values()) ?? [];
 | 
					    const choices: [number, CellValue][] = (await field.values()) ?? [];
 | 
				
			||||||
    // 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])));
 | 
				
			||||||
    // Support for 20 choices, TODO: make it dynamic.
 | 
					    // Support for 30 choices, TODO: make it dynamic.
 | 
				
			||||||
    choices.splice(20);
 | 
					    choices.splice(30);
 | 
				
			||||||
    return `
 | 
					    return `
 | 
				
			||||||
      <div name='${field.colId}' class='grist-ref-list'>
 | 
					      <div name='${field.colId}' class='grist-ref-list grist-checkbox-list ${required}'>
 | 
				
			||||||
        ${choices.map((choice) => `
 | 
					        ${choices.map((choice) => `
 | 
				
			||||||
          <label class='grist-checkbox'>
 | 
					          <label class='grist-checkbox'>
 | 
				
			||||||
            <input type='checkbox' name='${field.colId}[]' value='${String(choice[0])}' />
 | 
					            <input type='checkbox'
 | 
				
			||||||
 | 
					                   data-grist-type='${field.type}'
 | 
				
			||||||
 | 
					                   name='${field.colId}[]'
 | 
				
			||||||
 | 
					                   value='${String(choice[0])}' />
 | 
				
			||||||
            <span>
 | 
					            <span>
 | 
				
			||||||
              ${String(choice[1] ?? '')}
 | 
					              ${String(choice[1] ?? '')}
 | 
				
			||||||
            </span>
 | 
					            </span>
 | 
				
			||||||
@ -310,14 +329,17 @@ class RefList extends BaseQuestion {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
class Ref extends BaseQuestion {
 | 
					class Ref extends BaseQuestion {
 | 
				
			||||||
  public async input(field: FieldModel) {
 | 
					  public async input(field: FieldModel) {
 | 
				
			||||||
    const choices: [number, CellValue][] = (await field.values()) ?? [];
 | 
					    const choices: [number|string, CellValue][] = (await field.values()) ?? [];
 | 
				
			||||||
    // Sort by the second value, which is the display value.
 | 
					    // 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])));
 | 
				
			||||||
    // Support for 1000 choices, TODO: make it dynamic.
 | 
					    // Support for 1000 choices, TODO: make it dynamic.
 | 
				
			||||||
    choices.splice(1000);
 | 
					    choices.splice(1000);
 | 
				
			||||||
 | 
					    // Insert empty option.
 | 
				
			||||||
 | 
					    choices.unshift(['', '']);
 | 
				
			||||||
    // <option type='number' is not standard, we parse it ourselves.
 | 
					    // <option type='number' is not standard, we parse it ourselves.
 | 
				
			||||||
 | 
					    const required = field.options.formRequired ? 'required' : '';
 | 
				
			||||||
    return `
 | 
					    return `
 | 
				
			||||||
      <select name='${field.colId}' class='grist-ref' data-grist-type='${field.type}'>
 | 
					      <select name='${field.colId}' class='grist-ref' data-grist-type='${field.type}' ${required}>
 | 
				
			||||||
        ${choices.map((choice) => `<option value='${String(choice[0])}'>${String(choice[1] ?? '')}</option>`).join('')}
 | 
					        ${choices.map((choice) => `<option value='${String(choice[0])}'>${String(choice[1] ?? '')}</option>`).join('')}
 | 
				
			||||||
      </select>
 | 
					      </select>
 | 
				
			||||||
    `;
 | 
					    `;
 | 
				
			||||||
@ -351,4 +373,8 @@ const elements = {
 | 
				
			|||||||
  'Layout': Layout,
 | 
					  'Layout': Layout,
 | 
				
			||||||
  'Field': Field,
 | 
					  'Field': Field,
 | 
				
			||||||
  'Label': Label,
 | 
					  'Label': Label,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Those are just aliases for Paragraph.
 | 
				
			||||||
 | 
					  'Separator': Paragraph,
 | 
				
			||||||
 | 
					  'Header': Paragraph,
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -166,6 +166,7 @@ handlebars.registerHelper('dompurify', (html: string) => {
 | 
				
			|||||||
  return new handlebars.SafeString(`
 | 
					  return new handlebars.SafeString(`
 | 
				
			||||||
    <script data-html="${handlebars.escapeExpression(html)}">
 | 
					    <script data-html="${handlebars.escapeExpression(html)}">
 | 
				
			||||||
      document.write(DOMPurify.sanitize(document.currentScript.getAttribute('data-html')));
 | 
					      document.write(DOMPurify.sanitize(document.currentScript.getAttribute('data-html')));
 | 
				
			||||||
 | 
					      document.currentScript.remove(); // remove the script tag so it is easier to inspect the DOM
 | 
				
			||||||
    </script>
 | 
					    </script>
 | 
				
			||||||
  `);
 | 
					  `);
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										19
									
								
								static/forms/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								static/forms/README.md
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,19 @@
 | 
				
			|||||||
 | 
					## grist-form-submit.js
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					File is taken from https://github.com/gristlabs/grist-form-submit. But it is modified to work with
 | 
				
			||||||
 | 
					forms, especially for:
 | 
				
			||||||
 | 
					- Ref and RefList columns, as by default it sends numbers as strings (FormData issue), and Grist
 | 
				
			||||||
 | 
					  doesn't know how to convert them back to numbers.
 | 
				
			||||||
 | 
					- Empty strings are not sent at all - otherwise Grist won't be able to fire trigger formulas
 | 
				
			||||||
 | 
					  correctly and provide default values for columns.
 | 
				
			||||||
 | 
					- By default it requires a redirect URL, now it is optional.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## purify.min.js
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					File taken from https://www.npmjs.com/package/dompurify. It is used to sanitize HTML. It wasn't
 | 
				
			||||||
 | 
					modified at all.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## form.html
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This is handlebars template filled by DocApi.ts
 | 
				
			||||||
@ -9,6 +9,15 @@
 | 
				
			|||||||
  </style>
 | 
					  </style>
 | 
				
			||||||
  <script src="forms/grist-form-submit.js"></script>
 | 
					  <script src="forms/grist-form-submit.js"></script>
 | 
				
			||||||
  <script src="forms/purify.min.js"></script>
 | 
					  <script src="forms/purify.min.js"></script>
 | 
				
			||||||
 | 
					  <script>
 | 
				
			||||||
 | 
					    // Make all links open in a new tab.
 | 
				
			||||||
 | 
					    DOMPurify.addHook('uponSanitizeAttribute', (node) => {
 | 
				
			||||||
 | 
					      if (!('target' in node)) { return; }
 | 
				
			||||||
 | 
					      node.setAttribute('target', '_blank');
 | 
				
			||||||
 | 
					      // Make sure that this is set explicitly, as it's often set by the browser.
 | 
				
			||||||
 | 
					      node.setAttribute('rel', 'noopener');
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  </script>
 | 
				
			||||||
  <link rel="stylesheet" href="forms/form.css">
 | 
					  <link rel="stylesheet" href="forms/form.css">
 | 
				
			||||||
  <meta name="viewport" content="width=device-width, initial-scale=1">
 | 
					  <meta name="viewport" content="width=device-width, initial-scale=1">
 | 
				
			||||||
</head>
 | 
					</head>
 | 
				
			||||||
@ -43,7 +52,7 @@
 | 
				
			|||||||
    document.querySelector('.grist-form input[type="submit"]').addEventListener('click', function(event) {
 | 
					    document.querySelector('.grist-form input[type="submit"]').addEventListener('click', function(event) {
 | 
				
			||||||
      // When submit is pressed make sure that all choice lists that are required
 | 
					      // When submit is pressed make sure that all choice lists that are required
 | 
				
			||||||
      // have at least one option selected
 | 
					      // have at least one option selected
 | 
				
			||||||
      const choiceLists = document.querySelectorAll('.grist-choice-list.required:not(:has(input:checked))');
 | 
					      const choiceLists = document.querySelectorAll('.grist-checkbox-list.required:not(:has(input:checked))');
 | 
				
			||||||
      Array.from(choiceLists).forEach(function(choiceList) {
 | 
					      Array.from(choiceLists).forEach(function(choiceList) {
 | 
				
			||||||
        // If the form has at least one checkbox make it required
 | 
					        // If the form has at least one checkbox make it required
 | 
				
			||||||
        const firstCheckbox = choiceList.querySelector('input[type="checkbox"]');
 | 
					        const firstCheckbox = choiceList.querySelector('input[type="checkbox"]');
 | 
				
			||||||
@ -51,7 +60,7 @@
 | 
				
			|||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // All other required choice lists with at least one option selected are no longer required
 | 
					      // All other required choice lists with at least one option selected are no longer required
 | 
				
			||||||
      const choiceListsRequired = document.querySelectorAll('.grist-choice-list.required:has(input:checked)');
 | 
					      const choiceListsRequired = document.querySelectorAll('.grist-checkbox-list.required:has(input:checked)');
 | 
				
			||||||
      Array.from(choiceListsRequired).forEach(function(choiceList) {
 | 
					      Array.from(choiceListsRequired).forEach(function(choiceList) {
 | 
				
			||||||
        // If the form has at least one checkbox make it required
 | 
					        // If the form has at least one checkbox make it required
 | 
				
			||||||
        const firstCheckbox = choiceList.querySelector('input[type="checkbox"]');
 | 
					        const firstCheckbox = choiceList.querySelector('input[type="checkbox"]');
 | 
				
			||||||
 | 
				
			|||||||
@ -69,7 +69,19 @@ class TypedFormData {
 | 
				
			|||||||
    this._formData = formData ?? new FormData(formElement);
 | 
					    this._formData = formData ?? new FormData(formElement);
 | 
				
			||||||
    this._formElement = formElement;
 | 
					    this._formElement = formElement;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  keys() { return this._formData.keys(); }
 | 
					  keys() {
 | 
				
			||||||
 | 
					    const keys = Array.from(this._formData.keys());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Don't return keys for scalar values which just return empty string.
 | 
				
			||||||
 | 
					    // Otherwise Grist won't fire trigger formulas.
 | 
				
			||||||
 | 
					    return keys.filter(key => {
 | 
				
			||||||
 | 
					      // If there are multiple values, return this key as it is.
 | 
				
			||||||
 | 
					      if (this._formData.getAll(key).length !== 1) { return true; }
 | 
				
			||||||
 | 
					      // If the value is empty string or null, don't return the key.
 | 
				
			||||||
 | 
					      const value = this._formData.get(key);
 | 
				
			||||||
 | 
					      return value !== '' && value !== null;
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
  type(key) {
 | 
					  type(key) {
 | 
				
			||||||
    return this._formElement?.querySelector(`[name="${key}"]`)?.getAttribute('data-grist-type');
 | 
					    return this._formElement?.querySelector(`[name="${key}"]`)?.getAttribute('data-grist-type');
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
				
			|||||||
@ -80,6 +80,7 @@
 | 
				
			|||||||
  --icon-FunctionResult: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTQsMiBMMTIsMiBDMTMuMTA0NTY5NSwyIDE0LDIuODk1NDMwNSAxNCw0IEwxNCwxMiBDMTQsMTMuMTA0NTY5NSAxMy4xMDQ1Njk1LDE0IDEyLDE0IEw0LDE0IEMyLjg5NTQzMDUsMTQgMiwxMy4xMDQ1Njk1IDIsMTIgTDIsNCBDMiwyLjg5NTQzMDUgMi44OTU0MzA1LDIgNCwyIFogTTQuNSw2IEM0LjIyMzg1NzYzLDYgNCw2LjIyMzg1NzYzIDQsNi41IEM0LDYuNzc2MTQyMzcgNC4yMjM4NTc2Myw3IDQuNSw3IEwxMS41LDcgQzExLjc3NjE0MjQsNyAxMiw2Ljc3NjE0MjM3IDEyLDYuNSBDMTIsNi4yMjM4NTc2MyAxMS43NzYxNDI0LDYgMTEuNSw2IEw0LjUsNiBaIE00LjUsOSBDNC4yMjM4NTc2Myw5IDQsOS4yMjM4NTc2MyA0LDkuNSBDNCw5Ljc3NjE0MjM3IDQuMjIzODU3NjMsMTAgNC41LDEwIEwxMS41LDEwIEMxMS43NzYxNDI0LDEwIDEyLDkuNzc2MTQyMzcgMTIsOS41IEMxMiw5LjIyMzg1NzYzIDExLjc3NjE0MjQsOSAxMS41LDkgTDQuNSw5IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
 | 
					  --icon-FunctionResult: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTQsMiBMMTIsMiBDMTMuMTA0NTY5NSwyIDE0LDIuODk1NDMwNSAxNCw0IEwxNCwxMiBDMTQsMTMuMTA0NTY5NSAxMy4xMDQ1Njk1LDE0IDEyLDE0IEw0LDE0IEMyLjg5NTQzMDUsMTQgMiwxMy4xMDQ1Njk1IDIsMTIgTDIsNCBDMiwyLjg5NTQzMDUgMi44OTU0MzA1LDIgNCwyIFogTTQuNSw2IEM0LjIyMzg1NzYzLDYgNCw2LjIyMzg1NzYzIDQsNi41IEM0LDYuNzc2MTQyMzcgNC4yMjM4NTc2Myw3IDQuNSw3IEwxMS41LDcgQzExLjc3NjE0MjQsNyAxMiw2Ljc3NjE0MjM3IDEyLDYuNSBDMTIsNi4yMjM4NTc2MyAxMS43NzYxNDI0LDYgMTEuNSw2IEw0LjUsNiBaIE00LjUsOSBDNC4yMjM4NTc2Myw5IDQsOS4yMjM4NTc2MyA0LDkuNSBDNCw5Ljc3NjE0MjM3IDQuMjIzODU3NjMsMTAgNC41LDEwIEwxMS41LDEwIEMxMS43NzYxNDI0LDEwIDEyLDkuNzc2MTQyMzcgMTIsOS41IEMxMiw5LjIyMzg1NzYzIDExLjc3NjE0MjQsOSAxMS41LDkgTDQuNSw5IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
 | 
				
			||||||
  --icon-GreenArrow: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOTQiIGhlaWdodD0iMTIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTk0IDZMODQgMC4yMjY0OTdWMTEuNzczNUw5NCA2Wk0wIDdINC43VjVIMFY3Wk0xNC4xIDdIMjMuNVY1SDE0LjFWN1pNMzIuOSA3SDQyLjNWNUgzMi45VjdaTTUxLjcgN0g2MS4xVjVINTEuN1Y3Wk03MC41IDdINzkuOVY1SDcwLjVWN1oiIGZpbGw9IiMxNkIzNzgiLz48L3N2Zz4=');
 | 
					  --icon-GreenArrow: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOTQiIGhlaWdodD0iMTIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTk0IDZMODQgMC4yMjY0OTdWMTEuNzczNUw5NCA2Wk0wIDdINC43VjVIMFY3Wk0xNC4xIDdIMjMuNVY1SDE0LjFWN1pNMzIuOSA3SDQyLjNWNUgzMi45VjdaTTUxLjcgN0g2MS4xVjVINTEuN1Y3Wk03MC41IDdINzkuOVY1SDcwLjVWN1oiIGZpbGw9IiMxNkIzNzgiLz48L3N2Zz4=');
 | 
				
			||||||
  --icon-Grow: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEuNSAxLjVMNi41IDYuNU05LjUgOS41TDE0LjUgMTQuNU03LjUgMS41SDEuNVY3LjVNMTQuNSA4LjVWMTQuNUg4LjUiIHN0cm9rZT0iIzkyOTI5OSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=');
 | 
					  --icon-Grow: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEuNSAxLjVMNi41IDYuNU05LjUgOS41TDE0LjUgMTQuNU03LjUgMS41SDEuNVY3LjVNMTQuNSA4LjVWMTQuNUg4LjUiIHN0cm9rZT0iIzkyOTI5OSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=');
 | 
				
			||||||
 | 
					  --icon-Headband: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzEwNjFfNzU4NSkiPjxwYXRoIGQ9Ik0xNCAxNkw4IDExTDIgMTZWMEgxNFYxNloiIGZpbGw9IiMwMDAiLz48L2c+PGRlZnM+PGNsaXBQYXRoIGlkPSJjbGlwMF8xMDYxXzc1ODUiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0wIDBIMTZWMTZIMHoiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4=');
 | 
				
			||||||
  --icon-Heart: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTciIGhlaWdodD0iMTciIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzg0Nl8xNTQxOSkiPjxwYXRoIGQ9Ik03LjY4NTY0IDE1Ljk5OUM2Ljg4NTY0IDE1LjI5OSAwLjI4NTY0NSA5Ljc5OTAxIDAuMjg1NjQ1IDUuNTk5MDFDMC4yODU2NDUgMi44OTkwMSAyLjQ4NTY0IDAuNzk5MDExIDUuMDg1NjQgMC43OTkwMTFDNi4yODU2NCAwLjc5OTAxMSA3LjM4NTY0IDEuMjk5MDEgOC4yODU2NCAxLjk5OTAxQzkuMTg1NjQgMS4xOTkwMSAxMC4yODU2IDAuNzk5MDExIDExLjQ4NTYgMC43OTkwMTFDMTQuMTg1NiAwLjc5OTAxMSAxNi4yODU2IDIuOTk5MDEgMTYuMjg1NiA1LjU5OTAxQzE2LjI4NTYgOS43OTkwMSA5LjY4NTY1IDE1LjI5OSA4Ljg4NTY0IDE1Ljg5OUM4LjU4NTY0IDE2LjI5OSA3Ljk4NTY0IDE2LjI5OSA3LjY4NTY0IDE1Ljk5OVpNNS4wODU2NCAyLjc5OTAxQzMuNTg1NjQgMi43OTkwMSAyLjI4NTY0IDQuMDk5MDEgMi4yODU2NCA1LjU5OTAxQzIuMjg1NjQgNy43OTkwMSA1Ljc4NTY0IDExLjU5OSA4LjI4NTY0IDEzLjc5OUMxMC4zODU2IDExLjg5OSAxNC4yODU2IDcuOTk5MDEgMTQuMjg1NiA1LjU5OTAxQzE0LjI4NTYgMy45OTkwMSAxMi45ODU2IDIuNzk5MDEgMTEuNDg1NiAyLjc5OTAxQzEwLjU4NTYgMi43OTkwMSA5LjY4NTY0IDMuMjk5MDEgOS4xODU2NCA0LjA5OTAxQzguNzg1NjQgNC42OTkwMSA3Ljg4NTY0IDQuNjk5MDEgNy40ODU2NCA0LjA5OTAxQzYuODg1NjQgMy4yOTkwMSA2LjA4NTY0IDIuNzk5MDEgNS4wODU2NCAyLjc5OTAxWiIgZmlsbD0iIzE2QjM3OCIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImNsaXAwXzg0Nl8xNTQxOSI+PHBhdGggZmlsbD0iI2ZmZiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLjI4NiAuNDk5KSIgZD0iTTAgMEgxNlYxNkgweiIvPjwvY2xpcFBhdGg+PC9kZWZzPjwvc3ZnPg==');
 | 
					  --icon-Heart: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTciIGhlaWdodD0iMTciIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzg0Nl8xNTQxOSkiPjxwYXRoIGQ9Ik03LjY4NTY0IDE1Ljk5OUM2Ljg4NTY0IDE1LjI5OSAwLjI4NTY0NSA5Ljc5OTAxIDAuMjg1NjQ1IDUuNTk5MDFDMC4yODU2NDUgMi44OTkwMSAyLjQ4NTY0IDAuNzk5MDExIDUuMDg1NjQgMC43OTkwMTFDNi4yODU2NCAwLjc5OTAxMSA3LjM4NTY0IDEuMjk5MDEgOC4yODU2NCAxLjk5OTAxQzkuMTg1NjQgMS4xOTkwMSAxMC4yODU2IDAuNzk5MDExIDExLjQ4NTYgMC43OTkwMTFDMTQuMTg1NiAwLjc5OTAxMSAxNi4yODU2IDIuOTk5MDEgMTYuMjg1NiA1LjU5OTAxQzE2LjI4NTYgOS43OTkwMSA5LjY4NTY1IDE1LjI5OSA4Ljg4NTY0IDE1Ljg5OUM4LjU4NTY0IDE2LjI5OSA3Ljk4NTY0IDE2LjI5OSA3LjY4NTY0IDE1Ljk5OVpNNS4wODU2NCAyLjc5OTAxQzMuNTg1NjQgMi43OTkwMSAyLjI4NTY0IDQuMDk5MDEgMi4yODU2NCA1LjU5OTAxQzIuMjg1NjQgNy43OTkwMSA1Ljc4NTY0IDExLjU5OSA4LjI4NTY0IDEzLjc5OUMxMC4zODU2IDExLjg5OSAxNC4yODU2IDcuOTk5MDEgMTQuMjg1NiA1LjU5OTAxQzE0LjI4NTYgMy45OTkwMSAxMi45ODU2IDIuNzk5MDEgMTEuNDg1NiAyLjc5OTAxQzEwLjU4NTYgMi43OTkwMSA5LjY4NTY0IDMuMjk5MDEgOS4xODU2NCA0LjA5OTAxQzguNzg1NjQgNC42OTkwMSA3Ljg4NTY0IDQuNjk5MDEgNy40ODU2NCA0LjA5OTAxQzYuODg1NjQgMy4yOTkwMSA2LjA4NTY0IDIuNzk5MDEgNS4wODU2NCAyLjc5OTAxWiIgZmlsbD0iIzE2QjM3OCIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImNsaXAwXzg0Nl8xNTQxOSI+PHBhdGggZmlsbD0iI2ZmZiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLjI4NiAuNDk5KSIgZD0iTTAgMEgxNlYxNkgweiIvPjwvY2xpcFBhdGg+PC9kZWZzPjwvc3ZnPg==');
 | 
				
			||||||
  --icon-Help: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNzA5MjY0NDMsMy40MTYzNzEyMSBDMS42NDQ0MDc2OSw0LjY0NDQzMjIyIDEsNi4yNDY5NjEzNyAxLDggQzEsOS43NTMwMzg2MyAxLjY0NDQwNzY5LDExLjM1NTU2NzggMi43MDkyNjQ0MywxMi41ODM2Mjg4IEw0Ljg0MDA3MTI5LDEwLjQ1MjgyMTkgQzQuMzEzNTQwOCw5Ljc3NTQ4MDA3IDQsOC45MjQzNTU3MyA0LDggQzQsNy4wNzU2NDQyNyA0LjMxMzU0MDgsNi4yMjQ1MTk5MyA0Ljg0MDA3MTI5LDUuNTQ3MTc4MDcgTDIuNzA5MjY0NDMsMy40MTYzNzEyMSBaIE0zLjQxNjM3MTIxLDIuNzA5MjY0NDMgTDUuNTQ3MTc4MDcsNC44NDAwNzEyOSBDNi4yMjQ1MTk5Myw0LjMxMzU0MDggNy4wNzU2NDQyNyw0IDgsNCBDOC45MjQzNTU3Myw0IDkuNzc1NDgwMDcsNC4zMTM1NDA4IDEwLjQ1MjgyMTksNC44NDAwNzEyOSBMMTIuNTgzNjI4OCwyLjcwOTI2NDQzIEMxMS4zNTU1Njc4LDEuNjQ0NDA3NjkgOS43NTMwMzg2MywxIDgsMSBDNi4yNDY5NjEzNywxIDQuNjQ0NDMyMjIsMS42NDQ0MDc2OSAzLjQxNjM3MTIxLDIuNzA5MjY0NDMgWiBNNS45MDE5ODczMSw1Ljg1NTYyMzc2IEM1Ljg5NDgyMDAxLDUuODYzNzgzMDcgNS44ODczNDIwNCw1Ljg3MTc2NDc0IDUuODc5NTUzMzksNS44Nzk1NTMzOSBDNS44NzE3NjQ3NCw1Ljg4NzM0MjA0IDUuODYzNzgzMDcsNS44OTQ4MjAwMSA1Ljg1NTYyMzc2LDUuOTAxOTg3MzEgQzUuMzI2Mjk1NSw2LjQ0MjkzOTMyIDUsNy4xODMzNjQ0NiA1LDggQzUsOC44MTY2MzU1NCA1LjMyNjI5NTUsOS41NTcwNjA2OCA1Ljg1NTYyMzc2LDEwLjA5ODAxMjcgQzUuODYzNzgzMDcsMTAuMTA1MTggNS44NzE3NjQ3NCwxMC4xMTI2NTggNS44Nzk1NTMzOSwxMC4xMjA0NDY2IEM1Ljg4NzM0MjA0LDEwLjEyODIzNTMgNS44OTQ4MjAwMSwxMC4xMzYyMTY5IDUuOTAxOTg3MzEsMTAuMTQ0Mzc2MiBDNi40NDI5MzkzMiwxMC42NzM3MDQ1IDcuMTgzMzY0NDYsMTEgOCwxMSBDOC44MTY2MzU1NCwxMSA5LjU1NzA2MDY4LDEwLjY3MzcwNDUgMTAuMDk4MDEyNywxMC4xNDQzNzYyIEMxMC4xMDUxOCwxMC4xMzYyMTY5IDEwLjExMjY1OCwxMC4xMjgyMzUzIDEwLjEyMDQ0NjYsMTAuMTIwNDQ2NiBDMTAuMTI4MjM1MywxMC4xMTI2NTggMTAuMTM2MjE2OSwxMC4xMDUxOCAxMC4xNDQzNzYyLDEwLjA5ODAxMjcgQzEwLjY3MzcwNDUsOS41NTcwNjA2OCAxMSw4LjgxNjYzNTU0IDExLDggQzExLDcuMTgzMzY0NDYgMTAuNjczNzA0NSw2LjQ0MjkzOTMyIDEwLjE0NDM3NjIsNS45MDE5ODczMSBDMTAuMTM2MjE2OSw1Ljg5NDgyMDAxIDEwLjEyODIzNTMsNS44ODczNDIwNCAxMC4xMjA0NDY2LDUuODc5NTUzMzkgQzEwLjExMjY1OCw1Ljg3MTc2NDc0IDEwLjEwNTE4LDUuODYzNzgzMDcgMTAuMDk4MDEyNyw1Ljg1NTYyMzc2IEM5LjU1NzA2MDY4LDUuMzI2Mjk1NSA4LjgxNjYzNTU0LDUgOCw1IEM3LjE4MzM2NDQ2LDUgNi40NDI5MzkzMiw1LjMyNjI5NTUgNS45MDE5ODczMSw1Ljg1NTYyMzc2IFogTTMuNDE2MzcxMjEsMTMuMjkwNzM1NiBDNC42NDQ0MzIyMiwxNC4zNTU1OTIzIDYuMjQ2OTYxMzcsMTUgOCwxNSBDOS43NTMwMzg2MywxNSAxMS4zNTU1Njc4LDE0LjM1NTU5MjMgMTIuNTgzNjI4OCwxMy4yOTA3MzU2IEwxMC40NTI4MjE5LDExLjE1OTkyODcgQzkuNzc1NDgwMDcsMTEuNjg2NDU5MiA4LjkyNDM1NTczLDEyIDgsMTIgQzcuMDc1NjQ0MjcsMTIgNi4yMjQ1MTk5MywxMS42ODY0NTkyIDUuNTQ3MTc4MDcsMTEuMTU5OTI4NyBMMy40MTYzNzEyMSwxMy4yOTA3MzU2IFogTTEzLjI5MDczNTYsMTIuNTgzNjI4OCBDMTQuMzU1NTkyMywxMS4zNTU1Njc4IDE1LDkuNzUzMDM4NjMgMTUsOCBDMTUsNi4yNDY5NjEzNyAxNC4zNTU1OTIzLDQuNjQ0NDMyMjIgMTMuMjkwNzM1NiwzLjQxNjM3MTIxIEwxMS4xNTk5Mjg3LDUuNTQ3MTc4MDcgQzExLjY4NjQ1OTIsNi4yMjQ1MTk5MyAxMiw3LjA3NTY0NDI3IDEyLDggQzEyLDguOTI0MzU1NzMgMTEuNjg2NDU5Miw5Ljc3NTQ4MDA3IDExLjE1OTkyODcsMTAuNDUyODIxOSBMMTMuMjkwNzM1NiwxMi41ODM2Mjg4IFogTTgsMTYgQzMuNTgxNzIyLDE2IDAsMTIuNDE4Mjc4IDAsOCBDMCwzLjU4MTcyMiAzLjU4MTcyMiwwIDgsMCBDMTIuNDE4Mjc4LDAgMTYsMy41ODE3MjIgMTYsOCBDMTYsMTIuNDE4Mjc4IDEyLjQxODI3OCwxNiA4LDE2IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
 | 
					  --icon-Help: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNzA5MjY0NDMsMy40MTYzNzEyMSBDMS42NDQ0MDc2OSw0LjY0NDQzMjIyIDEsNi4yNDY5NjEzNyAxLDggQzEsOS43NTMwMzg2MyAxLjY0NDQwNzY5LDExLjM1NTU2NzggMi43MDkyNjQ0MywxMi41ODM2Mjg4IEw0Ljg0MDA3MTI5LDEwLjQ1MjgyMTkgQzQuMzEzNTQwOCw5Ljc3NTQ4MDA3IDQsOC45MjQzNTU3MyA0LDggQzQsNy4wNzU2NDQyNyA0LjMxMzU0MDgsNi4yMjQ1MTk5MyA0Ljg0MDA3MTI5LDUuNTQ3MTc4MDcgTDIuNzA5MjY0NDMsMy40MTYzNzEyMSBaIE0zLjQxNjM3MTIxLDIuNzA5MjY0NDMgTDUuNTQ3MTc4MDcsNC44NDAwNzEyOSBDNi4yMjQ1MTk5Myw0LjMxMzU0MDggNy4wNzU2NDQyNyw0IDgsNCBDOC45MjQzNTU3Myw0IDkuNzc1NDgwMDcsNC4zMTM1NDA4IDEwLjQ1MjgyMTksNC44NDAwNzEyOSBMMTIuNTgzNjI4OCwyLjcwOTI2NDQzIEMxMS4zNTU1Njc4LDEuNjQ0NDA3NjkgOS43NTMwMzg2MywxIDgsMSBDNi4yNDY5NjEzNywxIDQuNjQ0NDMyMjIsMS42NDQ0MDc2OSAzLjQxNjM3MTIxLDIuNzA5MjY0NDMgWiBNNS45MDE5ODczMSw1Ljg1NTYyMzc2IEM1Ljg5NDgyMDAxLDUuODYzNzgzMDcgNS44ODczNDIwNCw1Ljg3MTc2NDc0IDUuODc5NTUzMzksNS44Nzk1NTMzOSBDNS44NzE3NjQ3NCw1Ljg4NzM0MjA0IDUuODYzNzgzMDcsNS44OTQ4MjAwMSA1Ljg1NTYyMzc2LDUuOTAxOTg3MzEgQzUuMzI2Mjk1NSw2LjQ0MjkzOTMyIDUsNy4xODMzNjQ0NiA1LDggQzUsOC44MTY2MzU1NCA1LjMyNjI5NTUsOS41NTcwNjA2OCA1Ljg1NTYyMzc2LDEwLjA5ODAxMjcgQzUuODYzNzgzMDcsMTAuMTA1MTggNS44NzE3NjQ3NCwxMC4xMTI2NTggNS44Nzk1NTMzOSwxMC4xMjA0NDY2IEM1Ljg4NzM0MjA0LDEwLjEyODIzNTMgNS44OTQ4MjAwMSwxMC4xMzYyMTY5IDUuOTAxOTg3MzEsMTAuMTQ0Mzc2MiBDNi40NDI5MzkzMiwxMC42NzM3MDQ1IDcuMTgzMzY0NDYsMTEgOCwxMSBDOC44MTY2MzU1NCwxMSA5LjU1NzA2MDY4LDEwLjY3MzcwNDUgMTAuMDk4MDEyNywxMC4xNDQzNzYyIEMxMC4xMDUxOCwxMC4xMzYyMTY5IDEwLjExMjY1OCwxMC4xMjgyMzUzIDEwLjEyMDQ0NjYsMTAuMTIwNDQ2NiBDMTAuMTI4MjM1MywxMC4xMTI2NTggMTAuMTM2MjE2OSwxMC4xMDUxOCAxMC4xNDQzNzYyLDEwLjA5ODAxMjcgQzEwLjY3MzcwNDUsOS41NTcwNjA2OCAxMSw4LjgxNjYzNTU0IDExLDggQzExLDcuMTgzMzY0NDYgMTAuNjczNzA0NSw2LjQ0MjkzOTMyIDEwLjE0NDM3NjIsNS45MDE5ODczMSBDMTAuMTM2MjE2OSw1Ljg5NDgyMDAxIDEwLjEyODIzNTMsNS44ODczNDIwNCAxMC4xMjA0NDY2LDUuODc5NTUzMzkgQzEwLjExMjY1OCw1Ljg3MTc2NDc0IDEwLjEwNTE4LDUuODYzNzgzMDcgMTAuMDk4MDEyNyw1Ljg1NTYyMzc2IEM5LjU1NzA2MDY4LDUuMzI2Mjk1NSA4LjgxNjYzNTU0LDUgOCw1IEM3LjE4MzM2NDQ2LDUgNi40NDI5MzkzMiw1LjMyNjI5NTUgNS45MDE5ODczMSw1Ljg1NTYyMzc2IFogTTMuNDE2MzcxMjEsMTMuMjkwNzM1NiBDNC42NDQ0MzIyMiwxNC4zNTU1OTIzIDYuMjQ2OTYxMzcsMTUgOCwxNSBDOS43NTMwMzg2MywxNSAxMS4zNTU1Njc4LDE0LjM1NTU5MjMgMTIuNTgzNjI4OCwxMy4yOTA3MzU2IEwxMC40NTI4MjE5LDExLjE1OTkyODcgQzkuNzc1NDgwMDcsMTEuNjg2NDU5MiA4LjkyNDM1NTczLDEyIDgsMTIgQzcuMDc1NjQ0MjcsMTIgNi4yMjQ1MTk5MywxMS42ODY0NTkyIDUuNTQ3MTc4MDcsMTEuMTU5OTI4NyBMMy40MTYzNzEyMSwxMy4yOTA3MzU2IFogTTEzLjI5MDczNTYsMTIuNTgzNjI4OCBDMTQuMzU1NTkyMywxMS4zNTU1Njc4IDE1LDkuNzUzMDM4NjMgMTUsOCBDMTUsNi4yNDY5NjEzNyAxNC4zNTU1OTIzLDQuNjQ0NDMyMjIgMTMuMjkwNzM1NiwzLjQxNjM3MTIxIEwxMS4xNTk5Mjg3LDUuNTQ3MTc4MDcgQzExLjY4NjQ1OTIsNi4yMjQ1MTk5MyAxMiw3LjA3NTY0NDI3IDEyLDggQzEyLDguOTI0MzU1NzMgMTEuNjg2NDU5Miw5Ljc3NTQ4MDA3IDExLjE1OTkyODcsMTAuNDUyODIxOSBMMTMuMjkwNzM1NiwxMi41ODM2Mjg4IFogTTgsMTYgQzMuNTgxNzIyLDE2IDAsMTIuNDE4Mjc4IDAsOCBDMCwzLjU4MTcyMiAzLjU4MTcyMiwwIDgsMCBDMTIuNDE4Mjc4LDAgMTYsMy41ODE3MjIgMTYsOCBDMTYsMTIuNDE4Mjc4IDEyLjQxODI3OCwxNiA4LDE2IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
 | 
				
			||||||
  --icon-Home: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsMi4wODMwOTUxOSBMMS40NzE4MjUzMiw2IEw4LDkuOTE2OTA0ODEgTDE0LjUyODE3NDcsNiBMOCwyLjA4MzA5NTE5IFogTTguMjU3MjQ3ODgsMS4wNzEyNTM1NCBMMTUuNzU3MjQ3OSw1LjU3MTI1MzU0IEMxNi4wODA5MTc0LDUuNzY1NDU1MjMgMTYuMDgwOTE3NCw2LjIzNDU0NDc3IDE1Ljc1NzI0NzksNi40Mjg3NDY0NiBMOC4yNTcyNDc4OCwxMC45Mjg3NDY1IEM4LjA5ODkwNjY4LDExLjAyMzc1MTIgNy45MDEwOTMzMiwxMS4wMjM3NTEyIDcuNzQyNzUyMTIsMTAuOTI4NzQ2NSBMMC4yNDI3NTIxMjIsNi40Mjg3NDY0NiBDLTAuMDgwOTE3Mzc0MSw2LjIzNDU0NDc3IC0wLjA4MDkxNzM3NDEsNS43NjU0NTUyMyAwLjI0Mjc1MjEyMiw1LjU3MTI1MzU0IEw3Ljc0Mjc1MjEyLDEuMDcxMjUzNTQgQzcuOTAxMDkzMzIsMC45NzYyNDg4MjEgOC4wOTg5MDY2OCwwLjk3NjI0ODgyMSA4LjI1NzI0Nzg4LDEuMDcxMjUzNTQgWiBNMTQuNTI4MTc0NywxMCBMMTMuNzQyNzUyMSw5LjUyODc0NjQ2IEMxMy41MDU5NjIsOS4zODY2NzIzOCAxMy40MjkxNzk1LDkuMDc5NTQyMjYgMTMuNTcxMjUzNSw4Ljg0Mjc1MjEyIEMxMy43MTMzMjc2LDguNjA1OTYxOTkgMTQuMDIwNDU3Nyw4LjUyOTE3OTQ2IDE0LjI1NzI0NzksOC42NzEyNTM1NCBMMTUuNzU3MjQ3OSw5LjU3MTI1MzU0IEMxNi4wODA5MTc0LDkuNzY1NDU1MjMgMTYuMDgwOTE3NCwxMC4yMzQ1NDQ4IDE1Ljc1NzI0NzksMTAuNDI4NzQ2NSBMOC4yNTcyNDc4OCwxNC45Mjg3NDY1IEM4LjA5ODkwNjY4LDE1LjAyMzc1MTIgNy45MDEwOTMzMiwxNS4wMjM3NTEyIDcuNzQyNzUyMTIsMTQuOTI4NzQ2NSBMMC4yNDI3NTIxMjIsMTAuNDI4NzQ2NSBDLTAuMDgwOTE3Mzc0MSwxMC4yMzQ1NDQ4IC0wLjA4MDkxNzM3NDEsOS43NjU0NTUyMyAwLjI0Mjc1MjEyMiw5LjU3MTI1MzU0IEwxLjc0Mjc1MjEyLDguNjcxMjUzNTQgQzEuOTc5NTQyMjYsOC41MjkxNzk0NiAyLjI4NjY3MjM4LDguNjA1OTYxOTkgMi40Mjg3NDY0Niw4Ljg0Mjc1MjEyIEMyLjU3MDgyMDU0LDkuMDc5NTQyMjYgMi40OTQwMzgwMSw5LjM4NjY3MjM4IDIuMjU3MjQ3ODgsOS41Mjg3NDY0NiBMMS40NzE4MjUzMiwxMCBMOCwxMy45MTY5MDQ4IEwxNC41MjgxNzQ3LDEwIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
 | 
					  --icon-Home: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsMi4wODMwOTUxOSBMMS40NzE4MjUzMiw2IEw4LDkuOTE2OTA0ODEgTDE0LjUyODE3NDcsNiBMOCwyLjA4MzA5NTE5IFogTTguMjU3MjQ3ODgsMS4wNzEyNTM1NCBMMTUuNzU3MjQ3OSw1LjU3MTI1MzU0IEMxNi4wODA5MTc0LDUuNzY1NDU1MjMgMTYuMDgwOTE3NCw2LjIzNDU0NDc3IDE1Ljc1NzI0NzksNi40Mjg3NDY0NiBMOC4yNTcyNDc4OCwxMC45Mjg3NDY1IEM4LjA5ODkwNjY4LDExLjAyMzc1MTIgNy45MDEwOTMzMiwxMS4wMjM3NTEyIDcuNzQyNzUyMTIsMTAuOTI4NzQ2NSBMMC4yNDI3NTIxMjIsNi40Mjg3NDY0NiBDLTAuMDgwOTE3Mzc0MSw2LjIzNDU0NDc3IC0wLjA4MDkxNzM3NDEsNS43NjU0NTUyMyAwLjI0Mjc1MjEyMiw1LjU3MTI1MzU0IEw3Ljc0Mjc1MjEyLDEuMDcxMjUzNTQgQzcuOTAxMDkzMzIsMC45NzYyNDg4MjEgOC4wOTg5MDY2OCwwLjk3NjI0ODgyMSA4LjI1NzI0Nzg4LDEuMDcxMjUzNTQgWiBNMTQuNTI4MTc0NywxMCBMMTMuNzQyNzUyMSw5LjUyODc0NjQ2IEMxMy41MDU5NjIsOS4zODY2NzIzOCAxMy40MjkxNzk1LDkuMDc5NTQyMjYgMTMuNTcxMjUzNSw4Ljg0Mjc1MjEyIEMxMy43MTMzMjc2LDguNjA1OTYxOTkgMTQuMDIwNDU3Nyw4LjUyOTE3OTQ2IDE0LjI1NzI0NzksOC42NzEyNTM1NCBMMTUuNzU3MjQ3OSw5LjU3MTI1MzU0IEMxNi4wODA5MTc0LDkuNzY1NDU1MjMgMTYuMDgwOTE3NCwxMC4yMzQ1NDQ4IDE1Ljc1NzI0NzksMTAuNDI4NzQ2NSBMOC4yNTcyNDc4OCwxNC45Mjg3NDY1IEM4LjA5ODkwNjY4LDE1LjAyMzc1MTIgNy45MDEwOTMzMiwxNS4wMjM3NTEyIDcuNzQyNzUyMTIsMTQuOTI4NzQ2NSBMMC4yNDI3NTIxMjIsMTAuNDI4NzQ2NSBDLTAuMDgwOTE3Mzc0MSwxMC4yMzQ1NDQ4IC0wLjA4MDkxNzM3NDEsOS43NjU0NTUyMyAwLjI0Mjc1MjEyMiw5LjU3MTI1MzU0IEwxLjc0Mjc1MjEyLDguNjcxMjUzNTQgQzEuOTc5NTQyMjYsOC41MjkxNzk0NiAyLjI4NjY3MjM4LDguNjA1OTYxOTkgMi40Mjg3NDY0Niw4Ljg0Mjc1MjEyIEMyLjU3MDgyMDU0LDkuMDc5NTQyMjYgMi40OTQwMzgwMSw5LjM4NjY3MjM4IDIuMjU3MjQ3ODgsOS41Mjg3NDY0NiBMMS40NzE4MjUzMiwxMCBMOCwxMy45MTY5MDQ4IEwxNC41MjgxNzQ3LDEwIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										10
									
								
								static/ui-icons/UI/Headband.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								static/ui-icons/UI/Headband.svg
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
 | 
				
			||||||
 | 
					<g clip-path="url(#clip0_1061_7585)">
 | 
				
			||||||
 | 
					<path d="M14 16L8 11L2 16V0H14V16Z" fill="black"/>
 | 
				
			||||||
 | 
					</g>
 | 
				
			||||||
 | 
					<defs>
 | 
				
			||||||
 | 
					<clipPath id="clip0_1061_7585">
 | 
				
			||||||
 | 
					<rect width="16" height="16" fill="white"/>
 | 
				
			||||||
 | 
					</clipPath>
 | 
				
			||||||
 | 
					</defs>
 | 
				
			||||||
 | 
					</svg>
 | 
				
			||||||
| 
		 After Width: | Height: | Size: 300 B  | 
@ -60,6 +60,7 @@ describe('FormView', function() {
 | 
				
			|||||||
  async function createFormWith(type: string, more = false) {
 | 
					  async function createFormWith(type: string, more = false) {
 | 
				
			||||||
    await gu.addNewSection('Form', 'Table1');
 | 
					    await gu.addNewSection('Form', 'Table1');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Make sure column D is not there.
 | 
				
			||||||
    assert.isUndefined(await api.getTable(docId, 'Table1').then(t => t.D));
 | 
					    assert.isUndefined(await api.getTable(docId, 'Table1').then(t => t.D));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Add a text question
 | 
					    // Add a text question
 | 
				
			||||||
@ -117,10 +118,62 @@ describe('FormView', function() {
 | 
				
			|||||||
    assert.deepEqual(await api.getTable(docId, 'Table1').then(t => t.D), [value]);
 | 
					    assert.deepEqual(await api.getTable(docId, 'Table1').then(t => t.D), [value]);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async function expect(values: any[]) {
 | 
					  async function expectInD(values: any[]) {
 | 
				
			||||||
    assert.deepEqual(await api.getTable(docId, 'Table1').then(t => t.D), values);
 | 
					    assert.deepEqual(await api.getTable(docId, 'Table1').then(t => t.D), values);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('updates creator panel when navigated away', async function() {
 | 
				
			||||||
 | 
					    // Add 2 new pages.
 | 
				
			||||||
 | 
					    await gu.addNewPage('Form', 'New Table', {tableName: 'TabA'});
 | 
				
			||||||
 | 
					    await gu.renamePage('TabA');
 | 
				
			||||||
 | 
					    await gu.addNewPage('Form', 'New Table', {tableName: 'TabB'});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Open the creator panel on field tab
 | 
				
			||||||
 | 
					    await gu.openColumnPanel();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Select A column
 | 
				
			||||||
 | 
					    await question('A').click();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Make sure it is selected.
 | 
				
			||||||
 | 
					    assert.equal(await selectedLabel(), 'A');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // And creator panel reflects it.
 | 
				
			||||||
 | 
					    assert.equal(await driver.find('.test-field-label').value(), "A");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Now switch to page TabA.
 | 
				
			||||||
 | 
					    await gu.openPage('TabA');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // And select B column.
 | 
				
			||||||
 | 
					    await question('B').click();
 | 
				
			||||||
 | 
					    assert.equal(await selectedLabel(), 'B');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Make sure creator panel reflects it (it didn't).
 | 
				
			||||||
 | 
					    assert.equal(await driver.find('.test-field-label').value(), "B");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await gu.undo(2); // There was a bug with second undo.
 | 
				
			||||||
 | 
					    await gu.undo();
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('triggers trigger formulas', async function() {
 | 
				
			||||||
 | 
					    const formUrl = await createFormWith('Text');
 | 
				
			||||||
 | 
					    // Add a trigger formula for this column.
 | 
				
			||||||
 | 
					    await gu.showRawData();
 | 
				
			||||||
 | 
					    await gu.getCell('D', 1).click();
 | 
				
			||||||
 | 
					    await gu.openColumnPanel();
 | 
				
			||||||
 | 
					    await driver.find(".test-field-set-trigger").click();
 | 
				
			||||||
 | 
					    await gu.waitAppFocus(false);
 | 
				
			||||||
 | 
					    await gu.sendKeys('"Hello from trigger"', Key.ENTER);
 | 
				
			||||||
 | 
					    await gu.waitForServer();
 | 
				
			||||||
 | 
					    await gu.closeRawTable();
 | 
				
			||||||
 | 
					    await gu.onNewTab(async () => {
 | 
				
			||||||
 | 
					      await driver.get(formUrl);
 | 
				
			||||||
 | 
					      await driver.find('input[type="submit"]').click();
 | 
				
			||||||
 | 
					      await waitForConfirm();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    await expectSingle('Hello from trigger');
 | 
				
			||||||
 | 
					    await removeForm();
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  it('can submit a form with Text field', async function() {
 | 
					  it('can submit a form with 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.
 | 
				
			||||||
@ -189,7 +242,7 @@ describe('FormView', function() {
 | 
				
			|||||||
    await gu.onNewTab(async () => {
 | 
					    await gu.onNewTab(async () => {
 | 
				
			||||||
      await driver.get(formUrl);
 | 
					      await driver.get(formUrl);
 | 
				
			||||||
      // Make sure options are there.
 | 
					      // Make sure options are there.
 | 
				
			||||||
      assert.deepEqual(await driver.findAll('select[name="D"] option', e => e.getText()), ['Foo', 'Bar', 'Baz']);
 | 
					      assert.deepEqual(await driver.findAll('select[name="D"] option', e => e.getText()), ['', 'Foo', 'Bar', 'Baz']);
 | 
				
			||||||
      await driver.findWait('select[name="D"]', 1000).click();
 | 
					      await driver.findWait('select[name="D"]', 1000).click();
 | 
				
			||||||
      await driver.find("option[value='Bar']").click();
 | 
					      await driver.find("option[value='Bar']").click();
 | 
				
			||||||
      await driver.find('input[type="submit"]').click();
 | 
					      await driver.find('input[type="submit"]').click();
 | 
				
			||||||
@ -229,7 +282,7 @@ describe('FormView', function() {
 | 
				
			|||||||
      await driver.find('input[type="submit"]').click();
 | 
					      await driver.find('input[type="submit"]').click();
 | 
				
			||||||
      await waitForConfirm();
 | 
					      await waitForConfirm();
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    await expect([true, false]);
 | 
					    await expectInD([true, false]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Remove the additional record added just now.
 | 
					    // Remove the additional record added just now.
 | 
				
			||||||
    await gu.sendActions([
 | 
					    await gu.sendActions([
 | 
				
			||||||
@ -262,6 +315,78 @@ describe('FormView', function() {
 | 
				
			|||||||
    await removeForm();
 | 
					    await removeForm();
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('can submit a form with Ref field', async function() {
 | 
				
			||||||
 | 
					    const formUrl = await createFormWith('Reference', true);
 | 
				
			||||||
 | 
					    // Add some options.
 | 
				
			||||||
 | 
					    await gu.openColumnPanel();
 | 
				
			||||||
 | 
					    await gu.setRefShowColumn('A');
 | 
				
			||||||
 | 
					    // Add 3 records to this table (it is now empty).
 | 
				
			||||||
 | 
					    await gu.sendActions([
 | 
				
			||||||
 | 
					      ['AddRecord', 'Table1', null, {A: 'Foo'}], // id 1
 | 
				
			||||||
 | 
					      ['AddRecord', 'Table1', null, {A: 'Bar'}], // id 2
 | 
				
			||||||
 | 
					      ['AddRecord', 'Table1', null, {A: 'Baz'}], // id 3
 | 
				
			||||||
 | 
					    ]);
 | 
				
			||||||
 | 
					    await gu.toggleSidePanel('right', 'close');
 | 
				
			||||||
 | 
					    // We are in a new window.
 | 
				
			||||||
 | 
					    await gu.onNewTab(async () => {
 | 
				
			||||||
 | 
					      await driver.get(formUrl);
 | 
				
			||||||
 | 
					      assert.deepEqual(
 | 
				
			||||||
 | 
					        await driver.findAll('select[name="D"] option', e => e.getText()),
 | 
				
			||||||
 | 
					        ['', ...['Bar', 'Baz', 'Foo']]
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      assert.deepEqual(
 | 
				
			||||||
 | 
					        await driver.findAll('select[name="D"] option', e => e.value()),
 | 
				
			||||||
 | 
					        ['', ...['2', '3', '1']]
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      await driver.findWait('select[name="D"]', 1000).click();
 | 
				
			||||||
 | 
					      await driver.find('option[value="2"]').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 RefList field', async function() {
 | 
				
			||||||
 | 
					    const formUrl = await createFormWith('Reference List', true);
 | 
				
			||||||
 | 
					    // Add some options.
 | 
				
			||||||
 | 
					    await gu.openColumnPanel();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await gu.setRefShowColumn('A');
 | 
				
			||||||
 | 
					    // Add 3 records to this table (it is now empty).
 | 
				
			||||||
 | 
					    await gu.sendActions([
 | 
				
			||||||
 | 
					      ['AddRecord', 'Table1', null, {A: 'Foo'}], // id 1
 | 
				
			||||||
 | 
					      ['AddRecord', 'Table1', null, {A: 'Bar'}], // id 2
 | 
				
			||||||
 | 
					      ['AddRecord', 'Table1', null, {A: 'Baz'}], // id 3
 | 
				
			||||||
 | 
					    ]);
 | 
				
			||||||
 | 
					    await gu.toggleSidePanel('right', 'close');
 | 
				
			||||||
 | 
					    // We are in a new window.
 | 
				
			||||||
 | 
					    await gu.onNewTab(async () => {
 | 
				
			||||||
 | 
					      await driver.get(formUrl);
 | 
				
			||||||
 | 
					      await driver.findWait('input[name="D[]"][value="1"]', 1000).click();
 | 
				
			||||||
 | 
					      await driver.findWait('input[name="D[]"][value="2"]', 1000).click();
 | 
				
			||||||
 | 
					      assert.equal(await driver.find('.grist-checkbox:has(input[name="D[]"][value="1"])').getText(), 'Foo');
 | 
				
			||||||
 | 
					      assert.equal(await driver.find('.grist-checkbox:has(input[name="D[]"][value="2"])').getText(), 'Bar');
 | 
				
			||||||
 | 
					      assert.equal(await driver.find('.grist-checkbox:has(input[name="D[]"][value="3"])').getText(), 'Baz');
 | 
				
			||||||
 | 
					      await driver.find('input[type="submit"]').click();
 | 
				
			||||||
 | 
					      await waitForConfirm();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    await expectInD([null, null, null, ['L', 2, 1]]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Remove 3 records.
 | 
				
			||||||
 | 
					    await gu.sendActions([
 | 
				
			||||||
 | 
					      ['BulkRemoveRecord', 'Table1', [1, 2, 3, 4]],
 | 
				
			||||||
 | 
					    ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await removeForm();
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  it('can unpublish forms', async function() {
 | 
					  it('can unpublish forms', async function() {
 | 
				
			||||||
    const formUrl = await createFormWith('Text');
 | 
					    const formUrl = await createFormWith('Text');
 | 
				
			||||||
    await driver.find('.test-forms-unpublish').click();
 | 
					    await driver.find('.test-forms-unpublish').click();
 | 
				
			||||||
 | 
				
			|||||||
@ -508,7 +508,7 @@ describe('RawData', function () {
 | 
				
			|||||||
    await gu.sendKeys("abc");
 | 
					    await gu.sendKeys("abc");
 | 
				
			||||||
    await gu.checkTextEditor("abc");
 | 
					    await gu.checkTextEditor("abc");
 | 
				
			||||||
    await gu.sendKeys(Key.ESCAPE);
 | 
					    await gu.sendKeys(Key.ESCAPE);
 | 
				
			||||||
    await showRawData();
 | 
					    await gu.showRawData();
 | 
				
			||||||
    assert.equal(await gu.getActiveSectionTitle(), 'City');
 | 
					    assert.equal(await gu.getActiveSectionTitle(), 'City');
 | 
				
			||||||
    assert.deepEqual(await gu.getCursorPosition(), {rowNum: 20, col: 0}); // raw popup is not sorted
 | 
					    assert.deepEqual(await gu.getCursorPosition(), {rowNum: 20, col: 0}); // raw popup is not sorted
 | 
				
			||||||
    await gu.sendKeys("abc");
 | 
					    await gu.sendKeys("abc");
 | 
				
			||||||
@ -530,7 +530,7 @@ describe('RawData', function () {
 | 
				
			|||||||
    await gu.sendKeys(Key.ESCAPE);
 | 
					    await gu.sendKeys(Key.ESCAPE);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Now open popup again, but close it by clicking on the close button.
 | 
					    // Now open popup again, but close it by clicking on the close button.
 | 
				
			||||||
    await showRawData();
 | 
					    await gu.showRawData();
 | 
				
			||||||
    await gu.closeRawTable();
 | 
					    await gu.closeRawTable();
 | 
				
			||||||
    await assertNoPopup();
 | 
					    await assertNoPopup();
 | 
				
			||||||
    assert.equal(await gu.getActiveSectionTitle(), 'CITY');
 | 
					    assert.equal(await gu.getActiveSectionTitle(), 'CITY');
 | 
				
			||||||
@ -540,7 +540,7 @@ describe('RawData', function () {
 | 
				
			|||||||
    await gu.sendKeys(Key.ESCAPE);
 | 
					    await gu.sendKeys(Key.ESCAPE);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Now do the same, but close by clicking on a diffrent page
 | 
					    // Now do the same, but close by clicking on a diffrent page
 | 
				
			||||||
    await showRawData();
 | 
					    await gu.showRawData();
 | 
				
			||||||
    await gu.getPageItem('Country').click();
 | 
					    await gu.getPageItem('Country').click();
 | 
				
			||||||
    await assertNoPopup();
 | 
					    await assertNoPopup();
 | 
				
			||||||
    assert.equal(await gu.getActiveSectionTitle(), 'COUNTRY');
 | 
					    assert.equal(await gu.getActiveSectionTitle(), 'COUNTRY');
 | 
				
			||||||
@ -552,7 +552,7 @@ describe('RawData', function () {
 | 
				
			|||||||
    // Now make sure that raw data is available for card view.
 | 
					    // Now make sure that raw data is available for card view.
 | 
				
			||||||
    await gu.selectSectionByTitle("COUNTRY Card List");
 | 
					    await gu.selectSectionByTitle("COUNTRY Card List");
 | 
				
			||||||
    assert.equal(await gu.getActiveSectionTitle(), 'COUNTRY Card List');
 | 
					    assert.equal(await gu.getActiveSectionTitle(), 'COUNTRY Card List');
 | 
				
			||||||
    await showRawData();
 | 
					    await gu.showRawData();
 | 
				
			||||||
    assert.equal(await gu.getActiveSectionTitle(), 'Country');
 | 
					    assert.equal(await gu.getActiveSectionTitle(), 'Country');
 | 
				
			||||||
    assert.deepEqual(await gu.getCursorPosition(), {rowNum: 1, col: 1});
 | 
					    assert.deepEqual(await gu.getCursorPosition(), {rowNum: 1, col: 1});
 | 
				
			||||||
    await gu.sendKeys("abc");
 | 
					    await gu.sendKeys("abc");
 | 
				
			||||||
@ -623,7 +623,7 @@ describe('RawData', function () {
 | 
				
			|||||||
    // Now open plain raw data for City table.
 | 
					    // Now open plain raw data for City table.
 | 
				
			||||||
    await gu.selectSectionByTitle("CITY");
 | 
					    await gu.selectSectionByTitle("CITY");
 | 
				
			||||||
    assert.equal(await gu.getActiveSectionTitle(), 'CITY'); // CITY is viewSection title
 | 
					    assert.equal(await gu.getActiveSectionTitle(), 'CITY'); // CITY is viewSection title
 | 
				
			||||||
    await showRawData();
 | 
					    await gu.showRawData();
 | 
				
			||||||
    assert.equal(await gu.getActiveSectionTitle(), 'City'); // City is now a table title
 | 
					    assert.equal(await gu.getActiveSectionTitle(), 'City'); // City is now a table title
 | 
				
			||||||
    // Now remove the table.
 | 
					    // Now remove the table.
 | 
				
			||||||
    await api.applyUserActions(doc, [[
 | 
					    await api.applyUserActions(doc, [[
 | 
				
			||||||
@ -787,12 +787,6 @@ function replaceAnchor(link: string, values: {
 | 
				
			|||||||
  return link.replace(anchorRegex, `#a${values.a || a}.s${values.s || s}.r${values.r || r}.c${values.c || c}`);
 | 
					  return link.replace(anchorRegex, `#a${values.a || a}.s${values.s || s}.r${values.r || r}.c${values.c || c}`);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function showRawData() {
 | 
					 | 
				
			||||||
  await gu.openSectionMenu('viewLayout');
 | 
					 | 
				
			||||||
  await driver.find('.test-show-raw-data').click();
 | 
					 | 
				
			||||||
  await waitForPopup();
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
async function openRawData() {
 | 
					async function openRawData() {
 | 
				
			||||||
  await driver.find('.test-tools-raw').click();
 | 
					  await driver.find('.test-tools-raw').click();
 | 
				
			||||||
  await waitForRawData();
 | 
					  await waitForRawData();
 | 
				
			||||||
 | 
				
			|||||||
@ -1188,7 +1188,12 @@ export async function changeWidget(type: WidgetType) {
 | 
				
			|||||||
/**
 | 
					/**
 | 
				
			||||||
 * Rename the given page to a new name. The oldName can be a full string name or a RegExp.
 | 
					 * Rename the given page to a new name. The oldName can be a full string name or a RegExp.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export async function renamePage(oldName: string|RegExp, newName: string) {
 | 
					export async function renamePage(oldName: string|RegExp, newName?: string) {
 | 
				
			||||||
 | 
					  if (!newName && typeof oldName === 'string') {
 | 
				
			||||||
 | 
					    newName = oldName;
 | 
				
			||||||
 | 
					    oldName = await getCurrentPageName();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  if (newName === undefined) { throw new Error('newName must be specified'); }
 | 
				
			||||||
  await openPageMenu(oldName);
 | 
					  await openPageMenu(oldName);
 | 
				
			||||||
  await driver.find('.test-docpage-rename').click();
 | 
					  await driver.find('.test-docpage-rename').click();
 | 
				
			||||||
  await driver.find('.test-docpage-editor').sendKeys(newName, Key.ENTER);
 | 
					  await driver.find('.test-docpage-editor').sendKeys(newName, Key.ENTER);
 | 
				
			||||||
@ -1570,6 +1575,15 @@ export async function openSectionMenu(which: 'sortAndFilter'|'viewLayout', secti
 | 
				
			|||||||
  return await driver.findWait('.grist-floating-menu', 100);
 | 
					  return await driver.findWait('.grist-floating-menu', 100);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Opens Raw data view for current section.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					export async function showRawData(section?: string|WebElement) {
 | 
				
			||||||
 | 
					  await openSectionMenu('viewLayout', section);
 | 
				
			||||||
 | 
					  await driver.find('.test-show-raw-data').click();
 | 
				
			||||||
 | 
					  assert.isTrue(await driver.findWait('.test-raw-data-overlay', 100).isDisplayed());
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Mapping from column menu option name to dom element selector to wait for, or null if no need to wait.
 | 
					// Mapping from column menu option name to dom element selector to wait for, or null if no need to wait.
 | 
				
			||||||
const ColumnMenuOption: { [id: string]: string; } = {
 | 
					const ColumnMenuOption: { [id: string]: string; } = {
 | 
				
			||||||
  Filter: '.test-filter-menu-wrapper'
 | 
					  Filter: '.test-filter-menu-wrapper'
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user