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