(core) Forms improvements
Summary: Forms improvements and following new design - New headers - New UI - New right panel options Test Plan: Tests updated Reviewers: georgegevoian, dsagal Reviewed By: georgegevoian Subscribers: dsagal, paulfitz Differential Revision: https://phab.getgrist.com/D4158
| @ -1,10 +1,12 @@ | ||||
| import {buildEditor} from 'app/client/components/Forms/Editor'; | ||||
| import {buildMenu} from 'app/client/components/Forms/Menu'; | ||||
| import {Box, BoxModel} from 'app/client/components/Forms/Model'; | ||||
| import * as style from 'app/client/components/Forms/styles'; | ||||
| import {Box, BoxModel, RenderContext} from 'app/client/components/Forms/Model'; | ||||
| import {makeTestId} from 'app/client/lib/domUtils'; | ||||
| import {icon} from 'app/client/ui2018/icons'; | ||||
| import * as menus from 'app/client/ui2018/menus'; | ||||
| import {inlineStyle, not} from 'app/common/gutil'; | ||||
| import {bundleChanges, Computed, dom, MultiHolder, Observable, styled} from 'grainjs'; | ||||
| import {bundleChanges, Computed, dom, IDomArgs, MultiHolder, Observable, styled} from 'grainjs'; | ||||
| 
 | ||||
| const testId = makeTestId('test-forms-'); | ||||
| 
 | ||||
| @ -12,7 +14,7 @@ export class ColumnsModel extends BoxModel { | ||||
|   private _columnCount = Computed.create(this, use => use(this.children).length); | ||||
| 
 | ||||
|   public removeChild(box: BoxModel) { | ||||
|     if (box.type.get() === 'Placeholder') { | ||||
|     if (box.type === 'Placeholder') { | ||||
|       // Make sure we have at least one rendered.
 | ||||
|       if (this.children.get().length <= 1) { | ||||
|         return; | ||||
| @ -24,33 +26,29 @@ export class ColumnsModel extends BoxModel { | ||||
|   } | ||||
| 
 | ||||
|   // Dropping a box on a column will replace it.
 | ||||
|   public drop(dropped: Box): BoxModel { | ||||
|   public accept(dropped: Box): BoxModel { | ||||
|     if (!this.parent) { throw new Error('No parent'); } | ||||
| 
 | ||||
|     // We need to remove it from the parent, so find it first.
 | ||||
|     const droppedId = dropped.id; | ||||
|     const droppedRef = this.root().find(droppedId); | ||||
|     const droppedRef = this.root().get(droppedId); | ||||
| 
 | ||||
|     // Now we simply insert it after this box.
 | ||||
|     droppedRef?.removeSelf(); | ||||
| 
 | ||||
|     return this.parent.replace(this, dropped); | ||||
|   } | ||||
|   public render(context: RenderContext): HTMLElement { | ||||
|     context.overlay.set(false); | ||||
| 
 | ||||
|   public render(...args: IDomArgs<HTMLElement>): HTMLElement { | ||||
|     // Now render the dom.
 | ||||
|     const renderedDom = style.cssColumns( | ||||
|     const content: HTMLElement = style.cssColumns( | ||||
|       // Pass column count as a css variable (to style the grid).
 | ||||
|       inlineStyle(`--css-columns-count`, this._columnCount), | ||||
| 
 | ||||
|       // Render placeholders as children.
 | ||||
|       dom.forEach(this.children, (child) => { | ||||
|         return this.view.renderBox( | ||||
|           this.children, | ||||
|           child || BoxModel.new(Placeholder(), this), | ||||
|           testId('column') | ||||
|         ); | ||||
|         const toRender = child ?? BoxModel.new(Placeholder(), this); | ||||
|         return toRender.render(testId('column')); | ||||
|       }), | ||||
| 
 | ||||
|       // Append + button at the end.
 | ||||
| @ -60,17 +58,17 @@ export class ColumnsModel extends BoxModel { | ||||
|         dom.on('click', () => this.placeAfterListChild()(Placeholder())), | ||||
|         style.cssColumn.cls('-add-button') | ||||
|       ), | ||||
| 
 | ||||
|       ...args, | ||||
|     ); | ||||
|     return renderedDom; | ||||
|     return buildEditor({ box: this, content }); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export class PlaceholderModel extends BoxModel { | ||||
| 
 | ||||
|   public render(context: RenderContext): HTMLElement { | ||||
|     const [box, view, overlay] = [this, this.view, context.overlay]; | ||||
|   public render(...args: IDomArgs<HTMLElement>): HTMLElement { | ||||
|     const [box, view] = [this, this.view]; | ||||
|     const scope = new MultiHolder(); | ||||
|     overlay.set(false); | ||||
| 
 | ||||
|     const liveIndex = Computed.create(scope, (use) => { | ||||
|       if (!box.parent) { return -1; } | ||||
| @ -91,15 +89,17 @@ export class PlaceholderModel extends BoxModel { | ||||
|     const dragHover = Observable.create(scope, false); | ||||
| 
 | ||||
|     return cssPlaceholder( | ||||
|       style.cssDrag(), | ||||
|       testId('placeholder'), | ||||
|       style.cssDrop(), | ||||
|       testId('Placeholder'), | ||||
|       dom.autoDispose(scope), | ||||
| 
 | ||||
|       style.cssColumn.cls('-drag-over', dragHover), | ||||
|       style.cssColumn.cls('-empty', not(boxModelAt)), | ||||
|       style.cssColumn.cls('-selected', use => use(view.selectedBox) === box), | ||||
| 
 | ||||
|       view.buildAddMenu(insertBox, { | ||||
|       buildMenu({ | ||||
|         box: this, | ||||
|         insertBox, | ||||
|         customItems: [menus.menuItem(removeColumn, menus.menuIcon('Remove'), 'Remove Column')], | ||||
|       }), | ||||
| 
 | ||||
| @ -133,7 +133,7 @@ export class PlaceholderModel extends BoxModel { | ||||
| 
 | ||||
|         // We need to remove it from the parent, so find it first.
 | ||||
|         const droppedId = dropped.id; | ||||
|         const droppedRef = box.root().find(droppedId); | ||||
|         const droppedRef = box.root().get(droppedId); | ||||
|         if (!droppedRef) { return; } | ||||
| 
 | ||||
|         // Now we simply insert it after this box.
 | ||||
| @ -144,11 +144,13 @@ export class PlaceholderModel extends BoxModel { | ||||
|           parent.save().catch(reportError); | ||||
|         }); | ||||
|       }), | ||||
| 
 | ||||
|       dom.maybeOwned(boxModelAt, (mscope, child) => view.renderBox(mscope, child)), | ||||
|       dom.maybe(use => !use(boxModelAt) && use(view.isEdit), () => { | ||||
|         return dom('span', `Column `, dom.text(use => String(use(liveIndex) + 1))); | ||||
|       }), | ||||
|       // If we an occupant, render it.
 | ||||
|       dom.maybe(boxModelAt, (child) => child.render()), | ||||
|       // If not, render a placeholder.
 | ||||
|       dom.maybe(not(boxModelAt), () => | ||||
|         dom('span', `Column `, dom.text(use => String(use(liveIndex) + 1))) | ||||
|       ), | ||||
|       ...args, | ||||
|     ); | ||||
| 
 | ||||
|     function insertBox(childBox: Box) { | ||||
|  | ||||
							
								
								
									
										209
									
								
								app/client/components/Forms/Editor.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,209 @@ | ||||
| import {allCommands} from 'app/client/components/commands'; | ||||
| import {BoxModel, parseBox} from 'app/client/components/Forms/Model'; | ||||
| import {buildMenu} from 'app/client/components/Forms/Menu'; | ||||
| import * as style from 'app/client/components/Forms/styles'; | ||||
| import {makeTestId, stopEvent} from 'app/client/lib/domUtils'; | ||||
| import {makeT} from 'app/client/lib/localization'; | ||||
| import {hoverTooltip} from 'app/client/ui/tooltips'; | ||||
| import {IconName} from 'app/client/ui2018/IconList'; | ||||
| import {icon} from 'app/client/ui2018/icons'; | ||||
| import {dom, DomContents, IDomArgs, MultiHolder, Observable} from 'grainjs'; | ||||
| 
 | ||||
| const testId = makeTestId('test-forms-'); | ||||
| const t = makeT('FormView.Editor'); | ||||
| 
 | ||||
| interface Props { | ||||
|   box: BoxModel, | ||||
|   /** Should we show an overlay */ | ||||
|   overlay?: Observable<boolean>, | ||||
|   /** Custom drag indicator slot */ | ||||
|   drag?: HTMLElement, | ||||
|   /** | ||||
|    * Actual element to put into the editor. This is the main content of the editor. | ||||
|    */ | ||||
|   content: DomContents, | ||||
|   /** | ||||
|    * Click handler. If not provided, then clicking on the editor will select it. | ||||
|    */ | ||||
|   click?: (ev: MouseEvent, box: BoxModel) => void, | ||||
|   /** | ||||
|    * Custom remove icon. If null, then no drop icon is shown. | ||||
|    */ | ||||
|   removeIcon?: IconName|null, | ||||
|   /** | ||||
|    * Custom remove button rendered atop overlay. | ||||
|    */ | ||||
|   removeButton?: DomContents, | ||||
|   /** | ||||
|    * Tooltip for the remove button. | ||||
|    */ | ||||
|   removeTooltip?: string, | ||||
|   /** | ||||
|    * Position of the remove button. Defaults to inside. | ||||
|    */ | ||||
|   removePosition?: 'inside'|'right', | ||||
|   editMode?: Observable<boolean>, | ||||
| } | ||||
| 
 | ||||
| export function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) { | ||||
|   const owner: MultiHolder = new MultiHolder(); | ||||
|   const {box, overlay} = props; | ||||
|   const view = box.view; | ||||
|   const dragHover = Observable.create(owner, false); | ||||
|   let element: HTMLElement; | ||||
| 
 | ||||
|   // When element is selected, scroll it into view.
 | ||||
|   owner.autoDispose(view.selectedBox.addListener(selectedBox => { | ||||
|     if (selectedBox === box) { | ||||
|       element?.scrollIntoView({behavior: 'smooth', block: 'nearest', inline: 'nearest'}); | ||||
|     } | ||||
|   })); | ||||
| 
 | ||||
|   // Default remove icon, can be overriden by props.
 | ||||
|   const defaultRemoveButton = () => style.cssRemoveButton( | ||||
|     icon((props.removeIcon as any) ?? 'RemoveBig'), | ||||
|     dom.on('click', ev => { | ||||
|       stopEvent(ev); | ||||
|       box.view.selectedBox.set(box); | ||||
|       allCommands.deleteFields.run(); | ||||
|     }), | ||||
|     props.removeButton === null ? null : hoverTooltip(props.removeTooltip ?? t('Delete')), | ||||
|     style.cssRemoveButton.cls('-right', props.removePosition === 'right'), | ||||
|   ); | ||||
| 
 | ||||
|   const onClick = (ev: MouseEvent) => { | ||||
|     // Only if the click was in this element.
 | ||||
|     const target = ev.target as HTMLElement; | ||||
|     if (!target.closest) { return; } | ||||
|     // Make sure that the closest editor is this one.
 | ||||
|     const closest = target.closest(`.${style.cssFieldEditor.className}`); | ||||
|     if (closest !== element) { return; } | ||||
| 
 | ||||
|     ev.stopPropagation(); | ||||
|     ev.preventDefault(); | ||||
|     props.click?.(ev, props.box); | ||||
| 
 | ||||
|     // Mark this box as selected.
 | ||||
|     box.view.selectedBox.set(box); | ||||
|   }; | ||||
| 
 | ||||
|   const dragAbove = Observable.create(owner, false); | ||||
|   const dragBelow = Observable.create(owner, false); | ||||
|   const dragging = Observable.create(owner, false); | ||||
| 
 | ||||
| 
 | ||||
|   return element = style.cssFieldEditor( | ||||
|     testId('editor'), | ||||
| 
 | ||||
|     style.cssFieldEditor.cls('-drag-above', use => use(dragAbove) && use(dragHover)), | ||||
|     style.cssFieldEditor.cls('-drag-below', use => use(dragBelow) && use(dragHover)), | ||||
| 
 | ||||
|     props.drag ?? style.cssDragWrapper(style.cssDrag('DragDrop')), | ||||
|     style.cssFieldEditor.cls(`-${props.box.type}`), | ||||
| 
 | ||||
|     // Turn on active like state when we clicked here.
 | ||||
|     style.cssFieldEditor.cls('-selected', box.selected), | ||||
|     style.cssFieldEditor.cls('-cut', box.cut), | ||||
|     testId('field-editor-selected', box.selected), | ||||
| 
 | ||||
|     // Select on click.
 | ||||
|     dom.on('click', onClick), | ||||
| 
 | ||||
|     // Attach context menu.
 | ||||
|     buildMenu({ | ||||
|       box, | ||||
|       context: true, | ||||
|     }), | ||||
| 
 | ||||
|     // And now drag and drop support.
 | ||||
|     {draggable: "true"}, | ||||
| 
 | ||||
|     // When started, we just put the box into the dataTransfer as a plain text.
 | ||||
|     // TODO: this might be very sofisticated in the future.
 | ||||
|     dom.on('dragstart', (ev) => { | ||||
|       // Prevent propagation, as we might be in a nested editor.
 | ||||
|       ev.stopPropagation(); | ||||
|       ev.dataTransfer?.setData('text/plain', JSON.stringify(box.toJSON())); | ||||
|       ev.dataTransfer!.dropEffect = "move"; | ||||
|       dragging.set(true); | ||||
|     }), | ||||
| 
 | ||||
|     dom.on('dragover', (ev) => { | ||||
|       // As usual, prevent propagation.
 | ||||
|       ev.stopPropagation(); | ||||
|       ev.preventDefault(); | ||||
|       ev.stopImmediatePropagation(); | ||||
|       // Here we just change the style of the element.
 | ||||
|       ev.dataTransfer!.dropEffect = "move"; | ||||
|       dragHover.set(true); | ||||
| 
 | ||||
|       if (dragging.get() || props.box.type === 'Section') { return; } | ||||
| 
 | ||||
|       const myHeight = element.offsetHeight; | ||||
|       const percentHeight = Math.round((ev.offsetY / myHeight) * 100); | ||||
| 
 | ||||
|       // If we are in the top half, we want to animate ourselves and transform a little below.
 | ||||
|       if (percentHeight < 40) { | ||||
|         dragAbove.set(true); | ||||
|         dragBelow.set(false); | ||||
|       } else if (percentHeight > 60) { | ||||
|         dragAbove.set(false); | ||||
|         dragBelow.set(true); | ||||
|       } else { | ||||
|         dragAbove.set(false); | ||||
|         dragBelow.set(false); | ||||
|       } | ||||
|     }), | ||||
| 
 | ||||
|     dom.on('dragleave', (ev) => { | ||||
|       ev.stopPropagation(); | ||||
|       ev.preventDefault(); | ||||
|       // Just remove the style and stop propagation.
 | ||||
|       dragHover.set(false); | ||||
|       dragAbove.set(false); | ||||
|       dragBelow.set(false); | ||||
|     }), | ||||
| 
 | ||||
|     dom.on('dragend', () => { | ||||
|       dragHover.set(false); | ||||
|       dragAbove.set(false); | ||||
|       dragBelow.set(false); | ||||
|       dragging.set(false); | ||||
|     }), | ||||
| 
 | ||||
|     dom.on('drop', async (ev) => { | ||||
|       stopEvent(ev); | ||||
|       dragHover.set(false); | ||||
|       dragging.set(false); | ||||
|       dragAbove.set(false); | ||||
|       const wasBelow = dragBelow.get(); | ||||
|       dragBelow.set(false); | ||||
| 
 | ||||
|       const dropped = parseBox(ev.dataTransfer!.getData('text/plain')); | ||||
|       // We need to remove it from the parent, so find it first.
 | ||||
|       const droppedId = dropped.id; | ||||
|       if (droppedId === box.id) { return; } | ||||
|       const droppedModel = box.root().get(droppedId); | ||||
|       // It might happen that parent is dropped into child, so we need to check for that.
 | ||||
|       if (droppedModel?.get(box.id)) { return; } | ||||
|       await box.save(async () => { | ||||
|         droppedModel?.removeSelf(); | ||||
|         await box.accept(dropped, wasBelow ? 'below' : 'above')?.afterDrop(); | ||||
|       }); | ||||
|     }), | ||||
| 
 | ||||
|     style.cssFieldEditor.cls('-drag-hover', dragHover), | ||||
|     style.cssFieldEditorContent( | ||||
|       props.content, | ||||
|       style.cssDrop(), | ||||
|     ), | ||||
|     testId(box.type), | ||||
|     testId('element'), | ||||
|     dom.maybe(overlay, () => style.cssSelectedOverlay()), | ||||
|     // Custom icons for removing.
 | ||||
|     props.removeIcon === null || props.removeButton ? null : | ||||
|       dom.maybe(use => !props.editMode || !use(props.editMode), defaultRemoveButton), | ||||
|     props.removeButton ?? null, | ||||
|     ...args, | ||||
|   ); | ||||
| } | ||||
| @ -1,133 +1,297 @@ | ||||
| import {buildEditor} from 'app/client/components/Forms/Editor'; | ||||
| import {FormView} from 'app/client/components/Forms/FormView'; | ||||
| import {Box, BoxModel, ignoreClick, RenderContext} from 'app/client/components/Forms/Model'; | ||||
| import * as style from 'app/client/components/Forms/styles'; | ||||
| import {ViewFieldRec} from 'app/client/models/DocModel'; | ||||
| import {Box, BoxModel, ignoreClick} from 'app/client/components/Forms/Model'; | ||||
| import * as css from 'app/client/components/Forms/styles'; | ||||
| import {stopEvent} from 'app/client/lib/domUtils'; | ||||
| import {refRecord} from 'app/client/models/DocModel'; | ||||
| import {autoGrow} from 'app/client/ui/forms'; | ||||
| import {squareCheckbox} from 'app/client/ui2018/checkbox'; | ||||
| import {colors} from 'app/client/ui2018/cssVars'; | ||||
| import {Constructor} from 'app/common/gutil'; | ||||
| import {BindableValue, Computed, Disposable, dom, DomContents, | ||||
|         IDomComponent, makeTestId, Observable, toKo} from 'grainjs'; | ||||
| import { | ||||
|   BindableValue, | ||||
|   Computed, | ||||
|   Disposable, | ||||
|   dom, | ||||
|   DomContents, | ||||
|   DomElementArg, | ||||
|   IDomArgs, | ||||
|   makeTestId, | ||||
|   MultiHolder, | ||||
|   observable, | ||||
|   Observable, | ||||
|   styled, | ||||
|   toKo | ||||
| } from 'grainjs'; | ||||
| import * as ko from 'knockout'; | ||||
| 
 | ||||
| const testId = makeTestId('test-forms-'); | ||||
| 
 | ||||
| /** | ||||
|  * Base class for all field models. | ||||
|  * Container class for all fields. | ||||
|  */ | ||||
| export class FieldModel extends BoxModel { | ||||
| 
 | ||||
|   /** | ||||
|    * Edit mode, (only one element can be in edit mode in the form editor). | ||||
|    */ | ||||
|   public edit = Observable.create(this, false); | ||||
|   public fieldRef = this.autoDispose(ko.pureComputed(() => toKo(ko, this.leaf)())); | ||||
|   public field = this.view.gristDoc.docModel.viewFields.createFloatingRowModel(this.fieldRef); | ||||
| 
 | ||||
|   public field = refRecord(this.view.gristDoc.docModel.viewFields, this.fieldRef); | ||||
|   public colId = Computed.create(this, (use) => use(use(this.field).colId)); | ||||
|   public column = Computed.create(this, (use) => use(use(this.field).column)); | ||||
|   public question = Computed.create(this, (use) => { | ||||
|     return use(this.field.question) || use(this.field.origLabel); | ||||
|     const field = use(this.field); | ||||
|     if (field.isDisposed() || use(field.id) === 0) { return ''; } | ||||
|     return use(field.question) || use(field.origLabel); | ||||
|   }); | ||||
| 
 | ||||
|   public description = Computed.create(this, (use) => { | ||||
|     return use(this.field.description); | ||||
|     const field = use(this.field); | ||||
|     return use(field.description); | ||||
|   }); | ||||
| 
 | ||||
|   /** | ||||
|    * Column type of the field. | ||||
|    */ | ||||
|   public colType = Computed.create(this, (use) => { | ||||
|     return use(use(this.field.column).pureType); | ||||
|     const field = use(this.field); | ||||
|     return use(use(field.column).pureType); | ||||
|   }); | ||||
| 
 | ||||
|   /** | ||||
|    * Field row id. | ||||
|    */ | ||||
|   public get leaf() { | ||||
|     return this.props['leaf'] as Observable<number>; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * A renderer of question instance. | ||||
|    */ | ||||
|   public renderer = Computed.create(this, (use) => { | ||||
|     const ctor = fieldConstructor(use(this.colType)); | ||||
|     const instance = new ctor(this.field); | ||||
|     const instance = new ctor(this); | ||||
|     use.owner.autoDispose(instance); | ||||
|     return instance; | ||||
|   }); | ||||
| 
 | ||||
|   constructor(box: Box, parent: BoxModel | null, view: FormView) { | ||||
|     super(box, parent, view); | ||||
| 
 | ||||
|     this.question.onWrite(value => { | ||||
|       this.field.peek().question.setAndSave(value).catch(reportError); | ||||
|     }); | ||||
| 
 | ||||
|     this.autoDispose( | ||||
|       this.selected.addListener((now, then) => { | ||||
|         if (!now && then) { | ||||
|           setImmediate(() => !this.edit.isDisposed() && this.edit.set(false)); | ||||
|         } | ||||
|       }) | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   public async onDrop() { | ||||
|     await super.onDrop(); | ||||
|   public async afterDrop() { | ||||
|     // Base class does good job of handling drop.
 | ||||
|     await super.afterDrop(); | ||||
|     if (this.isDisposed()) { return; } | ||||
| 
 | ||||
|     // Except when a field is dragged from the creator panel, which stores colId instead of fieldRef (as there is no
 | ||||
|     // field yet). In this case, we need to create a field.
 | ||||
|     if (typeof this.leaf.get() === 'string') { | ||||
|       this.leaf.set(await this.view.showColumn(this.leaf.get())); | ||||
|     } | ||||
|   } | ||||
|   public render(context: RenderContext) { | ||||
|     const model = this; | ||||
| 
 | ||||
|     return dom('div', | ||||
|       testId('question'), | ||||
|       style.cssLabel( | ||||
|         testId('label'), | ||||
|         dom.text(model.question) | ||||
|       ), | ||||
|       testType(this.colType), | ||||
|       dom.domComputed(this.renderer, (renderer) => renderer.buildDom()), | ||||
|       dom.maybe(model.description, (description) => [ | ||||
|         style.cssDesc(description, testId('description')), | ||||
|       ]), | ||||
|   public override render(...args: IDomArgs<HTMLElement>): HTMLElement { | ||||
|     // Updated question is used for editing, we don't save on every key press, but only on blur (or enter, etc).
 | ||||
|     const save = (value: string) => { | ||||
|       value = value?.trim(); | ||||
|       // If question is empty or same as original, don't save.
 | ||||
|       if (!value || value === this.field.peek().question()) { | ||||
|         return; | ||||
|       } | ||||
|       this.field.peek().question.setAndSave(value).catch(reportError); | ||||
|     }; | ||||
|     const overlay = Observable.create(null, true); | ||||
| 
 | ||||
|     const content = dom.domComputed(this.renderer, (r) => r.buildDom({ | ||||
|       edit: this.edit, | ||||
|       overlay, | ||||
|       onSave: save, | ||||
|     }, ...args)); | ||||
| 
 | ||||
|     return buildEditor({ | ||||
|         box: this, | ||||
|         overlay, | ||||
|         removeIcon: 'CrossBig', | ||||
|         removeTooltip: 'Hide', | ||||
|         editMode: this.edit, | ||||
|         content, | ||||
|       }, | ||||
|       dom.on('dblclick', () => this.selected.get() && this.edit.set(true)), | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| 
 | ||||
|   public async deleteSelf() { | ||||
|     const rowId = this.field.getRowId(); | ||||
|     const rowId = this.field.peek().id.peek(); | ||||
|     const view = this.view; | ||||
|     const root = this.root(); | ||||
|     this.removeSelf(); | ||||
|     // The order here matters for undo.
 | ||||
|     await this.save(); | ||||
|     // We are disposed at this point, be still can access the view.
 | ||||
|     if (rowId) { | ||||
|       await view.viewSection.removeField(rowId); | ||||
|     } | ||||
|     await root.save(async () => { | ||||
|       // Make sure to save first layout without this field, otherwise the undo won't work properly.
 | ||||
|       await root.save(); | ||||
|       // We are disposed at this point, be still can access the view.
 | ||||
|       if (rowId) { | ||||
|         await view.viewSection.removeField(rowId); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export abstract class Question extends Disposable implements IDomComponent { | ||||
|   constructor(public field: ViewFieldRec) { | ||||
| export abstract class Question extends Disposable { | ||||
|   constructor(public model: FieldModel) { | ||||
|     super(); | ||||
|   } | ||||
| 
 | ||||
|   public abstract buildDom(): DomContents; | ||||
|   public buildDom(props: { | ||||
|     edit: Observable<boolean>, | ||||
|     overlay: Observable<boolean>, | ||||
|     onSave: (value: string) => void, | ||||
|   }, ...args: IDomArgs<HTMLElement>) { | ||||
|     return css.cssPadding( | ||||
|       testId('question'), | ||||
|       testType(this.model.colType), | ||||
|       this.renderLabel(props, dom.style('margin-bottom', '5px')), | ||||
|       this.renderInput(), | ||||
|       ...args | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   public abstract renderInput(): DomContents; | ||||
| 
 | ||||
|   protected renderLabel(props: { | ||||
|     edit: Observable<boolean>, | ||||
|     onSave: (value: string) => void, | ||||
|   }, ...args: DomElementArg[]) { | ||||
|     const {edit, onSave} = props; | ||||
| 
 | ||||
|     const scope = new MultiHolder(); | ||||
| 
 | ||||
|     // When in edit, we will update a copy of the question.
 | ||||
|     const draft = Observable.create(scope, this.model.question.get()); | ||||
|     scope.autoDispose( | ||||
|       this.model.question.addListener(q => draft.set(q)), | ||||
|     ); | ||||
|     const controller = Computed.create(scope, (use) => use(draft)); | ||||
|     controller.onWrite(value => { | ||||
|       if (this.isDisposed() || draft.isDisposed()) { return; } | ||||
|       if (!edit.get()) { return; } | ||||
|       draft.set(value); | ||||
|     }); | ||||
| 
 | ||||
|     // Wire up save method.
 | ||||
|     const saveDraft = (ok: boolean) => { | ||||
|       if (this.isDisposed() || draft.isDisposed()) { return; } | ||||
|       if (!ok || !edit.get() || !controller.get()) { | ||||
|         controller.set(this.model.question.get()); | ||||
|         return; | ||||
|       } | ||||
|       onSave(controller.get()); | ||||
|     }; | ||||
|     let element: HTMLTextAreaElement; | ||||
| 
 | ||||
|     scope.autoDispose( | ||||
|       props.edit.addListener((now, then) => { | ||||
|         if (now && !then) { | ||||
|           // When we go into edit mode, we copy the question into draft.
 | ||||
|           draft.set(this.model.question.get()); | ||||
|           // And focus on the element.
 | ||||
|           setTimeout(() => { | ||||
|             element?.focus(); | ||||
|             element?.select(); | ||||
|           }, 10); | ||||
|         } | ||||
|       }) | ||||
|     ); | ||||
| 
 | ||||
|     return [ | ||||
|       dom.autoDispose(scope), | ||||
|       element = css.cssEditableLabel( | ||||
|         controller, | ||||
|         {onInput: true}, | ||||
|         // Attach common Enter,Escape, blur handlers.
 | ||||
|         css.saveControls(edit, saveDraft), | ||||
|         // Autoselect whole text when mounted.
 | ||||
|         // Auto grow for textarea.
 | ||||
|         autoGrow(controller), | ||||
|         // Enable normal menu.
 | ||||
|         dom.on('contextmenu', stopEvent), | ||||
|         dom.style('resize', 'none'), | ||||
|         testId('label'), | ||||
|         css.cssEditableLabel.cls('-edit', props.edit), | ||||
|         // When selected, we want to be able to edit the label by clicking it
 | ||||
|         // so we need to make it relative and z-indexed.
 | ||||
|         dom.style('position', u => u(this.model.selected) ? 'relative' : 'static'), | ||||
|         dom.style('z-index', '2'), | ||||
|         dom.on('click', (ev) => { | ||||
|           if (this.model.selected.get() && !props.edit.get()) { | ||||
|             props.edit.set(true); | ||||
|             ev.stopPropagation(); | ||||
|           } | ||||
|         }), | ||||
|         ...args, | ||||
|       ), | ||||
|     ]; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| class TextModel extends Question { | ||||
|   public buildDom() { | ||||
|     return style.cssInput( | ||||
|       dom.prop('name', this.field.colId), | ||||
|   public renderInput() { | ||||
|     return css.cssInput( | ||||
|       dom.prop('name', u => u(u(this.model.field).colId)), | ||||
|       {disabled: true}, | ||||
|       {type: 'text', tabIndex: "-1"}, | ||||
|       ignoreClick | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class ChoiceModel extends Question { | ||||
|   public buildDom() { | ||||
|     const field = this.field; | ||||
|   public renderInput() { | ||||
|     const field = this.model.field; | ||||
|     const choices: Computed<string[]> = Computed.create(this, use => { | ||||
|       return use(use(field.origCol).widgetOptionsJson.prop('choices')) || []; | ||||
|       return use(use(use(field).origCol).widgetOptionsJson.prop('choices')) || []; | ||||
|     }); | ||||
|     return style.cssSelect( | ||||
|     const typedChoices = Computed.create(this, use => { | ||||
|       const value = use(choices); | ||||
|       // Make sure it is array of strings.
 | ||||
|       if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) { | ||||
|         return []; | ||||
|       } | ||||
|       return value; | ||||
|     }); | ||||
|     return css.cssSelect( | ||||
|       {tabIndex: "-1"}, | ||||
|       ignoreClick, | ||||
|       dom.prop('name', this.field.colId), | ||||
|       dom.forEach(choices, (choice) => dom('option', choice, {value: choice})), | ||||
|       dom.prop('name', use => use(use(field).colId)), | ||||
|       dom.forEach(typedChoices, (choice) => dom('option', choice, {value: choice})), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class ChoiceListModel extends Question { | ||||
|   public buildDom() { | ||||
|     const field = this.field; | ||||
|   public renderInput() { | ||||
|     const field = this.model.field; | ||||
|     const choices: Computed<string[]> = Computed.create(this, use => { | ||||
|       return use(use(field.origCol).widgetOptionsJson.prop('choices')) || []; | ||||
|       return use(use(use(field).origCol).widgetOptionsJson.prop('choices')) || []; | ||||
|     }); | ||||
|     return dom('div', | ||||
|       dom.prop('name', this.field.colId), | ||||
|       dom.forEach(choices, (choice) => style.cssLabel( | ||||
|         dom('input', | ||||
|           dom.prop('name', this.field.colId), | ||||
|           {type: 'checkbox', value: choice, style: 'margin-right: 5px;'} | ||||
|         ), | ||||
|       dom.prop('name', use => use(use(field).colId)), | ||||
|       dom.forEach(choices, (choice) => css.cssCheckboxLabel( | ||||
|         squareCheckbox(observable(false)), | ||||
|         choice | ||||
|       )), | ||||
|       dom.maybe(use => use(choices).length === 0, () => [ | ||||
| @ -138,25 +302,38 @@ class ChoiceListModel extends Question { | ||||
| } | ||||
| 
 | ||||
| class BoolModel extends Question { | ||||
|   public buildDom() { | ||||
|     return dom('div', | ||||
|       style.cssLabel( | ||||
|         {style: 'display: flex; align-items: center; gap: 8px;'}, | ||||
|         dom('input', | ||||
|           dom.prop('name', this.field.colId), | ||||
|           {type: 'checkbox', name: 'choice', style: 'margin: 0px; padding: 0px;'} | ||||
|         ), | ||||
|         'Yes' | ||||
|   public override buildDom(props: { | ||||
|     edit: Observable<boolean>, | ||||
|     overlay: Observable<boolean>, | ||||
|     question: Observable<string>, | ||||
|     onSave: () => void, | ||||
|   }) { | ||||
|     return css.cssPadding( | ||||
|       testId('question'), | ||||
|       testType(this.model.colType), | ||||
|       cssToggle( | ||||
|         this.renderInput(), | ||||
|         this.renderLabel(props, css.cssEditableLabel.cls('-normal')), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|   public override renderInput() { | ||||
|     const value = Observable.create(this, true); | ||||
|     return dom('div.widget_switch', | ||||
|       dom.style('--grist-actual-cell-color', colors.lightGreen.toString()), | ||||
|       dom.cls('switch_on', value), | ||||
|       dom.cls('switch_transition', true), | ||||
|       dom('div.switch_slider'), | ||||
|       dom('div.switch_circle'), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class DateModel extends Question { | ||||
|   public buildDom() { | ||||
|   public renderInput() { | ||||
|     return dom('div', | ||||
|       dom('input', | ||||
|         dom.prop('name', this.field.colId), | ||||
|       css.cssInput( | ||||
|         dom.prop('name', this.model.colId), | ||||
|         {type: 'date', style: 'margin-right: 5px; width: 100%;' | ||||
|       }), | ||||
|     ); | ||||
| @ -164,10 +341,10 @@ class DateModel extends Question { | ||||
| } | ||||
| 
 | ||||
| class DateTimeModel extends Question { | ||||
|   public buildDom() { | ||||
|   public renderInput() { | ||||
|     return dom('div', | ||||
|       dom('input', | ||||
|         dom.prop('name', this.field.colId), | ||||
|       css.cssInput( | ||||
|         dom.prop('name', this.model.colId), | ||||
|         {type: 'datetime-local', style: 'margin-right: 5px; width: 100%;'} | ||||
|       ), | ||||
|       dom.style('width', '100%'), | ||||
| @ -175,13 +352,61 @@ class DateTimeModel extends Question { | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class RefListModel extends Question { | ||||
|   protected choices = this._subscribeForChoices(); | ||||
| 
 | ||||
|   public renderInput() { | ||||
|     return dom('div', | ||||
|       dom.prop('name', this.model.colId), | ||||
|       dom.forEach(this.choices, (choice) => css.cssLabel( | ||||
|         dom('input', | ||||
|           dom.prop('name', this.model.colId), | ||||
|           {type: 'checkbox', value: String(choice[0]), style: 'margin-right: 5px;'} | ||||
|         ), | ||||
|         String(choice[1] ?? '') | ||||
|       )), | ||||
|       dom.maybe(use => use(this.choices).length === 0, () => [ | ||||
|         dom('div', 'No choices defined'), | ||||
|       ]), | ||||
|     ) as HTMLElement; | ||||
|   } | ||||
| 
 | ||||
|   private _subscribeForChoices() { | ||||
|     const tableId = Computed.create(this, use => { | ||||
|       const refTable = use(use(this.model.column).refTable); | ||||
|       return refTable ? use(refTable.tableId) : ''; | ||||
|     }); | ||||
| 
 | ||||
|     const colId = Computed.create(this, use => { | ||||
|       const dispColumnIdObs = use(use(this.model.column).visibleColModel); | ||||
|       return use(dispColumnIdObs.colId); | ||||
|     }); | ||||
| 
 | ||||
|     const observer = this.model.view.gristDoc.columnObserver(this, tableId, colId); | ||||
| 
 | ||||
|     return Computed.create(this, use => { | ||||
|       const unsorted = use(observer); | ||||
|       unsorted.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); | ||||
|       return unsorted.slice(0, 50); // TODO: pagination or a waning
 | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class RefModel extends RefListModel { | ||||
|   public renderInput() { | ||||
|     return css.cssSelect( | ||||
|       {tabIndex: "-1"}, | ||||
|       ignoreClick, | ||||
|       dom.prop('name', this.model.colId), | ||||
|       dom.forEach(this.choices, (choice) => dom('option', String(choice[1] ?? ''), {value: String(choice[0])})), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // TODO: decide which one we need and implement rest.
 | ||||
| const AnyModel = TextModel; | ||||
| const NumericModel = TextModel; | ||||
| const IntModel = TextModel; | ||||
| const RefModel = TextModel; | ||||
| const RefListModel = TextModel; | ||||
| const AttachmentsModel = TextModel; | ||||
| 
 | ||||
| 
 | ||||
| @ -208,3 +433,10 @@ function fieldConstructor(type: string): Constructor<Question> { | ||||
| function testType(value: BindableValue<string>) { | ||||
|   return dom('input', {type: 'hidden'}, dom.prop('value', value), testId('type')); | ||||
| } | ||||
| 
 | ||||
| const cssToggle = styled('div', ` | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 8px; | ||||
|   --grist-actual-cell-color: ${colors.lightGreen}; | ||||
| `);
 | ||||
|  | ||||
| @ -1,34 +1,32 @@ | ||||
| import BaseView from 'app/client/components/BaseView'; | ||||
| import * as commands from 'app/client/components/commands'; | ||||
| import {allCommands} from 'app/client/components/commands'; | ||||
| import {Cursor} from 'app/client/components/Cursor'; | ||||
| import * as components from 'app/client/components/Forms/elements'; | ||||
| import {NewBox} from 'app/client/components/Forms/Menu'; | ||||
| import {Box, BoxModel, BoxType, LayoutModel, parseBox, Place} from 'app/client/components/Forms/Model'; | ||||
| import * as style from 'app/client/components/Forms/styles'; | ||||
| import {GristDoc} from 'app/client/components/GristDoc'; | ||||
| import {copyToClipboard} from 'app/client/lib/clipboardUtils'; | ||||
| import {Disposable} from 'app/client/lib/dispose'; | ||||
| import {AsyncComputed, makeTestId} from 'app/client/lib/domUtils'; | ||||
| import {FocusLayer} from 'app/client/lib/FocusLayer'; | ||||
| import {AsyncComputed, makeTestId, stopEvent} from 'app/client/lib/domUtils'; | ||||
| import {makeT} from 'app/client/lib/localization'; | ||||
| import {localStorageBoolObs} from 'app/client/lib/localStorageObs'; | ||||
| import DataTableModel from 'app/client/models/DataTableModel'; | ||||
| import {ViewSectionRec} from 'app/client/models/DocModel'; | ||||
| import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel'; | ||||
| import {ShareRec} from 'app/client/models/entities/ShareRec'; | ||||
| import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec'; | ||||
| import {SortedRowSet} from 'app/client/models/rowset'; | ||||
| import {getColumnTypes as getNewColumnTypes} from 'app/client/ui/GridViewMenus'; | ||||
| import {showTransientTooltip} from 'app/client/ui/tooltips'; | ||||
| import {cssButton} from 'app/client/ui2018/buttons'; | ||||
| import {icon} from 'app/client/ui2018/icons'; | ||||
| import * as menus from 'app/client/ui2018/menus'; | ||||
| import {confirmModal} from 'app/client/ui2018/modals'; | ||||
| import {not} from 'app/common/gutil'; | ||||
| import {INITIAL_FIELDS_COUNT} from "app/common/Forms"; | ||||
| import {Events as BackboneEvents} from 'backbone'; | ||||
| import {Computed, dom, Holder, IDisposableOwner, IDomArgs, MultiHolder, Observable} from 'grainjs'; | ||||
| import {Computed, dom, Holder, IDomArgs, Observable} from 'grainjs'; | ||||
| import defaults from 'lodash/defaults'; | ||||
| import isEqual from 'lodash/isEqual'; | ||||
| import {v4 as uuidv4} from 'uuid'; | ||||
| import * as ko from 'knockout'; | ||||
| 
 | ||||
| const t = makeT('FormView'); | ||||
| 
 | ||||
| @ -38,8 +36,8 @@ export class FormView extends Disposable { | ||||
|   public viewPane: HTMLElement; | ||||
|   public gristDoc: GristDoc; | ||||
|   public viewSection: ViewSectionRec; | ||||
|   public isEdit: Observable<boolean>; | ||||
|   public selectedBox: Observable<BoxModel | null>; | ||||
|   public selectedColumns: ko.Computed<ViewFieldRec[]>|null; | ||||
| 
 | ||||
|   protected sortedRows: SortedRowSet; | ||||
|   protected tableModel: DataTableModel; | ||||
| @ -60,12 +58,10 @@ export class FormView extends Disposable { | ||||
| 
 | ||||
|   public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) { | ||||
|     BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false}); | ||||
|     this.isEdit = Observable.create(this, true); | ||||
|     this.menuHolder = Holder.create(this); | ||||
| 
 | ||||
|     this.selectedBox = Observable.create(this, null); | ||||
|     this.bundle = (clb) => this.gristDoc.docData.bundleActions('Saving form layout', clb, {nestInActiveBundle: true}); | ||||
| 
 | ||||
|     this.selectedBox = Observable.create(this, null); | ||||
| 
 | ||||
|     this.selectedBox.addListener((v) => { | ||||
|       if (!v) { return; } | ||||
| @ -76,34 +72,49 @@ export class FormView extends Disposable { | ||||
|       this.cursor.setCursorPos({fieldIndex}); | ||||
|     }); | ||||
| 
 | ||||
|     this.selectedColumns = this.autoDispose(ko.pureComputed(() => { | ||||
|       const result = this.viewSection.viewFields().all().filter((field, index) => { | ||||
|         // During column removal or restoring (with undo), some columns fields
 | ||||
|         // might be disposed.
 | ||||
|         if (field.isDisposed() || field.column().isDisposed()) { return false; } | ||||
|         return this.cursor.currentPosition().fieldIndex === index; | ||||
|       }); | ||||
|       return result; | ||||
|     })); | ||||
| 
 | ||||
|     // Wire up selected fields to the cursor.
 | ||||
|     this.autoDispose(this.selectedColumns.subscribe((columns) => { | ||||
|       this.viewSection.selectedFields(columns); | ||||
|     })); | ||||
|     this.viewSection.selectedFields(this.selectedColumns.peek()); | ||||
| 
 | ||||
| 
 | ||||
|     this._autoLayout = Computed.create(this, use => { | ||||
|       // If the layout is already there, don't do anything.
 | ||||
|       const existing = use(this.viewSection.layoutSpecObj); | ||||
|       if (!existing || !existing.id) { | ||||
|         // Else create a temporary one.
 | ||||
|         const fields = use(use(this.viewSection.viewFields).getObservable()); | ||||
|         const children: Box[] = fields.map(f => { | ||||
|           return { | ||||
|             type: 'Field', | ||||
|             leaf: use(f.id), | ||||
|           }; | ||||
|         }); | ||||
|         children.push({type: 'Submit'}); | ||||
|         return { | ||||
|           type: 'Layout', | ||||
|           children, | ||||
|         }; | ||||
|         return this._formTemplate(fields); | ||||
|       } | ||||
|       return existing; | ||||
|     }); | ||||
| 
 | ||||
|     this._root = this.autoDispose(new LayoutModel(this._autoLayout.get(), null, async () => { | ||||
|       await this._saveNow(); | ||||
|     this._root = this.autoDispose(new LayoutModel(this._autoLayout.get(), null, async (clb?: () => Promise<void>) => { | ||||
|       await this.bundle(async () => { | ||||
|         // If the box is autogenerated we need to save it first.
 | ||||
|         if (!this.viewSection.layoutSpecObj.peek()?.id) { | ||||
|           await this.save(); | ||||
|         } | ||||
|         if (clb) { | ||||
|           await clb(); | ||||
|         } | ||||
|         await this.save(); | ||||
|       }); | ||||
|     }, this)); | ||||
| 
 | ||||
|     this._autoLayout.addListener((v) => { | ||||
|       if (this._saving) { | ||||
|         console.error('Layout changed while saving'); | ||||
|         console.warn('Layout changed while saving'); | ||||
|         return; | ||||
|       } | ||||
|       // When the layout has changed, we will update the root, but only when it is not the same
 | ||||
| @ -140,20 +151,17 @@ export class FormView extends Disposable { | ||||
|           } else { | ||||
|             this.selectedBox.set(this.selectedBox.get()!.insertBefore(boxInClipboard)); | ||||
|           } | ||||
| 
 | ||||
|           // Remove the orginal box from the clipboard.
 | ||||
|           const cutted = this._root.find(boxInClipboard.id); | ||||
|           cutted?.removeSelf(); | ||||
| 
 | ||||
|           // Remove the original box from the clipboard.
 | ||||
|           const cut = this._root.get(boxInClipboard.id); | ||||
|           cut?.removeSelf(); | ||||
|           await this._root.save(); | ||||
| 
 | ||||
|           await navigator.clipboard.writeText(''); | ||||
|         }; | ||||
|         doPast().catch(reportError); | ||||
|       }, | ||||
|       nextField: () => { | ||||
|         const current = this.selectedBox.get(); | ||||
|         const all = [...this._root.list()]; | ||||
|         const all = [...this._root.iterate()]; | ||||
|         if (!all.length) { return; } | ||||
|         if (!current) { | ||||
|           this.selectedBox.set(all[0]); | ||||
| @ -168,7 +176,7 @@ export class FormView extends Disposable { | ||||
|       }, | ||||
|       prevField: () => { | ||||
|         const current = this.selectedBox.get(); | ||||
|         const all = [...this._root.list()]; | ||||
|         const all = [...this._root.iterate()]; | ||||
|         if (!all.length) { return; } | ||||
|         if (!current) { | ||||
|           this.selectedBox.set(all[all.length - 1]); | ||||
| @ -182,12 +190,12 @@ export class FormView extends Disposable { | ||||
|         } | ||||
|       }, | ||||
|       lastField: () => { | ||||
|         const all = [...this._root.list()]; | ||||
|         const all = [...this._root.iterate()]; | ||||
|         if (!all.length) { return; } | ||||
|         this.selectedBox.set(all[all.length - 1]); | ||||
|       }, | ||||
|       firstField: () => { | ||||
|         const all = [...this._root.list()]; | ||||
|         const all = [...this._root.iterate()]; | ||||
|         if (!all.length) { return; } | ||||
|         this.selectedBox.set(all[0]); | ||||
|       }, | ||||
| @ -204,39 +212,74 @@ export class FormView extends Disposable { | ||||
|           await selected.deleteSelf(); | ||||
|         }).catch(reportError); | ||||
|       }, | ||||
|       insertFieldBefore: (type: {field: BoxType} | {structure: BoxType}) => { | ||||
|       hideFields: (colId: [string]) => { | ||||
|         // Get the ref from colId.
 | ||||
|         const existing: Array<[number, string]> = | ||||
|           this.viewSection.viewFields().all().map(f => [f.id(), f.column().colId()]); | ||||
|         const ref = existing.filter(([_, c]) => colId.includes(c)).map(([r, _]) => r); | ||||
|         if (!ref.length) { return; } | ||||
|         const box = Array.from(this._root.filter(b => ref.includes(b.prop('leaf')?.get()))); | ||||
|         box.forEach(b => b.removeSelf()); | ||||
|         this._root.save(async () => { | ||||
|           await this.viewSection.removeField(ref); | ||||
|         }).catch(reportError); | ||||
|       }, | ||||
|       insertFieldBefore: (what: NewBox) => { | ||||
|         const selected = this.selectedBox.get(); | ||||
|         if (!selected) { return; } | ||||
|         if ('field' in type) { | ||||
|           this.addNewQuestion(selected.placeBeforeMe(), type.field).catch(reportError); | ||||
|         if ('add' in what || 'show' in what) { | ||||
|           this.addNewQuestion(selected.placeBeforeMe(), what).catch(reportError); | ||||
|         } else { | ||||
|           selected.insertBefore(components.defaultElement(type.structure)); | ||||
|           selected.insertBefore(components.defaultElement(what.structure)); | ||||
|         } | ||||
|       }, | ||||
|       insertFieldAfter: (type: {field: BoxType} | {structure: BoxType}) => { | ||||
|       insertField: (what: NewBox) => { | ||||
|         const selected = this.selectedBox.get(); | ||||
|         if (!selected) { return; } | ||||
|         if ('field' in type) { | ||||
|           this.addNewQuestion(selected.placeAfterMe(), type.field).catch(reportError); | ||||
|         const place = selected.placeAfterListChild(); | ||||
|         if ('add' in what || 'show' in what) { | ||||
|           this.addNewQuestion(place, what).catch(reportError); | ||||
|         } else { | ||||
|           selected.insertAfter(components.defaultElement(type.structure)); | ||||
|           place(components.defaultElement(what.structure)); | ||||
|           this.save().catch(reportError); | ||||
|         } | ||||
|       }, | ||||
|       insertFieldAfter: (what: NewBox) => { | ||||
|         const selected = this.selectedBox.get(); | ||||
|         if (!selected) { return; } | ||||
|         if ('add' in what || 'show' in what) { | ||||
|           this.addNewQuestion(selected.placeAfterMe(), what).catch(reportError); | ||||
|         } else { | ||||
|           selected.insertAfter(components.defaultElement(what.structure)); | ||||
|         } | ||||
|       }, | ||||
|       showColumns: (colIds: string[]) => { | ||||
|         this.bundle(async () => { | ||||
|         // Sanity check that type is correct.
 | ||||
|         if (!colIds.every(c => typeof c === 'string')) { throw new Error('Invalid column id'); } | ||||
|         this._root.save(async () => { | ||||
|           const boxes: Box[] = []; | ||||
|           for (const colId of colIds) { | ||||
|             const fieldRef = await this.viewSection.showColumn(colId); | ||||
|             const field = this.viewSection.viewFields().all().find(f => f.getRowId() === fieldRef); | ||||
|             if (!field) { continue; } | ||||
|             const box = { | ||||
|               type: field.pureType.peek() as BoxType, | ||||
|               leaf: fieldRef, | ||||
|               type: 'Field' as BoxType, | ||||
|             }; | ||||
|             boxes.push(box); | ||||
|           } | ||||
|           boxes.forEach(b => this._root.append(b)); | ||||
|           await this._saveNow(); | ||||
|           // Add to selected or last section, or root.
 | ||||
|           const selected = this.selectedBox.get(); | ||||
|           if (selected instanceof components.SectionModel) { | ||||
|             boxes.forEach(b => selected.append(b)); | ||||
|           } else { | ||||
|             const topLevel = this._root.kids().reverse().find(b => b instanceof components.SectionModel); | ||||
|             if (topLevel) { | ||||
|               boxes.forEach(b => topLevel.append(b)); | ||||
|             } else { | ||||
|               boxes.forEach(b => this._root.append(b)); | ||||
|             } | ||||
|           } | ||||
|         }).catch(reportError); | ||||
|       }, | ||||
|     }; | ||||
| @ -250,6 +293,7 @@ export class FormView extends Disposable { | ||||
|       shiftUp: keyboardActions.firstField, | ||||
|       editField: keyboardActions.edit, | ||||
|       deleteFields: keyboardActions.clearValues, | ||||
|       hideFields: keyboardActions.hideFields, | ||||
|     }, this, this.viewSection.hasFocus)); | ||||
| 
 | ||||
|     this._url = Computed.create(this, use => { | ||||
| @ -273,8 +317,15 @@ export class FormView extends Disposable { | ||||
|     this._remoteShare = AsyncComputed.create(this, async (use) => { | ||||
|       const share = use(this._pageShare); | ||||
|       if (!share) { return null; } | ||||
|       const remoteShare = await this.gristDoc.docComm.getShare(use(share.linkId)); | ||||
|       return remoteShare ?? null; | ||||
|       try { | ||||
|         const remoteShare = await this.gristDoc.docComm.getShare(use(share.linkId)); | ||||
|         return remoteShare ?? null; | ||||
|       } catch(ex) { | ||||
|         // TODO: for now ignore the error, but the UI should be updated to not show editor
 | ||||
|         // for non owners.
 | ||||
|         if (ex.code === 'AUTH_NO_OWNER') { return null; } | ||||
|         throw ex; | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     this._published = Computed.create(this, use => { | ||||
| @ -306,374 +357,60 @@ export class FormView extends Disposable { | ||||
|   } | ||||
| 
 | ||||
|   public buildDom() { | ||||
|     return dom('div.flexauto.flexvbox', | ||||
|       style.cssFormEdit.cls('-preview', not(this.isEdit)), | ||||
|       style.cssFormEdit.cls('', this.isEdit), | ||||
|       testId('preview', not(this.isEdit)), | ||||
|       testId('editor', this.isEdit), | ||||
| 
 | ||||
|       dom.maybe(this.isEdit, () => style.cssFormEditBody( | ||||
|     return  style.cssFormView( | ||||
|       testId('editor'), | ||||
|       style.cssFormEditBody( | ||||
|         style.cssFormContainer( | ||||
|           dom.forEach(this._root.children, (child) => { | ||||
|             if (!child) { | ||||
|               // This shouldn't happen, and it is bad design, as columns allow nulls, where other container
 | ||||
|               // don't. But for now, just ignore it.
 | ||||
|               return dom('div', 'Empty node'); | ||||
|             } | ||||
|             const element = this.renderBox(this._root.children, child); | ||||
|             if (Array.isArray(element)) { | ||||
|               throw new Error('Element is an array'); | ||||
|             } | ||||
|             if (!(element instanceof HTMLElement)) { | ||||
|             const element = child.render(); | ||||
|             if (!(element instanceof Node)) { | ||||
|               throw new Error('Element is not an HTMLElement'); | ||||
|             } | ||||
|             return element; | ||||
|           }), | ||||
|           this.buildDropzone(this, this._root.placeAfterListChild()), | ||||
|           this._buildPublisher(), | ||||
|         ), | ||||
|       )), | ||||
|       dom.maybe(not(this.isEdit), () => [ | ||||
|         style.cssPreview( | ||||
|           dom.prop('src', this._url), | ||||
|         ) | ||||
|       ]), | ||||
|       this._buildSwitcher(), | ||||
|       ), | ||||
|       dom.on('click', () => this.selectedBox.set(null)) | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   public renderBox(owner: IDisposableOwner, box: BoxModel, ...args: IDomArgs<HTMLElement>): HTMLElement { | ||||
|     const overlay = Observable.create(owner, true); | ||||
| 
 | ||||
|     return this.buildEditor(owner, {box, overlay}, | ||||
|       dom.domComputedOwned(box.type, (scope, type) => { | ||||
|         const renderedElement = box.render({overlay}); | ||||
|         const element = renderedElement; | ||||
|         return dom.update( | ||||
|           element, | ||||
|           testId('element'), | ||||
|           testId(box.type), | ||||
|           ...args, | ||||
|         ); | ||||
|       }) | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   public buildDropzone(owner: IDisposableOwner, insert: Place, ...args: IDomArgs) { | ||||
|     const dragHover = Observable.create(owner, false); | ||||
|     const forceShow = Observable.create(owner, false); | ||||
|     return style.cssAddElement( | ||||
|       testId('dropzone'), | ||||
|       style.cssDrag(), | ||||
|       style.cssAddText(), | ||||
|       this.buildAddMenu(insert, { | ||||
|         onOpen: () => forceShow.set(true), | ||||
|         onClose: () => forceShow.set(false), | ||||
|       }), | ||||
|       style.cssAddElement.cls('-hover', use => use(dragHover)), | ||||
|       // And drop zone handlers
 | ||||
|       dom.on('drop', async (ev) => { | ||||
|         ev.stopPropagation(); | ||||
|         ev.preventDefault(); | ||||
|         dragHover.set(false); | ||||
| 
 | ||||
|         // Get the box that was dropped.
 | ||||
|         const dropped = parseBox(ev.dataTransfer!.getData('text/plain')); | ||||
| 
 | ||||
|         // We need to remove it from the parent, so find it first.
 | ||||
|         const droppedId = dropped.id; | ||||
| 
 | ||||
|         const droppedRef = this._root.find(droppedId); | ||||
| 
 | ||||
|         await this.bundle(async () => { | ||||
|           // Save the layout if it is not saved yet.
 | ||||
|           await this._saveNow(); | ||||
|           // Remove the orginal box from the clipboard.
 | ||||
|           droppedRef?.removeSelf(); | ||||
|           await insert(dropped).onDrop(); | ||||
| 
 | ||||
|           // Save the change.
 | ||||
|           await this._saveNow(); | ||||
|         }); | ||||
|       }), | ||||
|       dom.on('dragover', (ev) => { | ||||
|         ev.preventDefault(); | ||||
|         ev.dataTransfer!.dropEffect = "move"; | ||||
|         dragHover.set(true); | ||||
|       }), | ||||
|       dom.on('dragleave', (ev) => { | ||||
|         ev.preventDefault(); | ||||
|         dragHover.set(false); | ||||
|       }), | ||||
|       style.cssAddElement.cls('-hover', dragHover), | ||||
|       ...args, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   public buildFieldPanel() { | ||||
|     return dom('div', 'Hello there'); | ||||
|   } | ||||
| 
 | ||||
|   public buildEditor( | ||||
|     owner: IDisposableOwner | null, | ||||
|     options: { | ||||
|       box: BoxModel, | ||||
|       overlay: Observable<boolean> | ||||
|     } | ||||
|     , | ||||
|     ...args: IDomArgs | ||||
|   ) { | ||||
|     const {box, overlay} = options; | ||||
|     const myOwner = new MultiHolder(); | ||||
|     if (owner) { | ||||
|       owner.autoDispose(myOwner); | ||||
|     } | ||||
| 
 | ||||
|     let element: HTMLElement; | ||||
|     const dragHover = Observable.create(myOwner, false); | ||||
| 
 | ||||
|     myOwner.autoDispose(this.selectedBox.addListener(v => { | ||||
|       if (v !== box) { return; } | ||||
|       if (!element) { return; } | ||||
|       element.scrollIntoView({behavior: 'smooth', block: 'center', inline: 'center'}); | ||||
|     })); | ||||
| 
 | ||||
|     const isSelected = Computed.create(myOwner, use => { | ||||
|       if (!this.viewSection || this.viewSection.isDisposed()) { return false; } | ||||
|       if (use(this.selectedBox) === box) { | ||||
|         // We are only selected when the section is also selected.
 | ||||
|         return use(this.viewSection.hasFocus); | ||||
|       } | ||||
|       return false; | ||||
|     }); | ||||
| 
 | ||||
|     return style.cssFieldEditor( | ||||
|       testId('editor'), | ||||
|       style.cssDrag(), | ||||
| 
 | ||||
|       dom.maybe(overlay, () => this.buildOverlay(myOwner, box)), | ||||
| 
 | ||||
|       owner ? null : dom.autoDispose(myOwner), | ||||
|       (el) => { element = el; }, | ||||
|       // Control panel
 | ||||
|       style.cssControls( | ||||
|         style.cssControlsLabel(dom.text(box.type)), | ||||
|       ), | ||||
| 
 | ||||
|       // Turn on active like state when we clicked here.
 | ||||
|       style.cssFieldEditor.cls('-selected', isSelected), | ||||
|       style.cssFieldEditor.cls('-cut', use => use(box.cut)), | ||||
|       testId('field-editor-selected', isSelected), | ||||
| 
 | ||||
|       // Select on click.
 | ||||
|       (el) => { | ||||
|         dom.onElem(el, 'click', (ev) => { | ||||
|           // Only if the click was in this element.
 | ||||
|           const target = ev.target as HTMLElement; | ||||
|           if (!target.closest) { return; } | ||||
|           // Make sure that the closest editor is this one.
 | ||||
|           const closest = target.closest(`.${style.cssFieldEditor.className}`); | ||||
|           if (closest !== el) { return; } | ||||
| 
 | ||||
|           // It looks like we clicked somewhere in this editor, and not inside any other inside.
 | ||||
|           this.selectedBox.set(box); | ||||
|           ev.stopPropagation(); | ||||
|           ev.preventDefault(); | ||||
|           ev.stopImmediatePropagation(); | ||||
|         }); | ||||
|       }, | ||||
| 
 | ||||
|       // Attach menu
 | ||||
|       menus.menu((ctl) => { | ||||
|         this.menuHolder.autoDispose(ctl); | ||||
|         this.selectedBox.set(box); | ||||
|         const field = (type: string) => ({field: type}); | ||||
|         const struct = (structure: string) => ({structure}); | ||||
|         const above = (el: {field: string} | {structure: string}) => () => allCommands.insertFieldBefore.run(el); | ||||
|         const below: typeof above = (el) => () => allCommands.insertFieldAfter.run(el); | ||||
|         const quick = ['Text', 'Numeric', 'Choice', 'Date']; | ||||
|         const commonTypes = () => getNewColumnTypes(this.gristDoc, this.viewSection.tableId()); | ||||
|         const isQuick = ({colType}: {colType: string}) => quick.includes(colType); | ||||
|         const notQuick = ({colType}: {colType: string}) => !quick.includes(colType); | ||||
|         const insertMenu = (where: typeof above) => () => { | ||||
|           return [ | ||||
|             menus.menuSubHeader('New question'), | ||||
|             ...commonTypes() | ||||
|               .filter(isQuick) | ||||
|               .map(ct => menus.menuItem(where(field(ct.colType)), menus.menuIcon(ct.icon!), ct.displayName)) | ||||
|             , | ||||
|             menus.menuItemSubmenu( | ||||
|               () => commonTypes() | ||||
|                 .filter(notQuick) | ||||
|                 .map(ct => menus.menuItem(where(field(ct.colType)), menus.menuIcon(ct.icon!), ct.displayName)), | ||||
|               {}, | ||||
|               menus.menuIcon('Dots'), | ||||
|               dom('span', "More", dom.style('margin-right', '8px')) | ||||
|             ), | ||||
|             menus.menuDivider(), | ||||
|             menus.menuSubHeader('Static element'), | ||||
|             menus.menuItem(where(struct('Section')), menus.menuIcon('Page'), "Section",), | ||||
|             menus.menuItem(where(struct('Columns')), menus.menuIcon('TypeCell'), "Columns"), | ||||
|             menus.menuItem(where(struct('Paragraph')), menus.menuIcon('Page'), "Paragraph",), | ||||
|             // menus.menuItem(where(struct('Button')),    menus.menuIcon('Tick'), "Button",  ),
 | ||||
|           ]; | ||||
|         }; | ||||
| 
 | ||||
| 
 | ||||
|         return [ | ||||
|           menus.menuItemSubmenu(insertMenu(above), {action: above(field('Text'))}, "Insert question above"), | ||||
|           menus.menuItemSubmenu(insertMenu(below), {action: below(field('Text'))}, "Insert question below"), | ||||
|           menus.menuDivider(), | ||||
|           menus.menuItemCmd(allCommands.contextMenuCopy, "Copy"), | ||||
|           menus.menuItemCmd(allCommands.contextMenuCut, "Cut"), | ||||
|           menus.menuItemCmd(allCommands.contextMenuPaste, "Paste"), | ||||
|           menus.menuDivider(), | ||||
|           menus.menuItemCmd(allCommands.deleteFields, "Hide"), | ||||
|         ]; | ||||
|       }, {trigger: ['contextmenu']}), | ||||
| 
 | ||||
|       dom.on('contextmenu', (ev) => { | ||||
|         ev.stopPropagation(); | ||||
|         ev.preventDefault(); | ||||
|       }), | ||||
| 
 | ||||
|       // And now drag and drop support.
 | ||||
|       {draggable: "true"}, | ||||
| 
 | ||||
|       // When started, we just put the box into the dataTransfer as a plain text.
 | ||||
|       // TODO: this might be very sofisticated in the future.
 | ||||
|       dom.on('dragstart', (ev) => { | ||||
|         // Prevent propagation, as we might be in a nested editor.
 | ||||
|         ev.stopPropagation(); | ||||
|         ev.dataTransfer?.setData('text/plain', JSON.stringify(box.toJSON())); | ||||
|         ev.dataTransfer!.dropEffect = "move"; | ||||
|       }), | ||||
| 
 | ||||
|       dom.on('dragover', (ev) => { | ||||
|         // As usual, prevent propagation.
 | ||||
|         ev.stopPropagation(); | ||||
|         ev.preventDefault(); | ||||
|         // Here we just change the style of the element.
 | ||||
|         ev.dataTransfer!.dropEffect = "move"; | ||||
|         dragHover.set(true); | ||||
|       }), | ||||
| 
 | ||||
|       dom.on('dragleave', (ev) => { | ||||
|         ev.stopPropagation(); | ||||
|         ev.preventDefault(); | ||||
|         // Just remove the style and stop propagation.
 | ||||
|         dragHover.set(false); | ||||
|       }), | ||||
| 
 | ||||
|       dom.on('drop', async (ev) => { | ||||
|         ev.stopPropagation(); | ||||
|         ev.preventDefault(); | ||||
|         dragHover.set(false); | ||||
|         const dropped = parseBox(ev.dataTransfer!.getData('text/plain')); | ||||
|         // We need to remove it from the parent, so find it first.
 | ||||
|         const droppedId = dropped.id; | ||||
|         if (droppedId === box.id) { return; } | ||||
|         const droppedRef = this._root.find(droppedId); | ||||
|         await this.bundle(async () => { | ||||
|           await this._root.save(); | ||||
|           droppedRef?.removeSelf(); | ||||
|           await box.drop(dropped)?.onDrop(); | ||||
|           await this._saveNow(); | ||||
|         }); | ||||
|       }), | ||||
| 
 | ||||
|       style.cssFieldEditor.cls('-drag-hover', dragHover), | ||||
| 
 | ||||
|       ...args, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   public buildOverlay(owner: IDisposableOwner, box: BoxModel) { | ||||
|   public buildOverlay(...args: IDomArgs) { | ||||
|     return style.cssSelectedOverlay( | ||||
|       ...args, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   public async addNewQuestion(insert: Place, type: string) { | ||||
|   public async addNewQuestion(insert: Place, action: {add: string}|{show: string}) { | ||||
|     await this.gristDoc.docData.bundleActions(`Saving form layout`, async () => { | ||||
|       // First save the layout, so that
 | ||||
|       await this._saveNow(); | ||||
|       // Now that the layout is saved, we won't be bottered with autogenerated layout,
 | ||||
|       // First save the layout, so that we don't have autogenerated layout.
 | ||||
|       await this.save(); | ||||
|       // Now that the layout is saved, we won't be bothered with autogenerated layout,
 | ||||
|       // and we can safely insert to column.
 | ||||
|       const {fieldRef} = await this.insertColumn(null, { | ||||
|         colInfo: { | ||||
|           type, | ||||
|         } | ||||
|       }); | ||||
| 
 | ||||
|       let fieldRef = 0; | ||||
|       if ('show' in action) { | ||||
|         fieldRef = await this.showColumn(action.show); | ||||
|       } else { | ||||
|         const result = await this.insertColumn(null, { | ||||
|           colInfo: { | ||||
|             type: action.add, | ||||
|           } | ||||
|         }); | ||||
|         fieldRef = result.fieldRef; | ||||
|       } | ||||
|       // And add it into the layout.
 | ||||
|       this.selectedBox.set(insert({ | ||||
|         leaf: fieldRef, | ||||
|         type: 'Field' | ||||
|       })); | ||||
| 
 | ||||
|       await this._root.save(); | ||||
|     }, {nestInActiveBundle: true}); | ||||
|   } | ||||
| 
 | ||||
|   public buildAddMenu(insert: Place, { | ||||
|     onClose: onClose = () => {}, | ||||
|     onOpen: onOpen = () => {}, | ||||
|     customItems = [] as Element[], | ||||
|   } = {}) { | ||||
|     return menus.menu( | ||||
|       (ctl) => { | ||||
|         onOpen(); | ||||
|         ctl.onDispose(onClose); | ||||
| 
 | ||||
|         const field = (colType: BoxType) => ({field: colType}); | ||||
|         const struct = (structure: BoxType) => ({structure}); | ||||
|         const where = (el: {field: string} | {structure: BoxType}) => () => { | ||||
|           if ('field' in el) { | ||||
|             return this.addNewQuestion(insert, el.field); | ||||
|           } else { | ||||
|             insert(components.defaultElement(el.structure)); | ||||
|             return this._root.save(); | ||||
|           } | ||||
|         }; | ||||
|         const quick = ['Text', 'Numeric', 'Choice', 'Date']; | ||||
|         const commonTypes = () => getNewColumnTypes(this.gristDoc, this.viewSection.tableId()); | ||||
|         const isQuick = ({colType}: {colType: string}) => quick.includes(colType); | ||||
|         const notQuick = ({colType}: {colType: string}) => !quick.includes(colType); | ||||
|         return [ | ||||
|           menus.menuSubHeader('New question'), | ||||
|           ...commonTypes() | ||||
|             .filter(isQuick) | ||||
|             .map(ct => menus.menuItem(where(field(ct.colType as BoxType)), menus.menuIcon(ct.icon!), ct.displayName)) | ||||
|           , | ||||
|           menus.menuItemSubmenu( | ||||
|             () => commonTypes() | ||||
|               .filter(notQuick) | ||||
|               .map(ct => menus.menuItem(where(field(ct.colType as BoxType)), menus.menuIcon(ct.icon!), ct.displayName)), | ||||
|             {}, | ||||
|             menus.menuIcon('Dots'), | ||||
|             dom('span', "More", dom.style('margin-right', '8px')) | ||||
|           ), | ||||
|           menus.menuDivider(), | ||||
|           menus.menuSubHeader('Static element'), | ||||
|           menus.menuItem(where(struct('Section')), menus.menuIcon('Page'), "Section",), | ||||
|           menus.menuItem(where(struct('Columns')), menus.menuIcon('TypeCell'), "Columns"), | ||||
|           menus.menuItem(where(struct('Paragraph')), menus.menuIcon('Page'), "Paragraph",), | ||||
|           // menus.menuItem(where(struct('Button')),    menus.menuIcon('Tick'), "Button",  ),
 | ||||
|           elem => void FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}), | ||||
|           customItems.length ? menus.menuDivider(dom.style('min-width', '200px')) : null, | ||||
|           ...customItems, | ||||
|         ]; | ||||
|       }, | ||||
|       { | ||||
|         selectOnOpen: true, | ||||
|         trigger: [ | ||||
|           'click', | ||||
|         ], | ||||
|       } | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   private async _saveNow() { | ||||
|   public async save() { | ||||
|     try { | ||||
|       this._saving = true; | ||||
|       const newVersion = {...this._root.toJSON()}; | ||||
| @ -690,13 +427,27 @@ export class FormView extends Disposable { | ||||
|     confirmModal(t('Publish your form?'), | ||||
|       t('Publish'), | ||||
|       async () => { | ||||
|         await this.gristDoc.docModel.docData.bundleActions('Publish form', async () => { | ||||
|           const page = this.viewSection.view().page(); | ||||
|           if (!page) { | ||||
|             throw new Error('Unable to publish form: undefined page'); | ||||
|         const page = this.viewSection.view().page(); | ||||
|         if (!page) { | ||||
|           throw new Error('Unable to publish form: undefined page'); | ||||
|         } | ||||
|         let validShare = page.shareRef() !== 0; | ||||
|         // If page is shared, make sure home server is aware of it.
 | ||||
|         if (validShare) { | ||||
|           try { | ||||
|           const pageShare = page.share(); | ||||
|           const serverShare = await this.gristDoc.docComm.getShare(pageShare.linkId()); | ||||
|           validShare = !!serverShare; | ||||
|           } catch(ex) { | ||||
|             // TODO: for now ignore the error, but the UI should be updated to not show editor
 | ||||
|             if (ex.code === 'AUTH_NO_OWNER') { | ||||
|               return; | ||||
|             } | ||||
|             throw ex; | ||||
|           } | ||||
| 
 | ||||
|           if (page.shareRef() === 0) { | ||||
|         } | ||||
|         await this.gristDoc.docModel.docData.bundleActions('Publish form', async () => { | ||||
|           if (!validShare) { | ||||
|             const shareRef = await this.gristDoc.docModel.docData.sendAction([ | ||||
|               'AddRecord', | ||||
|               '_grist_Shares', | ||||
| @ -715,6 +466,7 @@ export class FormView extends Disposable { | ||||
|             await share.optionsObj.save(); | ||||
|           } | ||||
| 
 | ||||
|           await this.save(); | ||||
|           this.viewSection.shareOptionsObj.update({ | ||||
|             form: true, | ||||
|             publish: true, | ||||
| @ -780,31 +532,34 @@ export class FormView extends Disposable { | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|   private _buildSwitcher() { | ||||
| 
 | ||||
|     const toggle = (val: boolean) => () => { | ||||
|       this.isEdit.set(val); | ||||
|       this._saveNow().catch(reportError); | ||||
|     }; | ||||
| 
 | ||||
|   private _buildPublisher() { | ||||
|     return style.cssSwitcher( | ||||
|       this._buildSwitcherMessage(), | ||||
|       style.cssButtonGroup( | ||||
|         style.cssIconButton( | ||||
|           icon('Pencil'), | ||||
|           testId('edit'), | ||||
|           dom('div', 'Editor'), | ||||
|           cssButton.cls('-primary', this.isEdit), | ||||
|           style.cssIconButton.cls('-standard', not(this.isEdit)), | ||||
|           dom.on('click', toggle(true)) | ||||
|           style.cssIconButton.cls('-frameless'), | ||||
|           icon('Revert'), | ||||
|           testId('reset'), | ||||
|           dom('div', 'Reset form'), | ||||
|           dom.style('margin-right', 'auto'), // move it to the left
 | ||||
|           dom.on('click', () => { | ||||
|             this._resetForm().catch(reportError); | ||||
|           }) | ||||
|         ), | ||||
|         style.cssIconButton( | ||||
|           icon('EyeShow'), | ||||
|           dom('div', 'Preview'), | ||||
|         style.cssIconLink( | ||||
|           testId('preview'), | ||||
|           cssButton.cls('-primary', not(this.isEdit)), | ||||
|           style.cssIconButton.cls('-standard', (this.isEdit)), | ||||
|           dom.on('click', toggle(false)) | ||||
|           icon('EyeShow'), | ||||
|           dom.text('Preview'), | ||||
|           dom.prop('href', this._url), | ||||
|           dom.prop('target', '_blank'), | ||||
|           dom.on('click', async (ev) => { | ||||
|             // If this form is not yet saved, we will save it first.
 | ||||
|             if (!this._savedLayout) { | ||||
|               stopEvent(ev); | ||||
|               await this.save(); | ||||
|               window.open(this._url.get()); | ||||
|             } | ||||
|           }) | ||||
|         ), | ||||
|         style.cssIconButton( | ||||
|           icon('FieldAttachment'), | ||||
| @ -831,6 +586,10 @@ export class FormView extends Disposable { | ||||
|               }); | ||||
|               await copyToClipboard(url); | ||||
|               showTransientTooltip(element, 'Link copied to clipboard', {key: 'copy-form-link'}); | ||||
|             } catch(ex) { | ||||
|               if (ex.code === 'AUTH_NO_OWNER') { | ||||
|                 throw new Error('Publishing form is only available to owners'); | ||||
|               } | ||||
|             } finally { | ||||
|               this._copyingLink.set(false); | ||||
|             } | ||||
| @ -876,8 +635,81 @@ export class FormView extends Disposable { | ||||
|       ); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Generates a form template based on the fields in the view section. | ||||
|    */ | ||||
|   private _formTemplate(fields: ViewFieldRec[]) { | ||||
|     const boxes: Box[] = fields.map(f => { | ||||
|       return { | ||||
|         type: 'Field', | ||||
|         leaf: f.id() | ||||
|       } as Box; | ||||
|     }); | ||||
|     const section = { | ||||
|       type: 'Section', | ||||
|       children: [ | ||||
|         {type: 'Paragraph', text: SECTION_TITLE}, | ||||
|         {type: 'Paragraph', text: SECTION_DESC}, | ||||
|         ...boxes, | ||||
|       ], | ||||
|     }; | ||||
|     return { | ||||
|       type: 'Layout', | ||||
|       children: [ | ||||
|         {type: 'Paragraph', text: FORM_TITLE, alignment: 'center', }, | ||||
|         {type: 'Paragraph', text: FORM_DESC, alignment: 'center', }, | ||||
|         section, | ||||
|         {type: 'Submit'} | ||||
|       ] | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   private async _resetForm() { | ||||
|     this.selectedBox.set(null); | ||||
|     await this.gristDoc.docData.bundleActions('Reset form', async () => { | ||||
|       // First we will remove all fields from this section, and add top 9 back.
 | ||||
|       const toDelete = this.viewSection.viewFields().all().map(f => f.getRowId()); | ||||
| 
 | ||||
|       const toAdd = this.viewSection.table().columns().peek().filter(c => { | ||||
|         // If hidden than no.
 | ||||
|         if (c.isHiddenCol()) { return false; } | ||||
| 
 | ||||
|         // If formula column, no.
 | ||||
|         if (c.isFormula() && c.formula()) { return false; } | ||||
| 
 | ||||
|         return true; | ||||
|       }); | ||||
|       toAdd.sort((a, b) => a.parentPos() - b.parentPos()); | ||||
| 
 | ||||
|       const colRef = toAdd.slice(0, INITIAL_FIELDS_COUNT).map(c => c.id()); | ||||
|       const parentId = colRef.map(() => this.viewSection.id()); | ||||
|       const parentPos = colRef.map((_, i) => i + 1); | ||||
|       const ids = colRef.map(() => null); | ||||
| 
 | ||||
|       await this.gristDoc.docData.sendActions([ | ||||
|         ['BulkRemoveRecord', '_grist_Views_section_field', toDelete], | ||||
|         ['BulkAddRecord', '_grist_Views_section_field', ids, { | ||||
|           colRef, | ||||
|           parentId, | ||||
|           parentPos, | ||||
|         }], | ||||
|       ]); | ||||
| 
 | ||||
|       const fields = this.viewSection.viewFields().all().slice(0, 9); | ||||
|       await this.viewSection.layoutSpecObj.setAndSave(this._formTemplate(fields)); | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // Getting an ES6 class to work with old-style multiple base classes takes a little hacking. Credits: ./ChartView.ts
 | ||||
| defaults(FormView.prototype, BaseView.prototype); | ||||
| Object.assign(FormView.prototype, BackboneEvents); | ||||
| 
 | ||||
| // Default values when form is reset.
 | ||||
| const FORM_TITLE = "## **My Super Form**"; | ||||
| const FORM_DESC = "This is the UI design work in progress on Grist Forms. We are working hard to " + | ||||
|                   "give you the best possible experience with this feature"; | ||||
| 
 | ||||
| const SECTION_TITLE = '### **Header**'; | ||||
| const SECTION_DESC = 'Description'; | ||||
|  | ||||
| @ -1,140 +0,0 @@ | ||||
| import {allCommands} from 'app/client/components/commands'; | ||||
| import {makeT} from 'app/client/lib/localization'; | ||||
| import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel'; | ||||
| import {cssButton} from 'app/client/ui2018/buttons'; | ||||
| import {theme, vars} from 'app/client/ui2018/cssVars'; | ||||
| import {cssDragger} from 'app/client/ui2018/draggableList'; | ||||
| import {icon} from 'app/client/ui2018/icons'; | ||||
| import {Disposable, dom, fromKo, makeTestId, styled} from 'grainjs'; | ||||
| import * as ko from 'knockout'; | ||||
| 
 | ||||
| const testId = makeTestId('test-vfc-'); | ||||
| const t = makeT('VisibleFieldsConfig'); | ||||
| 
 | ||||
| /** | ||||
|  * This is a component used in the RightPanel. It replaces hidden fields section on other views, and adds | ||||
|  * the ability to drag and drop fields onto the form. | ||||
|  */ | ||||
| export class HiddenQuestionConfig extends Disposable { | ||||
| 
 | ||||
|   constructor(private _section: ViewSectionRec) { | ||||
|     super(); | ||||
|   } | ||||
| 
 | ||||
|   public buildDom() { | ||||
|     const hiddenColumns = fromKo(this.autoDispose(ko.pureComputed(() => { | ||||
|       const fields = new Set(this._section.viewFields().map(f => f.colId()).all()); | ||||
|       return this._section.table().visibleColumns().filter(c => !fields.has(c.colId())); | ||||
|     }))); | ||||
|     return [ | ||||
|       cssHeader( | ||||
|         cssFieldListHeader(dom.text(t("Hidden fields"))), | ||||
|       ), | ||||
|       dom('div', | ||||
|         testId('hidden-fields'), | ||||
|         dom.forEach(hiddenColumns, (field) => { | ||||
|           return this._buildHiddenFieldItem(field); | ||||
|         }) | ||||
|       ) | ||||
|     ]; | ||||
|   } | ||||
| 
 | ||||
|   private _buildHiddenFieldItem(column: ColumnRec) { | ||||
|     return cssDragRow( | ||||
|       testId('hidden-field'), | ||||
|       {draggable: "true"}, | ||||
|       dom.on('dragstart', (ev) => { | ||||
|         // Prevent propagation, as we might be in a nested editor.
 | ||||
|         ev.stopPropagation(); | ||||
|         ev.dataTransfer?.setData('text/plain', JSON.stringify({ | ||||
|           type: 'Field', | ||||
|           leaf: column.colId.peek(), // TODO: convert to Field
 | ||||
|         })); | ||||
|         ev.dataTransfer!.dropEffect = "move"; | ||||
|       }), | ||||
|       cssSimpleDragger(), | ||||
|       cssFieldEntry( | ||||
|         cssFieldLabel(dom.text(column.label)), | ||||
|         cssHideIcon('EyeShow', | ||||
|           testId('hide'), | ||||
|           dom.on('click', () => { | ||||
|             allCommands.showColumns.run([column.colId.peek()]); | ||||
|           }), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| // TODO: reuse them
 | ||||
| const cssDragRow = styled('div', ` | ||||
|   display: flex !important; | ||||
|   align-items: center; | ||||
|   margin: 0 16px 0px 0px; | ||||
|   margin-bottom: 2px; | ||||
|   cursor: grab; | ||||
| `);
 | ||||
| 
 | ||||
| const cssFieldEntry = styled('div', ` | ||||
|   display: flex; | ||||
|   background-color: ${theme.hover}; | ||||
|   border-radius: 2px; | ||||
|   margin: 0 8px 0 0; | ||||
|   padding: 4px 8px; | ||||
|   cursor: default; | ||||
|   white-space: nowrap; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   flex: 1 1 auto; | ||||
| 
 | ||||
|   --icon-color: ${theme.lightText}; | ||||
| `);
 | ||||
| 
 | ||||
| const cssSimpleDragger = styled(cssDragger, ` | ||||
|   cursor: grab; | ||||
|   .${cssDragRow.className}:hover & { | ||||
|     visibility: visible; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssHideIcon = styled(icon, ` | ||||
|   --icon-color: ${theme.lightText}; | ||||
|   display: none; | ||||
|   cursor: pointer; | ||||
|   flex: none; | ||||
|   margin-right: 8px; | ||||
|   .${cssFieldEntry.className}:hover & { | ||||
|     display: block; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssFieldLabel = styled('span', ` | ||||
|   color: ${theme.text}; | ||||
|   flex: 1 1 auto; | ||||
|   text-overflow: ellipsis; | ||||
|   overflow: hidden; | ||||
| `);
 | ||||
| 
 | ||||
| const cssFieldListHeader = styled('span', ` | ||||
|   color: ${theme.text}; | ||||
|   flex: 1 1 0px; | ||||
|   font-size: ${vars.xsmallFontSize}; | ||||
|   text-transform: uppercase; | ||||
| `);
 | ||||
| 
 | ||||
| const cssRow = styled('div', ` | ||||
|   display: flex; | ||||
|   margin: 16px; | ||||
|   overflow: hidden; | ||||
|   --icon-color: ${theme.lightText}; | ||||
|   & > .${cssButton.className} { | ||||
|     margin-right: 8px; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssHeader = styled(cssRow, ` | ||||
|   align-items: center; | ||||
|   justify-content: space-between; | ||||
|   margin-bottom: 12px; | ||||
| `);
 | ||||
							
								
								
									
										85
									
								
								app/client/components/Forms/Label.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,85 @@ | ||||
| import * as css from './styles'; | ||||
| import {buildEditor} from 'app/client/components/Forms/Editor'; | ||||
| import {BoxModel} from 'app/client/components/Forms/Model'; | ||||
| import {stopEvent} from 'app/client/lib/domUtils'; | ||||
| import {not} from 'app/common/gutil'; | ||||
| import {Computed, dom, Observable} from 'grainjs'; | ||||
| 
 | ||||
| export class LabelModel extends BoxModel { | ||||
|   public edit = Observable.create(this, false); | ||||
| 
 | ||||
|   protected defaultValue = ''; | ||||
| 
 | ||||
|   public render(): HTMLElement { | ||||
|     let element: HTMLTextAreaElement; | ||||
|     const text = this.prop('text', this.defaultValue) as Observable<string|undefined>; | ||||
|     const cssClass = this.prop('cssClass', '') as Observable<string>; | ||||
|     const editableText = Observable.create(this, text.get() || ''); | ||||
|     const overlay = Computed.create(this, use => !use(this.edit)); | ||||
| 
 | ||||
|     this.autoDispose(text.addListener((v) => editableText.set(v || ''))); | ||||
| 
 | ||||
|     const save = (ok: boolean) => { | ||||
|       if (ok) { | ||||
|         text.set(editableText.get()); | ||||
|         void this.parent?.save().catch(reportError); | ||||
|       } else { | ||||
|         editableText.set(text.get() || ''); | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|     const mode = (edit: boolean) => { | ||||
|       if (this.isDisposed() || this.edit.isDisposed()) { return; } | ||||
|       if (this.edit.get() === edit) { return; } | ||||
|       this.edit.set(edit); | ||||
|     }; | ||||
| 
 | ||||
|     return buildEditor( | ||||
|       { | ||||
|         box: this, | ||||
|         editMode: this.edit, | ||||
|         overlay, | ||||
|         click: (ev) => { | ||||
|           stopEvent(ev); | ||||
|           // If selected, then edit.
 | ||||
|           if (!this.selected.get()) { return; } | ||||
|           if (document.activeElement === element) { return; } | ||||
|           editableText.set(text.get() || ''); | ||||
|           this.edit.set(true); | ||||
|           setTimeout(() => { | ||||
|             element.focus(); | ||||
|             element.select(); | ||||
|           }, 10); | ||||
|         }, | ||||
|         content: element = css.cssEditableLabel( | ||||
|           editableText, | ||||
|           {onInput: true, autoGrow: true}, | ||||
|           {placeholder: `Empty label`}, | ||||
|           dom.on('click', ev => { | ||||
|             stopEvent(ev); | ||||
|           }), | ||||
|           // Styles saved (for titles and such)
 | ||||
|           css.cssEditableLabel.cls(use => `-${use(cssClass)}`), | ||||
|           // Disable editing if not in edit mode.
 | ||||
|           dom.boolAttr('readonly', not(this.edit)), | ||||
|           // Pass edit to css.
 | ||||
|           css.cssEditableLabel.cls('-edit', this.edit), | ||||
|           // Attach default save controls (Enter, Esc) and so on.
 | ||||
|           css.saveControls(this.edit, save), | ||||
|           // Turn off resizable for textarea.
 | ||||
|           dom.style('resize', 'none'), | ||||
|         ), | ||||
|       }, | ||||
|       dom.onKeyDown({Enter$: (ev) => { | ||||
|         // If no in edit mode, change it.
 | ||||
|         if (!this.edit.get()) { | ||||
|           mode(true); | ||||
|           ev.stopPropagation(); | ||||
|           ev.stopImmediatePropagation(); | ||||
|           ev.preventDefault(); | ||||
|           return; | ||||
|         } | ||||
|       }}) | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										170
									
								
								app/client/components/Forms/Menu.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,170 @@ | ||||
| import {allCommands} from 'app/client/components/commands'; | ||||
| import {FormView} from 'app/client/components/Forms/FormView'; | ||||
| import {BoxModel, BoxType, Place} from 'app/client/components/Forms/Model'; | ||||
| import {makeTestId, stopEvent} from 'app/client/lib/domUtils'; | ||||
| import {FocusLayer} from 'app/client/lib/FocusLayer'; | ||||
| import {makeT} from 'app/client/lib/localization'; | ||||
| import {getColumnTypes as getNewColumnTypes} from 'app/client/ui/GridViewMenus'; | ||||
| import * as menus from 'app/client/ui2018/menus'; | ||||
| import * as components from 'app/client/components/Forms/elements'; | ||||
| import {Computed, dom, IDomArgs, MultiHolder} from 'grainjs'; | ||||
| 
 | ||||
| const t = makeT('FormView'); | ||||
| const testId = makeTestId('test-forms-menu-'); | ||||
| 
 | ||||
| // New box to add, either a new column of type, an existing column (by column id), or a structure.
 | ||||
| export type NewBox = {add: string} | {show: string} | {structure: BoxType}; | ||||
| 
 | ||||
| interface Props { | ||||
|   box?: BoxModel; | ||||
|   view?: FormView; | ||||
|   context?: boolean; | ||||
|   customItems?: Element[], | ||||
|   insertBox?: Place | ||||
| } | ||||
| 
 | ||||
| export function buildMenu(props: Props, ...args: IDomArgs<HTMLElement>): IDomArgs<HTMLElement> { | ||||
|   const {box, context, customItems} = props; | ||||
|   const view = box?.view ?? props.view; | ||||
|   if (!view) { throw new Error("No view provided"); } | ||||
|   const gristDoc = view.gristDoc; | ||||
|   const viewSection = view.viewSection; | ||||
|   const owner = new MultiHolder(); | ||||
| 
 | ||||
|   const unmapped = Computed.create(owner, (use) => { | ||||
|     const types = getNewColumnTypes(gristDoc, use(viewSection.tableId)); | ||||
|     const normalCols = use(viewSection.hiddenColumns).filter(col => { | ||||
|       if (use(col.isHiddenCol)) { return false; } | ||||
|       if (use(col.isFormula) && use(col.formula)) { return false; } | ||||
|       if (use(col.pureType) === 'Attachments') { return false; } | ||||
|       return true; | ||||
|     }); | ||||
|     const list = normalCols.map(col => { | ||||
|       return { | ||||
|         label: use(col.label), | ||||
|         icon: types.find(type => type.colType === use(col.pureType))?.icon ?? 'TypeCell', | ||||
|         colId: use(col.colId), | ||||
|       }; | ||||
|     }); | ||||
|     return list; | ||||
|   }); | ||||
| 
 | ||||
|   const oneTo5 = Computed.create(owner, (use) => use(unmapped).length > 0 && use(unmapped).length <= 5); | ||||
|   const moreThan5 = Computed.create(owner, (use) => use(unmapped).length > 5); | ||||
| 
 | ||||
|   return [ | ||||
|     dom.autoDispose(owner), | ||||
|     menus.menu((ctl) => { | ||||
|       box?.view.selectedBox.set(box); | ||||
| 
 | ||||
|       // Same for structure.
 | ||||
|       const struct = (structure: BoxType) => ({structure}); | ||||
| 
 | ||||
|       // Actions:
 | ||||
| 
 | ||||
|       // Insert field before and after.
 | ||||
|       const above = (el: NewBox) => () => { | ||||
|         allCommands.insertFieldBefore.run(el); | ||||
|       }; | ||||
|       const below = (el: NewBox) => () => { | ||||
|         allCommands.insertFieldAfter.run(el); | ||||
|       }; | ||||
|       const atEnd = (el: NewBox) => () => { | ||||
|         allCommands.insertField.run(el); | ||||
|       }; | ||||
|       const custom = props.insertBox ? (el: NewBox) => () => { | ||||
|         if ('add' in el || 'show' in el) { | ||||
|           return view.addNewQuestion(props.insertBox!, el); | ||||
|         } else { | ||||
|           props.insertBox!(components.defaultElement(el.structure)); | ||||
|           return view.save(); | ||||
|         } | ||||
|       } : null; | ||||
| 
 | ||||
|       // Field menus.
 | ||||
|       const quick = ['Text', 'Numeric', 'Choice', 'Date']; | ||||
|       const disabled = ['Attachments']; | ||||
|       const commonTypes = () => getNewColumnTypes(gristDoc, viewSection.tableId()); | ||||
|       const isQuick = ({colType}: {colType: string}) => quick.includes(colType); | ||||
|       const notQuick = ({colType}: {colType: string}) => !quick.includes(colType); | ||||
|       const isEnabled = ({colType}: {colType: string}) => !disabled.includes(colType); | ||||
| 
 | ||||
|       const insertMenu = (where: typeof above) => () => { | ||||
|         return [ | ||||
|           menus.menuSubHeader('New question'), | ||||
|           ...commonTypes() | ||||
|             .filter(isQuick) | ||||
|             .filter(isEnabled) | ||||
|             .map(ct => menus.menuItem(where({add: ct.colType}), menus.menuIcon(ct.icon!), ct.displayName)) | ||||
|           , | ||||
|           menus.menuItemSubmenu( | ||||
|             () => commonTypes() | ||||
|               .filter(notQuick) | ||||
|               .filter(isEnabled) | ||||
|               .map(ct => menus.menuItem( | ||||
|                 where({add: ct.colType}), | ||||
|                 menus.menuIcon(ct.icon!), | ||||
|                 ct.displayName, | ||||
|               )), | ||||
|             {}, | ||||
|             menus.menuIcon('Dots'), | ||||
|             dom('span', "More", dom.style('margin-right', '8px')) | ||||
|           ), | ||||
|           dom.maybe(oneTo5, () => [ | ||||
|             menus.menuDivider(), | ||||
|             menus.menuSubHeader(t('Unmapped fields')), | ||||
|             dom.domComputed(unmapped, (uf) => | ||||
|               uf.map(({label, icon, colId}) => menus.menuItem( | ||||
|                 where({show: colId}), | ||||
|                 menus.menuIcon(icon), | ||||
|                 label, | ||||
|                 testId('unmapped'), | ||||
|                 testId('unmapped-' + colId) | ||||
|               )), | ||||
|             ), | ||||
|           ]), | ||||
|           dom.maybe(moreThan5, () => [ | ||||
|             menus.menuDivider(), | ||||
|             menus.menuSubHeaderMenu( | ||||
|               () => unmapped.get().map( | ||||
|                 ({label, icon, colId}) => menus.menuItem( | ||||
|                   where({show: colId}), | ||||
|                   menus.menuIcon(icon), | ||||
|                   label, | ||||
|                   testId('unmapped'), | ||||
|                   testId('unmapped-' + colId) | ||||
|                 )), | ||||
|               {}, | ||||
|               dom('span', "Unmapped fields", dom.style('margin-right', '8px')) | ||||
|             ), | ||||
|           ]), | ||||
|           menus.menuDivider(), | ||||
|           menus.menuSubHeader(t('Building blocks')), | ||||
|           menus.menuItem(where(struct('Columns')), menus.menuIcon('Columns'), t("Columns")), | ||||
|           menus.menuItem(where(struct('Paragraph')), menus.menuIcon('Paragraph'), t("Paragraph")), | ||||
|           menus.menuItem(where(struct('Separator')), menus.menuIcon('Separator'), t("Separator")), | ||||
|         ]; | ||||
|       }; | ||||
| 
 | ||||
|       if (!props.context) { | ||||
|         return insertMenu(custom ?? atEnd)(); | ||||
|       } | ||||
| 
 | ||||
|       return [ | ||||
|         menus.menuItemSubmenu(insertMenu(above), {action: above({add: 'Text'})}, t("Insert question above")), | ||||
|         menus.menuItemSubmenu(insertMenu(below), {action: below({add: 'Text'})}, t("Insert question below")), | ||||
|         menus.menuDivider(), | ||||
|         menus.menuItemCmd(allCommands.contextMenuCopy, t("Copy")), | ||||
|         menus.menuItemCmd(allCommands.contextMenuCut, t("Cut")), | ||||
|         menus.menuItemCmd(allCommands.contextMenuPaste, t("Paste")), | ||||
|         menus.menuDivider(), | ||||
|         menus.menuItemCmd(allCommands.deleteFields, "Hide"), | ||||
|         elem => void FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}), | ||||
|         customItems?.length ? menus.menuDivider(dom.style('min-width', '200px')) : null, | ||||
|         ...(customItems ?? []), | ||||
|         ...args, | ||||
|       ]; | ||||
|     }, {trigger: [context ? 'contextmenu' : 'click']}), | ||||
|     context ? dom.on('contextmenu', stopEvent) : null, | ||||
|   ]; | ||||
| } | ||||
| @ -1,13 +1,13 @@ | ||||
| import {FormView} from 'app/client/components/Forms/FormView'; | ||||
| import * as elements from 'app/client/components/Forms/elements'; | ||||
| import { | ||||
|   bundleChanges, Computed, Disposable, dom, DomContents, | ||||
|   MultiHolder, MutableObsArray, obsArray, Observable | ||||
| } from 'grainjs'; | ||||
| import {FormView} from 'app/client/components/Forms/FormView'; | ||||
| import {bundleChanges, Computed, Disposable, dom, IDomArgs, MutableObsArray, obsArray, Observable} from 'grainjs'; | ||||
| import {v4 as uuidv4} from 'uuid'; | ||||
| 
 | ||||
| 
 | ||||
| export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' | 'Placeholder' | 'Layout' | 'Field'; | ||||
| 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 | ||||
| @ -35,6 +35,7 @@ export abstract class BoxModel extends Disposable { | ||||
|     const subClassName = `${box.type.split(':')[0]}Model`; | ||||
|     const factories = elements as any; | ||||
|     const factory = factories[subClassName]; | ||||
|     if (!parent && !view) { throw new Error('Cannot create detached box'); } | ||||
|     // If we have a factory, use it.
 | ||||
|     if (factory) { | ||||
|       return new factory(box, parent, view || parent!.view); | ||||
| @ -53,7 +54,7 @@ export abstract class BoxModel extends Disposable { | ||||
|    * Type of the box. As the type is bounded to the class that is used to render the box, it is possible | ||||
|    * to change the type of the box just by changing this value. The box is then replaced in the parent. | ||||
|    */ | ||||
|   public type: Observable<string>; | ||||
|   public type: BoxType; | ||||
|   /** | ||||
|    * List of children boxes. | ||||
|    */ | ||||
| @ -68,18 +69,27 @@ export abstract class BoxModel extends Disposable { | ||||
|    */ | ||||
|   public cut = Observable.create(this, false); | ||||
| 
 | ||||
|   public selected: Observable<boolean>; | ||||
|   /** | ||||
|    * Don't use it directly, use the BoxModel.new factory method instead. | ||||
|    */ | ||||
|   constructor(box: Box, public parent: BoxModel | null, public view: FormView) { | ||||
|     super(); | ||||
| 
 | ||||
|     this.selected = Computed.create(this, (use) => use(view.selectedBox) === this && use(view.viewSection.hasFocus)); | ||||
| 
 | ||||
|     this.children = this.autoDispose(obsArray([])); | ||||
| 
 | ||||
|     // We are owned by the parent children list.
 | ||||
|     if (parent) { | ||||
|       parent.children.autoDispose(this); | ||||
|     } | ||||
| 
 | ||||
|     // Store "pointer" to this element.
 | ||||
|     this.id = uuidv4(); | ||||
| 
 | ||||
|     // Create observables for all properties.
 | ||||
|     this.type = Observable.create(this, box.type); | ||||
|     this.children = this.autoDispose(obsArray([])); | ||||
|     this.type = box.type; | ||||
| 
 | ||||
|     // And now update this and all children based on the box JSON.
 | ||||
|     bundleChanges(() => { | ||||
| @ -96,7 +106,7 @@ export abstract class BoxModel extends Disposable { | ||||
|    * this method can send some actions to the server, or do some other work. In particular Field | ||||
|    * will insert or reveal a column. | ||||
|    */ | ||||
|   public async onDrop() { | ||||
|   public async afterDrop() { | ||||
| 
 | ||||
|   } | ||||
| 
 | ||||
| @ -104,7 +114,7 @@ export abstract class BoxModel extends Disposable { | ||||
|    * The only method that derived classes need to implement. It should return a DOM element that | ||||
|    * represents this box. | ||||
|    */ | ||||
|   public abstract render(context: RenderContext): HTMLElement; | ||||
|   public abstract render(...args: IDomArgs<HTMLElement>): HTMLElement; | ||||
| 
 | ||||
| 
 | ||||
|   public removeChild(box: BoxModel) { | ||||
| @ -135,7 +145,7 @@ export abstract class BoxModel extends Disposable { | ||||
|    * Cuts self and puts it into clipboard. | ||||
|    */ | ||||
|   public async cutSelf() { | ||||
|     [...this.root().list()].forEach(box => box?.cut.set(false)); | ||||
|     [...this.root().iterate()].forEach(box => box?.cut.set(false)); | ||||
|     // Add this box as a json to clipboard.
 | ||||
|     await navigator.clipboard.writeText(JSON.stringify(this.toJSON())); | ||||
|     this.cut.set(true); | ||||
| @ -145,7 +155,7 @@ export abstract class BoxModel extends Disposable { | ||||
|    * Accepts box from clipboard and inserts it before this box or if this is a container box, then | ||||
|    * as a first child. Default implementation is to insert before self. | ||||
|    */ | ||||
|   public drop(dropped: Box) { | ||||
|   public accept(dropped: Box, hint: 'above'|'below' = 'above') { | ||||
|     // Get the box that was dropped.
 | ||||
|     if (!dropped) { return null; } | ||||
|     if (dropped.id === this.id) { | ||||
| @ -153,11 +163,11 @@ export abstract class BoxModel extends Disposable { | ||||
|     } | ||||
|     // We need to remove it from the parent, so find it first.
 | ||||
|     const droppedId = dropped.id; | ||||
|     const droppedRef = this.root().find(droppedId); | ||||
|     const droppedRef = this.root().get(droppedId); | ||||
|     if (droppedRef) { | ||||
|       droppedRef.removeSelf(); | ||||
|     } | ||||
|     return this.placeBeforeMe()(dropped); | ||||
|     return hint === 'above' ? this.placeBeforeMe()(dropped) : this.placeAfterMe()(dropped); | ||||
|   } | ||||
| 
 | ||||
|   public prop(name: string, defaultValue?: any) { | ||||
| @ -167,9 +177,13 @@ export abstract class BoxModel extends Disposable { | ||||
|     return this.props[name]; | ||||
|   } | ||||
| 
 | ||||
|   public async save(): Promise<void> { | ||||
|   public hasProp(name: string) { | ||||
|     return this.props.hasOwnProperty(name); | ||||
|   } | ||||
| 
 | ||||
|   public async save(before?: () => Promise<void>): Promise<void> { | ||||
|     if (!this.parent) { throw new Error('Cannot save detached box'); } | ||||
|     return this.parent.save(); | ||||
|     return this.parent.save(before); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
| @ -252,15 +266,29 @@ export abstract class BoxModel extends Disposable { | ||||
|   /** | ||||
|    * Finds a box with a given id in the tree. | ||||
|    */ | ||||
|   public find(droppedId: string): BoxModel | null { | ||||
|   public get(droppedId: string): BoxModel | null { | ||||
|     for (const child of this.kids()) { | ||||
|       if (child.id === droppedId) { return child; } | ||||
|       const found = child.find(droppedId); | ||||
|       const found = child.get(droppedId); | ||||
|       if (found) { return found; } | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   public* filter(filter: (box: BoxModel) => boolean): Iterable<BoxModel> { | ||||
|     for (const child of this.kids()) { | ||||
|       if (filter(child)) { yield child; } | ||||
|       yield* child.filter(filter); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public includes(box: BoxModel) { | ||||
|     for (const child of this.kids()) { | ||||
|       if (child === box) { return true; } | ||||
|       if (child.includes(box)) { return true; } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public kids() { | ||||
|     return this.children.get().filter(Boolean); | ||||
|   } | ||||
| @ -271,15 +299,19 @@ export abstract class BoxModel extends Disposable { | ||||
|    */ | ||||
|   public update(boxDef: Box) { | ||||
|     // If we have a type and the type is changed, then we need to replace the box.
 | ||||
|     if (this.type.get() && boxDef.type !== this.type.get()) { | ||||
|       this.parent!.replace(this, BoxModel.new(boxDef, this.parent)); | ||||
|     if (this.type && boxDef.type !== this.type) { | ||||
|       if (!this.parent) { throw new Error('Cannot replace detached box'); } | ||||
|       this.parent.replace(this, BoxModel.new(boxDef, this.parent)); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     // Update all properties of self.
 | ||||
|     for (const key in boxDef) { | ||||
|       // Skip some keys.
 | ||||
|       if (key === 'id' || key === 'type' || key === 'children') { continue; } | ||||
|       // Skip any inherited properties.
 | ||||
|       if (!boxDef.hasOwnProperty(key)) { continue; } | ||||
|       // Skip if the value is the same.
 | ||||
|       if (this.prop(key).get() === boxDef[key]) { continue; } | ||||
|       this.prop(key).set(boxDef[key]); | ||||
|     } | ||||
| @ -296,11 +328,13 @@ export abstract class BoxModel extends Disposable { | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     if (!boxDef.children) { return; } | ||||
| 
 | ||||
|     // Update those that indices are the same.
 | ||||
|     const min = Math.min(myLength, newLength); | ||||
|     for (let i = 0; i < min; i++) { | ||||
|       const atIndex = this.children.get()[i]; | ||||
|       const atIndexDef = boxDef.children![i]; | ||||
|       const atIndexDef = boxDef.children[i]; | ||||
|       atIndex.update(atIndexDef); | ||||
|     } | ||||
|   } | ||||
| @ -311,16 +345,16 @@ export abstract class BoxModel extends Disposable { | ||||
|   public toJSON(): Box { | ||||
|     return { | ||||
|       id: this.id, | ||||
|       type: this.type.get() as BoxType, | ||||
|       type: this.type, | ||||
|       children: this.children.get().map(child => child?.toJSON() || null), | ||||
|       ...(Object.fromEntries(Object.entries(this.props).map(([key, val]) => [key, val.get()]))), | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   public * list(): IterableIterator<BoxModel> { | ||||
|   public * iterate(): IterableIterator<BoxModel> { | ||||
|     for (const child of this.kids()) { | ||||
|       yield child; | ||||
|       yield* child.list(); | ||||
|       yield* child.iterate(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
| @ -330,35 +364,30 @@ export abstract class BoxModel extends Disposable { | ||||
| } | ||||
| 
 | ||||
| export class LayoutModel extends BoxModel { | ||||
|   constructor(box: Box, public parent: BoxModel | null, public _save: () => Promise<void>, public view: FormView) { | ||||
|   constructor( | ||||
|     box: Box, | ||||
|     public parent: BoxModel | null, | ||||
|     public _save: (clb?: Callback) => Promise<void>, | ||||
|     public view: FormView | ||||
|   ) { | ||||
|     super(box, parent, view); | ||||
|   } | ||||
| 
 | ||||
|   public async save() { | ||||
|     return await this._save(); | ||||
|   public async save(clb?: Callback) { | ||||
|     return await this._save(clb); | ||||
|   } | ||||
| 
 | ||||
|   public render(): HTMLElement { | ||||
|   public override render(): HTMLElement { | ||||
|     throw new Error('Method not implemented.'); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class DefaultBoxModel extends BoxModel { | ||||
|   public render(): HTMLElement { | ||||
|     return dom('div', `Unknown box type ${this.type.get()}`); | ||||
|     return dom('div', `Unknown box type ${this.type}`); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export interface RenderContext { | ||||
|   overlay: Observable<boolean>, | ||||
| } | ||||
| 
 | ||||
| export type Builder = (owner: MultiHolder, options: { | ||||
|   box: BoxModel, | ||||
|   view: FormView, | ||||
|   overlay: Observable<boolean>, | ||||
| }) => DomContents; | ||||
| 
 | ||||
| export const ignoreClick = dom.on('click', (ev) => { | ||||
|   ev.stopPropagation(); | ||||
|   ev.preventDefault(); | ||||
|  | ||||
| @ -1,73 +1,64 @@ | ||||
| import * as css from './styles'; | ||||
| import {BoxModel, RenderContext} from 'app/client/components/Forms/Model'; | ||||
| import {BoxModel} from 'app/client/components/Forms/Model'; | ||||
| import {textarea} from 'app/client/ui/inputs'; | ||||
| import {theme} from 'app/client/ui2018/cssVars'; | ||||
| import {not} from 'app/common/gutil'; | ||||
| import {Computed, dom, Observable, styled} from 'grainjs'; | ||||
| import {buildEditor} from 'app/client/components/Forms/Editor'; | ||||
| 
 | ||||
| export class ParagraphModel extends BoxModel { | ||||
|   public edit = Observable.create(this, false); | ||||
| 
 | ||||
|   public render(context: RenderContext) { | ||||
|   protected defaultValue = '**Lorem** _ipsum_ dolor'; | ||||
|   protected cssClass = ''; | ||||
| 
 | ||||
|   private _overlay = Computed.create(this, not(this.selected)); | ||||
| 
 | ||||
|   public override render(): HTMLElement { | ||||
|     const box = this; | ||||
|     context.overlay.set(false); | ||||
|     const editMode = box.edit; | ||||
|     let element: HTMLElement; | ||||
|     const text = this.prop('text', '**Lorem** _ipsum_ dolor') as Observable<string|undefined>; | ||||
|     const properText = Computed.create(this, (use) => { | ||||
|       const savedText = use(text); | ||||
|       if (!savedText) { return ''; } | ||||
|       if (typeof savedText !== 'string') { return ''; } | ||||
|       return savedText; | ||||
|     }); | ||||
|     properText.onWrite((val) => { | ||||
|       if (typeof val !== 'string') { return; } | ||||
|       text.set(val); | ||||
|       this.parent?.save().catch(reportError); | ||||
|     }); | ||||
|     const text = this.prop('text', this.defaultValue) as Observable<string|undefined>; | ||||
| 
 | ||||
|     box.edit.addListener((val) => { | ||||
|       if (!val) { return; } | ||||
|       setTimeout(() => element.focus(), 0); | ||||
|     }); | ||||
|     // There is a spacial hack here. We might be created as a separator component, but the rendering
 | ||||
|     // for separator looks bad when it is the only content, so add a special case for that.
 | ||||
|     const isSeparator = Computed.create(this, (use) => use(text) === '---'); | ||||
| 
 | ||||
|     return css.cssStaticText( | ||||
|       css.markdown(use => use(properText) || '', dom.cls('_preview'), dom.hide(editMode)), | ||||
|       dom.maybe(use => !use(properText) && !use(editMode), () => cssEmpty('(empty)')), | ||||
|       dom.on('dblclick', () => { | ||||
|         editMode.set(true); | ||||
|       }), | ||||
|       css.cssStaticText.cls('-edit', editMode), | ||||
|       dom.maybe(editMode, () => [ | ||||
|         cssTextArea(properText, {}, | ||||
|           (el) => { | ||||
|             element = el; | ||||
|           }, | ||||
|           dom.onKeyDown({ | ||||
|             Enter$: (ev) => { | ||||
|               // if shift ignore
 | ||||
|               if (ev.shiftKey) { | ||||
|                 return; | ||||
|               } | ||||
|               ev.stopPropagation(); | ||||
|               ev.preventDefault(); | ||||
|               editMode.set(false); | ||||
|             }, | ||||
|             Escape$: (ev) => { | ||||
|               ev.stopPropagation(); | ||||
|               ev.preventDefault(); | ||||
|               editMode.set(false); | ||||
|             } | ||||
|           }), | ||||
|           dom.on('blur', () => { | ||||
|             editMode.set(false); | ||||
|           }), | ||||
|         ), | ||||
|       ]) | ||||
|     ); | ||||
|     return buildEditor({ | ||||
|       box: this, | ||||
|       overlay: this._overlay, | ||||
|       content: css.cssMarkdownRendered( | ||||
|         css.markdown(use => use(text) || '', dom.hide(editMode)), | ||||
|         dom.maybe(use => !use(text) && !use(editMode), () => cssEmpty('(empty)')), | ||||
|         css.cssMarkdownRendered.cls('-separator', isSeparator), | ||||
|         dom.on('click', () => { | ||||
|           if (!editMode.get() && this.selected.get()) { | ||||
|             editMode.set(true); | ||||
|           } | ||||
|         }), | ||||
|         css.cssMarkdownRendered.cls('-edit', editMode), | ||||
|         css.cssMarkdownRendered.cls(u => `-alignment-${u(box.prop('alignment', 'left'))}`), | ||||
|         this.cssClass ? dom.cls(this.cssClass, not(editMode)) : null, | ||||
|         dom.maybe(editMode, () => { | ||||
|           const draft = Observable.create(null, text.get() || ''); | ||||
|           setTimeout(() => element?.focus(), 10); | ||||
|           return [ | ||||
|             element = cssTextArea(draft, {autoGrow: true, onInput: true}, | ||||
|               cssTextArea.cls('-edit', editMode), | ||||
|               css.saveControls(editMode, (ok) => { | ||||
|                 if (ok && editMode.get()) { | ||||
|                   text.set(draft.get()); | ||||
|                   this.save().catch(reportError); | ||||
|                 } | ||||
|               }) | ||||
|             ), | ||||
|           ]; | ||||
|         }), | ||||
|       ) | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| const cssTextArea = styled(textarea, ` | ||||
|   color: ${theme.inputFg}; | ||||
|   background-color: ${theme.mainPanelBg}; | ||||
| @ -79,6 +70,13 @@ const cssTextArea = styled(textarea, ` | ||||
|   min-height: calc(3em * 1.5); | ||||
|   resize: none; | ||||
|   border-radius: 3px; | ||||
|   &-edit { | ||||
|     cursor: auto; | ||||
|     background: ${theme.inputBg}; | ||||
|     outline: 2px solid black; | ||||
|     outline-offset: 1px; | ||||
|     border-radius: 2px; | ||||
|   } | ||||
|   &::placeholder { | ||||
|     color: ${theme.inputPlaceholderFg}; | ||||
|   } | ||||
|  | ||||
| @ -1,25 +1,75 @@ | ||||
| import * as style from './styles'; | ||||
| import {BoxModel, RenderContext} from 'app/client/components/Forms/Model'; | ||||
| import {dom} from 'grainjs'; | ||||
| import {buildEditor} from 'app/client/components/Forms/Editor'; | ||||
| import {buildMenu} from 'app/client/components/Forms/Menu'; | ||||
| import {Box, BoxModel} from 'app/client/components/Forms/Model'; | ||||
| import {dom, styled} from 'grainjs'; | ||||
| import {makeTestId} from 'app/client/lib/domUtils'; | ||||
| 
 | ||||
| const testId = makeTestId('test-forms-'); | ||||
| 
 | ||||
| /** | ||||
|  * Component that renders a section of the form. | ||||
|  */ | ||||
| export class SectionModel extends BoxModel { | ||||
|   public render(context: RenderContext) { | ||||
|   public override render(): HTMLElement { | ||||
|     const children = this.children; | ||||
|     context.overlay.set(false); | ||||
|     const view = this.view; | ||||
|     const box = this; | ||||
| 
 | ||||
|     const element = style.cssSection( | ||||
|       style.cssDrag(), | ||||
|       dom.forEach(children, (child) => | ||||
|         child ? view.renderBox(children, child) : dom('div', 'Empty') | ||||
|       ), | ||||
|       view.buildDropzone(children, box.placeAfterListChild()), | ||||
|     return buildEditor({ | ||||
|       box: this, | ||||
|       // Custom drag element that is little bigger and at the top of the section.
 | ||||
|       drag: style.cssDragWrapper(style.cssDrag('DragDrop', style.cssDrag.cls('-top'))), | ||||
|       // No way to remove section now.
 | ||||
|       removeIcon: null, | ||||
|       // Content is just a list of children.
 | ||||
|       content: style.cssSection( | ||||
|         // Wrap them in a div that mutes hover events.
 | ||||
|         cssSectionItems( | ||||
|           dom.forEach(children, (child) => child.render()), | ||||
|         ), | ||||
|         // Plus icon
 | ||||
|         style.cssPlusButton( | ||||
|           testId('plus'), | ||||
|           style.cssDrop(), | ||||
|           style.cssCircle( | ||||
|             style.cssPlusIcon('Plus'), | ||||
|             buildMenu({ | ||||
|               box: this, | ||||
|             }) | ||||
|           ), | ||||
|         ) | ||||
|       )}, | ||||
|       style.cssSectionEditor.cls(''), | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|     return element; | ||||
| 
 | ||||
|   /** | ||||
|    * Accepts box from clipboard and inserts it before this box or if this is a container box, then | ||||
|    * as a first child. Default implementation is to insert before self. | ||||
|    */ | ||||
|   public override accept(dropped: Box) { | ||||
|     // Get the box that was dropped.
 | ||||
|     if (!dropped) { return null; } | ||||
|     if (dropped.id === this.id) { | ||||
|       return null; | ||||
|     } | ||||
|     // We need to remove it from the parent, so find it first.
 | ||||
|     const droppedId = dropped.id; | ||||
|     const droppedRef = this.root().get(droppedId); | ||||
|     if (droppedRef) { | ||||
|       droppedRef.removeSelf(); | ||||
|     } | ||||
| 
 | ||||
|     // Depending of the type of dropped box we need to insert it in different places.
 | ||||
|     // By default we insert it before this box.
 | ||||
|     let place = this.placeBeforeMe(); | ||||
|     if (dropped.type === 'Field') { | ||||
|       // Fields are inserted after last child.
 | ||||
|       place = this.placeAfterListChild(); | ||||
|     } | ||||
| 
 | ||||
|     return place(dropped); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| const cssSectionItems = styled('div.hover_border', ` | ||||
| `);
 | ||||
|  | ||||
| @ -1,10 +1,16 @@ | ||||
| import {BoxModel, RenderContext} from 'app/client/components/Forms/Model'; | ||||
| import {makeTestId} from 'app/client/lib/domUtils'; | ||||
| import {primaryButton} from 'app/client/ui2018/buttons'; | ||||
| const testId = makeTestId('test-forms-'); | ||||
| import { BoxModel } from "app/client/components/Forms/Model"; | ||||
| import { makeTestId } from "app/client/lib/domUtils"; | ||||
| import { bigPrimaryButton } from "app/client/ui2018/buttons"; | ||||
| import { dom } from "grainjs"; | ||||
| const testId = makeTestId("test-forms-"); | ||||
| 
 | ||||
| export class SubmitModel extends BoxModel { | ||||
|   public render(context: RenderContext) { | ||||
|     return primaryButton('Submit', testId('submit')); | ||||
|   public override render() { | ||||
|     const text = this.view.viewSection.layoutSpecObj.prop('submitText'); | ||||
|     return dom( | ||||
|       "div", | ||||
|       { style: "text-align: center; margin-top: 20px;" }, | ||||
|       bigPrimaryButton(dom.text(use => use(text) || 'Submit'), testId("submit")) | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -1,26 +0,0 @@ | ||||
| import * as style from './styles'; | ||||
| import {Builder, ignoreClick} from 'app/client/components/Forms/Model'; | ||||
| import {Computed, dom, IDisposableOwner, makeTestId} from 'grainjs'; | ||||
| const testId = makeTestId('test-forms-'); | ||||
| 
 | ||||
| export const buildTextField: Builder = (owner: IDisposableOwner, {box, view}) => { | ||||
| 
 | ||||
|   const field = Computed.create(owner, use => { | ||||
|     return view.gristDoc.docModel.viewFields.getRowModel(use(box.prop('leaf'))); | ||||
|   }); | ||||
|   return dom('div', | ||||
|     testId('question'), | ||||
|     testId('question-Text'), | ||||
|     style.cssLabel( | ||||
|       testId('label'), | ||||
|       dom.text(use => use(use(field).question) || use(use(field).origLabel)) | ||||
|     ), | ||||
|     style.cssInput( | ||||
|       testId('input'), | ||||
|       {type: 'text', tabIndex: "-1"}, | ||||
|       ignoreClick), | ||||
|     dom.maybe(use => use(use(field).description), (description) => [ | ||||
|       style.cssDesc(description, testId('description')), | ||||
|     ]), | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										274
									
								
								app/client/components/Forms/UnmappedFieldsConfig.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,274 @@ | ||||
| import {allCommands} from 'app/client/components/commands'; | ||||
| import {makeT} from 'app/client/lib/localization'; | ||||
| import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel'; | ||||
| import {basicButton, cssButton, primaryButton} from 'app/client/ui2018/buttons'; | ||||
| import {squareCheckbox} from 'app/client/ui2018/checkbox'; | ||||
| import {theme, vars} from 'app/client/ui2018/cssVars'; | ||||
| import {cssDragger} from 'app/client/ui2018/draggableList'; | ||||
| import {icon} from 'app/client/ui2018/icons'; | ||||
| import {Computed, Disposable, dom, fromKo, makeTestId, Observable, styled} from 'grainjs'; | ||||
| import * as ko from 'knockout'; | ||||
| 
 | ||||
| const testId = makeTestId('test-vfc-'); | ||||
| const t = makeT('VisibleFieldsConfig'); | ||||
| 
 | ||||
| /** | ||||
|  * This is a component used in the RightPanel. It replaces hidden fields section on other views, and adds | ||||
|  * the ability to drag and drop fields onto the form. | ||||
|  */ | ||||
| export class UnmappedFieldsConfig extends Disposable { | ||||
| 
 | ||||
|   constructor(private _section: ViewSectionRec) { | ||||
|     super(); | ||||
|   } | ||||
| 
 | ||||
|   public buildDom() { | ||||
|     const unmappedColumns = fromKo(this.autoDispose(ko.pureComputed(() => { | ||||
|       if (this._section.isDisposed()) { | ||||
|         return []; | ||||
|       } | ||||
|       const fields = new Set(this._section.viewFields().map(f => f.colId()).all()); | ||||
|       const cols = this._section.table().visibleColumns().filter(c => !fields.has(c.colId())); | ||||
|       return cols.map(col => ({ | ||||
|         col, | ||||
|         selected: Observable.create(null, false), | ||||
|       })); | ||||
|     }))); | ||||
|     const mappedColumns = fromKo(this.autoDispose(ko.pureComputed(() => { | ||||
|       if (this._section.isDisposed()) { | ||||
|         return []; | ||||
|       } | ||||
|       const cols = this._section.viewFields().map(f => f.column()); | ||||
|       return cols.map(col => ({ | ||||
|         col, | ||||
|         selected: Observable.create(null, false), | ||||
|       })).all(); | ||||
|     }))); | ||||
| 
 | ||||
|     const anyUnmappedSelected = Computed.create(this, use => { | ||||
|       return use(unmappedColumns).some(c => use(c.selected)); | ||||
|     }); | ||||
| 
 | ||||
|     const anyMappedSelected = Computed.create(this, use => { | ||||
|       return use(mappedColumns).some(c => use(c.selected)); | ||||
|     }); | ||||
| 
 | ||||
|     const mapSelected = async () => { | ||||
|       await allCommands.showColumns.run( | ||||
|         unmappedColumns.get().filter(c => c.selected.get()).map(c => c.col.colId.peek())); | ||||
|     }; | ||||
| 
 | ||||
|     const unMapSelected = async () => { | ||||
|       await allCommands.hideFields.run( | ||||
|         mappedColumns.get().filter(c => c.selected.get()).map(c => c.col.colId.peek())); | ||||
|     }; | ||||
| 
 | ||||
|     return [ | ||||
|       cssHeader( | ||||
|         cssFieldListHeader(t("Unmapped")), | ||||
|         selectAllLabel( | ||||
|           dom.on('click', () => { | ||||
|             unmappedColumns.get().forEach((col) => col.selected.set(true)); | ||||
|           }), | ||||
|           dom.show(/* any unmapped columns */ use => use(unmappedColumns).length > 0), | ||||
|         ), | ||||
|       ), | ||||
|       dom('div', | ||||
|         testId('hidden-fields'), | ||||
|         dom.forEach(unmappedColumns, (field) => { | ||||
|           return this._buildUnmappedField(field); | ||||
|         }) | ||||
|       ), | ||||
|       dom.maybe(anyUnmappedSelected, () => | ||||
|         cssRow( | ||||
|           primaryButton( | ||||
|             dom.text(t("Map fields")), | ||||
|             dom.on('click', mapSelected), | ||||
|             testId('visible-hide') | ||||
|           ), | ||||
|           basicButton( | ||||
|             t("Clear"), | ||||
|             dom.on('click', () => unmappedColumns.get().forEach((col) => col.selected.set(false))), | ||||
|             testId('visible-clear') | ||||
|           ), | ||||
|           testId('visible-batch-buttons') | ||||
|         ), | ||||
|       ), | ||||
|       cssHeader( | ||||
|         cssFieldListHeader(dom.text(t("Mapped"))), | ||||
|         selectAllLabel( | ||||
|           dom.on('click', () => { | ||||
|             mappedColumns.get().forEach((col) => col.selected.set(true)); | ||||
|           }), | ||||
|           dom.show(/* any mapped columns */ use => use(mappedColumns).length > 0), | ||||
|         ), | ||||
|       ), | ||||
|       dom('div', | ||||
|         testId('visible-fields'), | ||||
|         dom.forEach(mappedColumns, (field) => { | ||||
|           return this._buildMappedField(field); | ||||
|         }) | ||||
|       ), | ||||
|       dom.maybe(anyMappedSelected, () => | ||||
|         cssRow( | ||||
|           primaryButton( | ||||
|             dom.text(t("Unmap fields")), | ||||
|             dom.on('click', unMapSelected), | ||||
|             testId('visible-hide') | ||||
|           ), | ||||
|           basicButton( | ||||
|             t("Clear"), | ||||
|             dom.on('click', () => mappedColumns.get().forEach((col) => col.selected.set(false))), | ||||
|             testId('visible-clear') | ||||
|           ), | ||||
|           testId('visible-batch-buttons') | ||||
|         ), | ||||
|       ), | ||||
|     ]; | ||||
|   } | ||||
| 
 | ||||
|   private _buildUnmappedField(props: {col: ColumnRec, selected: Observable<boolean>}) { | ||||
|     const column = props.col; | ||||
|     return cssDragRow( | ||||
|       testId('hidden-field'), | ||||
|       {draggable: "true"}, | ||||
|       dom.on('dragstart', (ev) => { | ||||
|         // Prevent propagation, as we might be in a nested editor.
 | ||||
|         ev.stopPropagation(); | ||||
|         ev.dataTransfer?.setData('text/plain', JSON.stringify({ | ||||
|           type: 'Field', | ||||
|           leaf: column.colId.peek(), // TODO: convert to Field
 | ||||
|         })); | ||||
|         ev.dataTransfer!.dropEffect = "move"; | ||||
|       }), | ||||
|       cssSimpleDragger(), | ||||
|       cssFieldEntry( | ||||
|         cssFieldLabel(dom.text(column.label)), | ||||
|         cssHideIcon('EyeShow', | ||||
|           testId('hide'), | ||||
|           dom.on('click', () => { | ||||
|             allCommands.showColumns.run([column.colId.peek()]); | ||||
|           }), | ||||
|         ), | ||||
|         squareCheckbox(props.selected), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| 
 | ||||
|   private _buildMappedField(props: {col: ColumnRec, selected: Observable<boolean>}) { | ||||
|     const column = props.col; | ||||
|     return cssDragRow( | ||||
|       testId('visible-field'), | ||||
|       cssSimpleDragger( | ||||
|         cssSimpleDragger.cls('-hidden'), | ||||
|       ), | ||||
|       cssFieldEntry( | ||||
|         cssFieldLabel(dom.text(column.label)), | ||||
|         cssHideIcon('EyeHide', | ||||
|           testId('hide'), | ||||
|           dom.on('click', () => { | ||||
|             allCommands.hideFields.run([column.colId.peek()]); | ||||
|           }), | ||||
|         ), | ||||
|         squareCheckbox(props.selected), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| function selectAllLabel(...args: any[]) { | ||||
|   return cssControlLabel( | ||||
|     testId('select-all'), | ||||
|     icon('Tick'), | ||||
|     dom('span', t("Select All")), | ||||
|     ...args | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| const cssControlLabel = styled('div', ` | ||||
|   --icon-color: ${theme.controlFg}; | ||||
|   color: ${theme.controlFg}; | ||||
|   cursor: pointer; | ||||
|   line-height: 16px; | ||||
| `);
 | ||||
| 
 | ||||
| 
 | ||||
| // TODO: reuse them
 | ||||
| const cssDragRow = styled('div', ` | ||||
|   display: flex !important; | ||||
|   align-items: center; | ||||
|   margin: 0 16px 0px 0px; | ||||
|   margin-bottom: 2px; | ||||
|   cursor: grab; | ||||
| `);
 | ||||
| 
 | ||||
| const cssFieldEntry = styled('div', ` | ||||
|   display: flex; | ||||
|   background-color: ${theme.hover}; | ||||
|   border-radius: 2px; | ||||
|   margin: 0 8px 0 0; | ||||
|   padding: 4px 8px; | ||||
|   cursor: default; | ||||
|   white-space: nowrap; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   flex: 1 1 auto; | ||||
| 
 | ||||
|   --icon-color: ${theme.lightText}; | ||||
| `);
 | ||||
| 
 | ||||
| const cssSimpleDragger = styled(cssDragger, ` | ||||
|   cursor: grab; | ||||
|   .${cssDragRow.className}:hover & { | ||||
|     visibility: visible; | ||||
|   } | ||||
|   &-hidden { | ||||
|     visibility: hidden !important; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssHideIcon = styled(icon, ` | ||||
|   --icon-color: ${theme.lightText}; | ||||
|   display: none; | ||||
|   cursor: pointer; | ||||
|   flex: none; | ||||
|   margin-right: 8px; | ||||
|   .${cssFieldEntry.className}:hover & { | ||||
|     display: block; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssFieldLabel = styled('span', ` | ||||
|   color: ${theme.text}; | ||||
|   flex: 1 1 auto; | ||||
|   text-overflow: ellipsis; | ||||
|   overflow: hidden; | ||||
| `);
 | ||||
| 
 | ||||
| const cssFieldListHeader = styled('span', ` | ||||
|   color: ${theme.text}; | ||||
|   flex: 1 1 0px; | ||||
|   font-size: ${vars.xsmallFontSize}; | ||||
|   text-transform: uppercase; | ||||
| `);
 | ||||
| 
 | ||||
| const cssRow = styled('div', ` | ||||
|   display: flex; | ||||
|   margin: 16px; | ||||
|   overflow: hidden; | ||||
|   --icon-color: ${theme.lightText}; | ||||
|   & > .${cssButton.className} { | ||||
|     margin-right: 8px; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssHeader = styled(cssRow, ` | ||||
|   align-items: baseline; | ||||
|   justify-content: space-between; | ||||
|   margin-bottom: 12px; | ||||
|   line-height: 1em; | ||||
|   & * { | ||||
|     line-height: 1em; | ||||
|   } | ||||
| `);
 | ||||
| @ -10,11 +10,16 @@ export * from "./Section"; | ||||
| export * from './Field'; | ||||
| export * from './Columns'; | ||||
| export * from './Submit'; | ||||
| export * from './Label'; | ||||
| 
 | ||||
| export function defaultElement(type: BoxType): Box { | ||||
|   switch(type) { | ||||
|     case 'Columns': return Columns(); | ||||
|     case 'Placeholder': return Placeholder(); | ||||
|     case 'Separator': return { | ||||
|       type: 'Paragraph', | ||||
|       text: '---', | ||||
|     }; | ||||
|     default: return {type}; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -1,72 +1,177 @@ | ||||
| import {textarea} from 'app/client/ui/inputs'; | ||||
| import {sanitizeHTML} from 'app/client/ui/sanitizeHTML'; | ||||
| import {basicButton} from 'app/client/ui2018/buttons'; | ||||
| import {basicButton, basicButtonLink} from 'app/client/ui2018/buttons'; | ||||
| import {colors, theme, vars} from 'app/client/ui2018/cssVars'; | ||||
| import {BindableValue, dom, IDomArgs, styled, subscribeBindable} from 'grainjs'; | ||||
| import {icon} from 'app/client/ui2018/icons'; | ||||
| import {BindableValue, dom, DomElementArg, IDomArgs, Observable, styled, subscribeBindable} from 'grainjs'; | ||||
| import {marked} from 'marked'; | ||||
| 
 | ||||
| export { | ||||
|   cssLabel, | ||||
|   cssDesc, | ||||
|   cssInput, | ||||
|   cssFieldEditor, | ||||
|   cssSelectedOverlay, | ||||
|   cssControls, | ||||
|   cssControlsLabel, | ||||
|   cssAddElement, | ||||
|   cssAddText, | ||||
|   cssFormContainer, | ||||
|   cssFormEdit, | ||||
|   cssFormEditBody, | ||||
|   cssSection, | ||||
|   cssStaticText, | ||||
| }; | ||||
| 
 | ||||
| const cssFormEditBody = styled('div', ` | ||||
|   width: 100%; | ||||
|   overflow: auto; | ||||
|   padding-top: 52px; | ||||
| `);
 | ||||
| 
 | ||||
| const cssFormEdit = styled('div', ` | ||||
| export const cssFormView = styled('div.flexauto.flexvbox', ` | ||||
|   color: ${theme.text}; | ||||
|   background-color: ${theme.leftPanelBg}; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   flex-basis: 0px; | ||||
|   align-items: center; | ||||
|   justify-content: space-between; | ||||
|   position: relative; | ||||
|   background-color: ${theme.leftPanelBg}; | ||||
|   overflow: auto; | ||||
|   min-height: 100%; | ||||
|   width: 100%; | ||||
| `);
 | ||||
| 
 | ||||
|   --section-background: #739bc31f; /* On white background this is almost #f1f5f9 (slate-100 on tailwind palette)  */ | ||||
|   &, &-preview { | ||||
|     background-color: ${theme.leftPanelBg}; | ||||
|     overflow: auto; | ||||
|     min-height: 100%; | ||||
|     width: 100%; | ||||
|     position: relative; | ||||
|     flex-basis: 0px; | ||||
| export const cssFormContainer = styled('div', ` | ||||
|   background-color: ${theme.mainPanelBg}; | ||||
|   border: 1px solid ${theme.modalBorderDark}; | ||||
|   color: ${theme.text}; | ||||
|   width: 600px; | ||||
|   align-self: center; | ||||
|   margin: 0px auto; | ||||
|   border-radius: 3px; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   max-width: calc(100% - 32px); | ||||
|   padding-top: 20px; | ||||
|   padding-left: 48px; | ||||
|   padding-right: 48px; | ||||
|   gap: 8px; | ||||
| `);
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| export const cssFieldEditor = styled('div.hover_border.field_editor', ` | ||||
|   position: relative; | ||||
|   cursor: pointer; | ||||
|   user-select: none; | ||||
|   outline: none; | ||||
|   padding: 8px; | ||||
|   border-radius: 3px; | ||||
|   margin-bottom: 4px; | ||||
|   --hover-visible: hidden; | ||||
|   transition: transform 0.2s ease-in-out; | ||||
|   &:hover:not(:has(.hover_border:hover),&-cut) { | ||||
|     --hover-visible: visible; | ||||
|     outline: 1px solid ${colors.lightGreen}; | ||||
|   } | ||||
|   &-selected:not(&-cut) { | ||||
|     background: #F7F7F7; | ||||
|     outline: 1px solid ${colors.lightGreen}; | ||||
|     --selected-block: block; | ||||
|   } | ||||
|   &:active:not(:has(&:active)) { | ||||
|     outline: 1px solid ${colors.darkGreen}; | ||||
|   } | ||||
|   &-drag-hover { | ||||
|     outline: 2px dashed ${colors.lightGreen}; | ||||
|     outline-offset: 2px; | ||||
|   } | ||||
|   &-cut { | ||||
|     outline: 2px dashed ${colors.orange}; | ||||
|     outline-offset: 2px; | ||||
|   } | ||||
|   &-FormDescription { | ||||
|     margin-bottom: 10px; | ||||
|   } | ||||
|   &-drag-above { | ||||
|     transform: translateY(2px); | ||||
|   } | ||||
|   &-drag-below { | ||||
|     transform: translateY(-2px); | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssLabel = styled('label', ` | ||||
| export const cssSectionEditor = styled('div', ` | ||||
|   border-radius: 3px; | ||||
|   padding: 16px; | ||||
|   border: 1px solid ${theme.modalBorderDark}; | ||||
| `);
 | ||||
| 
 | ||||
| 
 | ||||
| export const cssSection = styled('div', ` | ||||
|   position: relative; | ||||
|   color: ${theme.text}; | ||||
|   margin: 0px auto; | ||||
|   min-height: 50px; | ||||
|   .${cssFormView.className}-preview & { | ||||
|     background: transparent; | ||||
|     border-radius: unset; | ||||
|     padding: 0px; | ||||
|     min-height: auto; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| export const cssLabel = styled('label', ` | ||||
|   font-size: 15px; | ||||
|   font-weight: normal; | ||||
|   margin-bottom: 8px; | ||||
|   user-select: none; | ||||
|   display: block; | ||||
|   margin: 0px; | ||||
| `);
 | ||||
| 
 | ||||
| const cssDesc = styled('div', ` | ||||
|   font-size: 10px; | ||||
| export const cssCheckboxLabel = styled('label', ` | ||||
|   font-size: 15px; | ||||
|   font-weight: normal; | ||||
|   user-select: none; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 8px; | ||||
|   margin: 0px; | ||||
|   margin-bottom: 8px; | ||||
| `);
 | ||||
| 
 | ||||
| export function textbox(obs: Observable<string|undefined>, ...args: DomElementArg[]): HTMLInputElement { | ||||
|   return dom('input', | ||||
|     dom.prop('value', u => u(obs) || ''), | ||||
|     dom.on('input', (_e, elem) => obs.set(elem.value)), | ||||
|     ...args, | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export const cssEditableLabel = styled(textarea, ` | ||||
|   font-weight: normal; | ||||
|   outline: none; | ||||
|   display: block; | ||||
|   padding: 0px; | ||||
|   border: 0px; | ||||
|   width: 100%; | ||||
|   margin: 0px; | ||||
|   background: transparent; | ||||
|   cursor: pointer; | ||||
|   min-height: 1.5rem; | ||||
| 
 | ||||
|   color: ${colors.darkText}; | ||||
|   font-size: 12px; | ||||
|   font-weight: 700; | ||||
| 
 | ||||
|   &::placeholder { | ||||
|     font-style: italic | ||||
|   } | ||||
|   &-edit { | ||||
|     cursor: auto; | ||||
|     background: ${theme.inputBg}; | ||||
|     outline: 2px solid black; | ||||
|     outline-offset: 1px; | ||||
|     border-radius: 2px; | ||||
|   } | ||||
|   &-normal { | ||||
|     color: ${colors.darkText}; | ||||
|     font-size: 15px; | ||||
|     font-weight: normal; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| export const cssDesc = styled('div', ` | ||||
|   font-size: 12px; | ||||
|   font-weight: 400; | ||||
|   margin-top: 4px; | ||||
|   color: ${colors.slate}; | ||||
|   color: ${theme.darkText}; | ||||
|   white-space: pre-wrap; | ||||
|   font-style: italic; | ||||
|   font-weight: 400; | ||||
|   line-height: 1.6; | ||||
| `);
 | ||||
| 
 | ||||
| const cssInput = styled('input', ` | ||||
|   flex: auto; | ||||
|   width: 100%; | ||||
| export const cssInput = styled('input', ` | ||||
|   font-size: inherit; | ||||
|   padding: 4px 8px; | ||||
|   border: 1px solid #D9D9D9; | ||||
| @ -77,6 +182,9 @@ const cssInput = styled('input', ` | ||||
|   &-invalid { | ||||
|     color: red; | ||||
|   } | ||||
|   &[type="number"], &[type="date"], &[type="datetime-local"], &[type="text"] { | ||||
|     width: 100%; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| export const cssSelect = styled('select', ` | ||||
| @ -94,33 +202,12 @@ export const cssSelect = styled('select', ` | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssFieldEditor = styled('div._cssFieldEditor', ` | ||||
|   position: relative; | ||||
|   cursor: pointer; | ||||
|   user-select: none; | ||||
|   outline: none; | ||||
|   &:hover:not(:has(&:hover)), &-selected { | ||||
|     outline: 1px solid ${colors.lightGreen}; | ||||
|   } | ||||
|   &:active:not(:has(&:active)) { | ||||
|     outline: 1px solid ${colors.darkGreen}; | ||||
|   } | ||||
|   &-drag-hover { | ||||
|     outline: 2px dashed ${colors.lightGreen}; | ||||
|     outline-offset: 2px; | ||||
|   } | ||||
|   &-cut { | ||||
|     outline: 2px dashed ${colors.orange}; | ||||
|     outline-offset: 2px; | ||||
|   } | ||||
| 
 | ||||
|   .${cssFormEdit.className}-preview & { | ||||
|     outline: 0px !import; | ||||
|   } | ||||
| export const cssFieldEditorContent = styled('div', ` | ||||
| 
 | ||||
| `);
 | ||||
| 
 | ||||
| const cssSelectedOverlay = styled('div', ` | ||||
|   background: ${colors.selection}; | ||||
| export const cssSelectedOverlay = styled('div._cssSelectedOverlay', ` | ||||
|   inset: 0; | ||||
|   position: absolute; | ||||
|   opacity: 0; | ||||
| @ -129,59 +216,47 @@ const cssSelectedOverlay = styled('div', ` | ||||
|     opacity: 1; | ||||
|   } | ||||
| 
 | ||||
|   .${cssFormEdit.className}-preview & { | ||||
|   .${cssFormView.className}-preview & { | ||||
|     display: none; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssControls = styled('div', ` | ||||
|   display: none; | ||||
|   position: absolute; | ||||
|   margin-top: -18px; | ||||
|   margin-left: -1px; | ||||
|   .${cssFieldEditor.className}:hover:not(:has(.${cssFieldEditor.className}:hover)) > &, | ||||
|   .${cssFieldEditor.className}:active:not(:has(.${cssFieldEditor.className}:active)) > &, | ||||
|   .${cssFieldEditor.className}-selected > & { | ||||
|     display: flex; | ||||
|   } | ||||
| 
 | ||||
|   .${cssFormEdit.className}-preview & { | ||||
|     display: none !important; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssControlsLabel = styled('div', ` | ||||
| export const cssControlsLabel = styled('div', ` | ||||
|   background: ${colors.lightGreen}; | ||||
|   color: ${colors.light}; | ||||
|   padding: 1px 2px; | ||||
|   min-width: 24px; | ||||
| `);
 | ||||
| 
 | ||||
| const cssAddElement = styled('div', ` | ||||
| export const cssPlusButton = styled('div', ` | ||||
|   position: relative; | ||||
|   min-height: 32px; | ||||
|   cursor: pointer; | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
|   align-items: center; | ||||
|   padding-right: 8px; | ||||
|   --icon-color: ${colors.lightGreen}; | ||||
|   align-self: stretch; | ||||
|   border: 2px dashed ${colors.darkGrey}; | ||||
|   background: ${colors.lightGrey}; | ||||
|   opacity: 0.7; | ||||
|   &:hover { | ||||
|     border: 2px dashed ${colors.darkGrey}; | ||||
|     background: ${colors.lightGrey}; | ||||
|     opacity: 1; | ||||
|   } | ||||
|   &-hover { | ||||
|     outline: 2px dashed ${colors.lightGreen}; | ||||
|     outline-offset: 2px; | ||||
| `);
 | ||||
| 
 | ||||
| export const cssCircle = styled('div', ` | ||||
|   border-radius: 50%; | ||||
|   width: 24px; | ||||
|   height: 24px; | ||||
|   background-color: ${colors.lightGreen}; | ||||
|   color: ${colors.light}; | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
|   align-items: center; | ||||
|   .${cssPlusButton.className}:hover & { | ||||
|     background: ${colors.darkGreen}; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssAddText = styled('div', ` | ||||
| export const cssPlusIcon = styled(icon, ` | ||||
|  --icon-color: ${colors.light}; | ||||
| `);
 | ||||
| 
 | ||||
| export const cssAddText = styled('div', ` | ||||
|   color: ${colors.slate}; | ||||
|   border-radius: 4px; | ||||
|   padding: 2px 4px; | ||||
| @ -190,39 +265,22 @@ const cssAddText = styled('div', ` | ||||
|   &:before { | ||||
|     content: "Add a field"; | ||||
|   } | ||||
|   .${cssAddElement.className}-hover &:before { | ||||
|   .${cssPlusButton.className}-hover &:before { | ||||
|     content: "Drop here"; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssSection = styled('div', ` | ||||
|   position: relative; | ||||
|   background-color: var(--section-background); | ||||
|   color: ${theme.text}; | ||||
|   align-self: center; | ||||
|   margin: 0px auto; | ||||
|   border-radius: 8px; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   min-height: 50px; | ||||
|   padding: 10px; | ||||
|   .${cssFormEdit.className}-preview & { | ||||
|     background: transparent; | ||||
|     border-radius: unset; | ||||
|     padding: 0px; | ||||
|     min-height: auto; | ||||
|   } | ||||
| export const cssPadding = styled('div', ` | ||||
| `);
 | ||||
| 
 | ||||
| export const cssColumns = styled('div', ` | ||||
|   --css-columns-count: 2; | ||||
|   background-color: var(--section-background); | ||||
|   display: grid; | ||||
|   grid-template-columns: repeat(var(--css-columns-count), 1fr) 32px; | ||||
|   gap: 8px; | ||||
|   padding: 12px 4px; | ||||
| 
 | ||||
|   .${cssFormEdit.className}-preview & { | ||||
|   .${cssFormView.className}-preview & { | ||||
|     background: transparent; | ||||
|     border-radius: unset; | ||||
|     padding: 0px; | ||||
| @ -269,11 +327,11 @@ export const cssColumn = styled('div', ` | ||||
|     align-self: flex-end; | ||||
|   } | ||||
| 
 | ||||
|   .${cssFormEdit.className}-preview &-add-button { | ||||
|   .${cssFormView.className}-preview &-add-button { | ||||
|     display: none; | ||||
|   } | ||||
| 
 | ||||
|   .${cssFormEdit.className}-preview &-empty { | ||||
|   .${cssFormView.className}-preview &-empty { | ||||
|     background: transparent; | ||||
|     border-radius: unset; | ||||
|     padding: 0px; | ||||
| @ -282,29 +340,43 @@ export const cssColumn = styled('div', ` | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssFormContainer = styled('div', ` | ||||
|   padding: 32px; | ||||
|   background-color: ${theme.mainPanelBg}; | ||||
|   border: 1px solid ${theme.menuBorder}; | ||||
|   color: ${theme.text}; | ||||
|   width: 640px; | ||||
|   align-self: center; | ||||
|   margin: 0px auto; | ||||
|   border-radius: 8px; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   flex-grow: 1; | ||||
|   gap: 16px; | ||||
|   max-width: calc(100% - 32px); | ||||
| `);
 | ||||
| 
 | ||||
| export const cssButtonGroup = styled('div', ` | ||||
|   display: flex; | ||||
|   justify-content: flex-end; | ||||
|   align-items: center; | ||||
|   flex-wrap: wrap; | ||||
|   padding: 0px 24px 0px 24px; | ||||
|   margin-bottom: 16px; | ||||
|   gap: 8px; | ||||
|   /* So that the height is 40px in normal state */ | ||||
|   padding-top: calc((40px - 24px) / 2); | ||||
|   padding-bottom: calc((40px - 24px) / 2); | ||||
| `);
 | ||||
| 
 | ||||
| 
 | ||||
| export const cssIconLink = styled(basicButtonLink, ` | ||||
|   padding: 3px 8px; | ||||
|   font-size: ${vars.smallFontSize}; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 4px; | ||||
|   min-height: 24px; | ||||
| 
 | ||||
|   &-standard { | ||||
|     background-color: ${theme.leftPanelBg}; | ||||
|   } | ||||
|   &-warning { | ||||
|     color: ${theme.controlPrimaryFg}; | ||||
|     background-color: ${theme.toastWarningBg}; | ||||
|     border: none; | ||||
|   } | ||||
|   &-warning:hover { | ||||
|     color: ${theme.controlPrimaryFg}; | ||||
|     background-color: #B8791B; | ||||
|     border: none; | ||||
|   } | ||||
|   &-frameless { | ||||
|     background-color: transparent; | ||||
|     border: none; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| export const cssIconButton = styled(basicButton, ` | ||||
| @ -328,21 +400,89 @@ export const cssIconButton = styled(basicButton, ` | ||||
|     background-color: #B8791B; | ||||
|     border: none; | ||||
|   } | ||||
|   &-frameless { | ||||
|     background-color: transparent; | ||||
|     border: none; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssStaticText = styled('div', ` | ||||
| export const cssMarkdownRendered = styled('div', ` | ||||
|   min-height: 1.5rem; | ||||
|   font-size: 15px; | ||||
|   & textarea { | ||||
|     font-size: 15px; | ||||
|   } | ||||
|   & strong { | ||||
|     font-weight: 600; | ||||
|   } | ||||
|   &-alignment-left { | ||||
|     text-align: left; | ||||
|   } | ||||
|   &-alignment-center { | ||||
|     text-align: center; | ||||
|   } | ||||
|   &-alignment-right { | ||||
|     text-align: right; | ||||
|   } | ||||
|   & hr { | ||||
|     border-color: ${colors.darkGrey}; | ||||
|     margin: 8px 0px; | ||||
|   } | ||||
|   &-separator { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     justify-content: center; | ||||
|   } | ||||
|   &-separator hr { | ||||
|     margin: 0px; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| export const cssMarkdownRender = styled('div', ` | ||||
|   & > p:last-child { | ||||
|     margin-bottom: 0px; | ||||
|   } | ||||
|   & h1 { | ||||
|     font-size: 24px; | ||||
|     margin: 4px 0px; | ||||
|     font-weight: normal; | ||||
|   } | ||||
|   & h2 { | ||||
|     font-size: 22px; | ||||
|     margin: 4px 0px; | ||||
|     font-weight: normal; | ||||
|   } | ||||
|   & h3 { | ||||
|     font-size: 16px; | ||||
|     margin: 4px 0px; | ||||
|     font-weight: normal; | ||||
|   } | ||||
|   & h4 { | ||||
|     font-size: 13px; | ||||
|     margin: 4px 0px; | ||||
|     font-weight: normal; | ||||
|   } | ||||
|   & h5 { | ||||
|     font-size: 11px; | ||||
|     margin: 4px 0px; | ||||
|     font-weight: normal; | ||||
|   } | ||||
|   & h6 { | ||||
|     font-size: 10px; | ||||
|     margin: 4px 0px; | ||||
|     font-weight: normal; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| export function markdown(obs: BindableValue<string>, ...args: IDomArgs<HTMLDivElement>) { | ||||
|   return dom('div', el => { | ||||
|   return cssMarkdownRender(el => { | ||||
|     dom.autoDisposeElem(el, subscribeBindable(obs, val => { | ||||
|       el.innerHTML = sanitizeHTML(marked(val)); | ||||
|     })); | ||||
|   }, ...args); | ||||
| } | ||||
| 
 | ||||
| export const cssDrag = styled('div.test-forms-drag', ` | ||||
| export const cssDrop = styled('div.test-forms-drag', ` | ||||
|   position: absolute; | ||||
|   pointer-events: none; | ||||
|   top: 2px; | ||||
| @ -351,21 +491,45 @@ export const cssDrag = styled('div.test-forms-drag', ` | ||||
|   height: 1px; | ||||
| `);
 | ||||
| 
 | ||||
| export const cssDragWrapper = styled('div', ` | ||||
|   position: absolute; | ||||
|   inset: 0px; | ||||
|   left: -16px; | ||||
|   top: 0px; | ||||
|   height: 100%; | ||||
|   width: 16px; | ||||
| `);
 | ||||
| 
 | ||||
| export const cssDrag = styled(icon, ` | ||||
|   position: absolute; | ||||
|   visibility: var(--hover-visible, hidden); | ||||
|   top: calc(50% - 16px / 2); | ||||
|   width: 16px; | ||||
|   height: 16px; | ||||
|   --icon-color: ${colors.lightGreen}; | ||||
|   &-top { | ||||
|     top: 16px; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| 
 | ||||
| export const cssPreview = styled('iframe', ` | ||||
|   height: 100%; | ||||
|   width: 100%; | ||||
|   border: 0px; | ||||
| `);
 | ||||
| 
 | ||||
| export const cssSwitcher = styled('div', ` | ||||
|   flex-shrink: 0; | ||||
|   margin-top: 24px; | ||||
|   width: 100%; | ||||
|   border-top: 1px solid ${theme.modalBorder}; | ||||
|   margin-left: -48px; | ||||
|   margin-right: -48px; | ||||
| `);
 | ||||
| 
 | ||||
| export const cssSwitcherMessage = styled('div', ` | ||||
|   display: flex; | ||||
|   padding: 0px 16px 0px 16px; | ||||
|   margin-bottom: 16px; | ||||
| `);
 | ||||
| 
 | ||||
| export const cssSwitcherMessageBody = styled('div', ` | ||||
| @ -373,7 +537,7 @@ export const cssSwitcherMessageBody = styled('div', ` | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
|   align-items: center; | ||||
|   padding: 0px 32px 0px 32px; | ||||
|   padding: 10px 32px; | ||||
| `);
 | ||||
| 
 | ||||
| export const cssSwitcherMessageDismissButton = styled('div', ` | ||||
| @ -392,3 +556,73 @@ export const cssSwitcherMessageDismissButton = styled('div', ` | ||||
| export const cssParagraph = styled('div', ` | ||||
|   margin-bottom: 16px; | ||||
| `);
 | ||||
| 
 | ||||
| export const cssFormEditBody = styled('div', ` | ||||
|   width: 100%; | ||||
|   overflow: auto; | ||||
|   padding-top: 52px; | ||||
|   padding-bottom: 24px; | ||||
| `);
 | ||||
| 
 | ||||
| export const cssRemoveButton = styled('div', ` | ||||
|   position: absolute; | ||||
|   right: 11px; | ||||
|   top: 11px; | ||||
|   border-radius: 3px; | ||||
|   background: ${colors.darkGrey}; | ||||
|   display: none; | ||||
|   height: 16px; | ||||
|   width: 16px; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   line-height: 0px; | ||||
|   z-index: 3; | ||||
|   & > div { | ||||
|     height: 13px; | ||||
|     width: 13px; | ||||
|   } | ||||
|   &:hover { | ||||
|     background: ${colors.mediumGreyOpaque}; | ||||
|     cursor: pointer; | ||||
|   } | ||||
|   .${cssFieldEditor.className}-selected > &, | ||||
|   .${cssFieldEditor.className}:hover > & { | ||||
|     display: flex; | ||||
|   } | ||||
|   &-right { | ||||
|     right: -20px; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| export function saveControls(editMode: Observable<boolean>, save: (ok: boolean) => void) { | ||||
|   return [ | ||||
|     dom.onKeyDown({ | ||||
|       Enter$: (ev) => { | ||||
|         // if shift ignore
 | ||||
|         if (ev.shiftKey) { | ||||
|           return; | ||||
|         } | ||||
|         ev.stopPropagation(); | ||||
|         ev.preventDefault(); | ||||
|         save(true); | ||||
|         editMode.set(false); | ||||
|         if (ev.target && 'blur' in ev.target) { | ||||
|           (ev.target as any).blur(); | ||||
|         } | ||||
|       }, | ||||
|       Escape: (ev) => { | ||||
|         save(false); | ||||
|         editMode.set(false); | ||||
|         if (ev.target && 'blur' in ev.target) { | ||||
|           (ev.target as any).blur(); | ||||
|         } | ||||
|       } | ||||
|     }), | ||||
|     dom.on('blur', (ev) => { | ||||
|       if (!editMode.isDisposed() && editMode.get()) { | ||||
|         save(true); | ||||
|         editMode.set(false); | ||||
|       } | ||||
|     }), | ||||
|   ]; | ||||
| } | ||||
|  | ||||
| @ -38,6 +38,7 @@ import {DocPageModel} from 'app/client/models/DocPageModel'; | ||||
| import {UserError} from 'app/client/models/errors'; | ||||
| import {getMainOrgUrl, urlState} from 'app/client/models/gristUrlState'; | ||||
| import {getFilterFunc, QuerySetManager} from 'app/client/models/QuerySet'; | ||||
| import TableModel from 'app/client/models/TableModel'; | ||||
| import {getUserOrgPrefObs, getUserOrgPrefsObs, markAsSeen} from 'app/client/models/UserPrefs'; | ||||
| import {App} from 'app/client/ui/App'; | ||||
| import {DocHistory} from 'app/client/ui/DocHistory'; | ||||
| @ -45,7 +46,7 @@ import {startDocTour} from "app/client/ui/DocTour"; | ||||
| import {DocTutorial} from 'app/client/ui/DocTutorial'; | ||||
| import {DocSettingsPage} from 'app/client/ui/DocumentSettings'; | ||||
| import {isTourActive} from "app/client/ui/OnBoardingPopups"; | ||||
| import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; | ||||
| import {DefaultPageWidget, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; | ||||
| import {linkFromId, selectBy} from 'app/client/ui/selectBy'; | ||||
| import {WebhookPage} from 'app/client/ui/WebhookPage'; | ||||
| import {startWelcomeTour} from 'app/client/ui/WelcomeTour'; | ||||
| @ -71,7 +72,7 @@ import {StringUnion} from 'app/common/StringUnion'; | ||||
| import {TableData} from 'app/common/TableData'; | ||||
| import {getGristConfig} from 'app/common/urlUtils'; | ||||
| import {DocStateComparison} from 'app/common/UserAPI'; | ||||
| import {AttachedCustomWidgets, IAttachedCustomWidget, IWidgetType} from 'app/common/widgetTypes'; | ||||
| import {AttachedCustomWidgets, IAttachedCustomWidget, IWidgetType, WidgetType} from 'app/common/widgetTypes'; | ||||
| import {CursorPos, UIRowId} from 'app/plugin/GristAPI'; | ||||
| import { | ||||
|   bundleChanges, | ||||
| @ -82,8 +83,10 @@ import { | ||||
|   fromKo, | ||||
|   Holder, | ||||
|   IDisposable, | ||||
|   IDisposableOwner, | ||||
|   IDomComponent, | ||||
|   keyframes, | ||||
|   MultiHolder, | ||||
|   Observable, | ||||
|   styled, | ||||
|   subscribe, | ||||
| @ -474,6 +477,7 @@ export class GristDoc extends DisposableWithEvents { | ||||
|       // Command to be manually triggered on cell selection. Moves the cursor to the selected cell.
 | ||||
|       // This is overridden by the formula editor to insert "$col" variables when clicking cells.
 | ||||
|       setCursor: this.onSetCursorPos.bind(this), | ||||
|       createForm: this.onCreateForm.bind(this), | ||||
|     }, this, true)); | ||||
| 
 | ||||
|     this.listenTo(app.comm, 'docUserAction', this.onDocUserAction); | ||||
| @ -873,7 +877,7 @@ export class GristDoc extends DisposableWithEvents { | ||||
|         return; | ||||
|       } | ||||
|     } | ||||
|     const res = await docData.bundleActions( | ||||
|     const res: {sectionRef: number} = await docData.bundleActions( | ||||
|       t("Added new linked section to view {{viewName}}", {viewName}), | ||||
|       () => this.addWidgetToPageImpl(val, tableId ?? null) | ||||
|     ); | ||||
| @ -886,6 +890,21 @@ export class GristDoc extends DisposableWithEvents { | ||||
|     if (AttachedCustomWidgets.guard(val.type)) { | ||||
|       this._handleNewAttachedCustomWidget(val.type).catch(reportError); | ||||
|     } | ||||
| 
 | ||||
|     return res.sectionRef; | ||||
|   } | ||||
| 
 | ||||
|   public async onCreateForm() { | ||||
|     const table = this.currentView.get()?.viewSection.tableRef.peek(); | ||||
|     if (!table) { | ||||
|       return; | ||||
|     } | ||||
|     await this.addWidgetToPage({ | ||||
|       ...DefaultPageWidget(), | ||||
|       table, | ||||
|       type: WidgetType.Form, | ||||
|     }); | ||||
|     commands.allCommands.expandSection.run(); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
| @ -914,7 +933,7 @@ export class GristDoc extends DisposableWithEvents { | ||||
|         return; | ||||
|       } | ||||
|       let newViewId: IDocPage; | ||||
|       if (val.type === 'record') { | ||||
|       if (val.type === WidgetType.Table) { | ||||
|         const result = await this.docData.sendAction(['AddEmptyTable', name]); | ||||
|         newViewId = result.views[0].id; | ||||
|       } else { | ||||
| @ -1468,6 +1487,32 @@ export class GristDoc extends DisposableWithEvents { | ||||
|     this._showBackgroundVideoPlayer.set(false); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Creates computed with all the data for the given column. | ||||
|    */ | ||||
|   public columnObserver(owner: IDisposableOwner, tableId: Observable<string>, columnId: Observable<string>) { | ||||
|     const tableModel = Computed.create(owner, (use) => this.docModel.dataTables[use(tableId)]); | ||||
|     const refreshed = Observable.create(owner, 0); | ||||
|     const toggle = () => !refreshed.isDisposed() && refreshed.set(refreshed.get() + 1); | ||||
|     const holder = Holder.create(owner); | ||||
|     const listener = (tab: TableModel) => { | ||||
|       // Now subscribe to any data change in that table.
 | ||||
|       const subs = MultiHolder.create(holder); | ||||
|       subs.autoDispose(tab.tableData.dataLoadedEmitter.addListener(toggle)); | ||||
|       subs.autoDispose(tab.tableData.tableActionEmitter.addListener(toggle)); | ||||
|       tab.fetch().catch(reportError); | ||||
|     }; | ||||
|     owner.autoDispose(tableModel.addListener(listener)); | ||||
|     listener(tableModel.get()); | ||||
|     const values = Computed.create(owner, refreshed, (use) => { | ||||
|       const rows = use(tableModel).getAllRows(); | ||||
|       const colValues = use(tableModel).tableData.getColValues(use(columnId)); | ||||
|       if (!colValues) { return []; } | ||||
|       return rows.map((row, i) => [row, colValues[i]]); | ||||
|     }); | ||||
|     return values; | ||||
|   } | ||||
| 
 | ||||
|   private _focusPreviousSection() { | ||||
|     const prevSectionId = this._prevSectionId; | ||||
|     if (!prevSectionId) { return; } | ||||
|  | ||||
| @ -197,7 +197,7 @@ export class LayoutTray extends DisposableWithEvents { | ||||
|         // And ask the viewLayout to save the specs.
 | ||||
|         viewLayout.saveLayoutSpec(); | ||||
|       }, | ||||
|       expandSection: () => { | ||||
|       restoreSection: () => { | ||||
|         // Get the section that is collapsed and clicked (we are setting this value).
 | ||||
|         const leafId = viewLayout.viewModel.activeCollapsedSectionId(); | ||||
|         if (!leafId) { return; } | ||||
|  | ||||
| @ -147,21 +147,19 @@ ViewConfigTab.prototype._buildAdvancedSettingsDom = function() { | ||||
| }; | ||||
| 
 | ||||
| ViewConfigTab.prototype._buildThemeDom = function() { | ||||
|   return kd.maybe(this.activeSectionData, (sectionData) => { | ||||
|     var section = sectionData.section; | ||||
|     if (this.isDetail()) { | ||||
|       const theme = Computed.create(null, (use) => use(section.themeDef)); | ||||
|       theme.onWrite(val => section.themeDef.setAndSave(val)); | ||||
|       return cssRow( | ||||
|         dom.autoDispose(theme), | ||||
|         select(theme, [ | ||||
|           {label: t("Form"),        value: 'form'   }, | ||||
|           {label: t("Compact"),     value: 'compact'}, | ||||
|           {label: t("Blocks"),      value: 'blocks'  }, | ||||
|         ]), | ||||
|         testId('detail-theme') | ||||
|       ); | ||||
|     } | ||||
|   return kd.maybe(() => this.isDetail() ? this.activeSectionData() : null, (sectionData) => { | ||||
|     const section = sectionData.section; | ||||
|     const theme = Computed.create(null, (use) => use(section.themeDef)); | ||||
|     theme.onWrite(val => section.themeDef.setAndSave(val)); | ||||
|     return cssRow( | ||||
|       dom.autoDispose(theme), | ||||
|       select(theme, [ | ||||
|         {label: t("Form"),        value: 'form'   }, | ||||
|         {label: t("Compact"),     value: 'compact'}, | ||||
|         {label: t("Blocks"),      value: 'blocks'  }, | ||||
|       ]), | ||||
|       testId('detail-theme') | ||||
|     ); | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| @ -170,21 +168,19 @@ ViewConfigTab.prototype._buildChartConfigDom = function() { | ||||
| }; | ||||
| 
 | ||||
| ViewConfigTab.prototype._buildLayoutDom = function() { | ||||
|   return kd.maybe(this.activeSectionData, (sectionData) => { | ||||
|     if (this.isDetail()) { | ||||
|       const view = sectionData.section.viewInstance.peek(); | ||||
|       const layoutEditorObs = ko.computed(() => view && view.recordLayout && view.recordLayout.layoutEditor()); | ||||
|       return cssRow({style: 'margin-top: 16px;'}, | ||||
|         kd.maybe(layoutEditorObs, (editor) => editor.buildFinishButtons()), | ||||
|         primaryButton(t("Edit Card Layout"), | ||||
|           dom.autoDispose(layoutEditorObs), | ||||
|           dom.on('click', () => commands.allCommands.editLayout.run()), | ||||
|           grainjsDom.hide(layoutEditorObs), | ||||
|           grainjsDom.cls('behavioral-prompt-edit-card-layout'), | ||||
|           testId('detail-edit-layout'), | ||||
|         ) | ||||
|       ); | ||||
|     } | ||||
|   return kd.maybe(() => this.isDetail() ? this.activeSectionData() : null, (sectionData) => { | ||||
|     const view = sectionData.section.viewInstance.peek(); | ||||
|     const layoutEditorObs = ko.computed(() => view && view.recordLayout && view.recordLayout.layoutEditor()); | ||||
|     return cssRow({style: 'margin-top: 16px;'}, | ||||
|       kd.maybe(layoutEditorObs, (editor) => editor.buildFinishButtons()), | ||||
|       primaryButton(t("Edit Card Layout"), | ||||
|         dom.autoDispose(layoutEditorObs), | ||||
|         dom.on('click', () => commands.allCommands.editLayout.run()), | ||||
|         grainjsDom.hide(layoutEditorObs), | ||||
|         grainjsDom.cls('behavioral-prompt-edit-card-layout'), | ||||
|         testId('detail-edit-layout'), | ||||
|       ) | ||||
|     ); | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
|  | ||||
| @ -190,7 +190,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { | ||||
|       prevSection: () => { this._otherSection(-1); }, | ||||
|       printSection: () => { printViewSection(this.layout, this.viewModel.activeSection()).catch(reportError); }, | ||||
|       sortFilterMenuOpen: (sectionId?: number) => { this._openSortFilterMenu(sectionId); }, | ||||
|       maximizeActiveSection: () => { this._maximizeActiveSection(); }, | ||||
|       expandSection: () => { this._expandSection(); }, | ||||
|       cancel: () => { | ||||
|         if (this.maximized.get()) { | ||||
|           this.maximized.set(null); | ||||
| @ -294,7 +294,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { | ||||
|     oldTray.dispose(); | ||||
|   } | ||||
| 
 | ||||
|   private _maximizeActiveSection() { | ||||
|   private _expandSection() { | ||||
|     const activeSection = this.viewModel.activeSection(); | ||||
|     const activeSectionId = activeSection.getRowId(); | ||||
|     const activeSectionBox = this.layout.getLeafBox(activeSectionId); | ||||
|  | ||||
| @ -22,7 +22,7 @@ export type CommandName = | ||||
|   | 'printSection' | ||||
|   | 'showRawData' | ||||
|   | 'openWidgetConfiguration' | ||||
|   | 'maximizeActiveSection' | ||||
|   | 'expandSection' | ||||
|   | 'leftPanelOpen' | ||||
|   | 'rightPanelOpen' | ||||
|   | 'videoTourToolsOpen' | ||||
| @ -95,7 +95,7 @@ export type CommandName = | ||||
|   | 'addSection' | ||||
|   | 'deleteSection' | ||||
|   | 'collapseSection' | ||||
|   | 'expandSection' | ||||
|   | 'restoreSection' | ||||
|   | 'deleteCollapsedSection' | ||||
|   | 'duplicateRows' | ||||
|   | 'sortAsc' | ||||
| @ -115,6 +115,8 @@ export type CommandName = | ||||
|   | 'activateAssistant' | ||||
|   | 'viewAsCard' | ||||
|   | 'showColumns' | ||||
|   | 'createForm' | ||||
|   | 'insertField' | ||||
|   ; | ||||
| 
 | ||||
| 
 | ||||
| @ -252,7 +254,7 @@ export const groups: CommendGroupDef[] = [{ | ||||
|       desc: 'Open Custom widget configuration screen', | ||||
|     }, | ||||
|     { | ||||
|       name: 'maximizeActiveSection', | ||||
|       name: 'expandSection', | ||||
|       keys: [], | ||||
|       desc: 'Maximize the active section', | ||||
|     }, | ||||
| @ -281,6 +283,16 @@ export const groups: CommendGroupDef[] = [{ | ||||
|       keys: ['Space'], | ||||
|       desc: 'Show the record card widget of the selected record', | ||||
|     }, | ||||
|     { | ||||
|       name: 'createForm', | ||||
|       keys: [], | ||||
|       desc: 'Creates form for active table', | ||||
|     }, | ||||
|     { | ||||
|       name: 'insertField', | ||||
|       keys: [], | ||||
|       desc: 'Insert new column in default location', | ||||
|     }, | ||||
|   ] | ||||
| }, { | ||||
|   group: 'Navigation', | ||||
| @ -590,7 +602,7 @@ export const groups: CommendGroupDef[] = [{ | ||||
|       keys: [], | ||||
|       desc: 'Collapse the currently active viewsection' | ||||
|     }, { | ||||
|       name: 'expandSection', | ||||
|       name: 'restoreSection', | ||||
|       keys: [], | ||||
|       desc: 'Expand collapsed viewsection' | ||||
|     }, { | ||||
|  | ||||
| @ -27,6 +27,7 @@ import {UserAction} from 'app/common/DocActions'; | ||||
| import {RecalcWhen} from 'app/common/gristTypes'; | ||||
| import {arrayRepeat} from 'app/common/gutil'; | ||||
| import {Sort} from 'app/common/SortSpec'; | ||||
| import {WidgetType} from 'app/common/widgetTypes'; | ||||
| import {ColumnsToMap, WidgetColumnMap} from 'app/plugin/CustomSectionAPI'; | ||||
| import {CursorPos, UIRowId} from 'app/plugin/GristAPI'; | ||||
| import {GristObjCode} from 'app/plugin/GristData'; | ||||
| @ -259,6 +260,8 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO | ||||
|   // Common type of selected columns or mixed.
 | ||||
|   columnsType: ko.PureComputed<string|'mixed'>; | ||||
| 
 | ||||
|   widgetType: modelUtil.KoSaveableObservable<WidgetType>; | ||||
| 
 | ||||
|   // Save all filters of fields/columns in the section.
 | ||||
|   saveFilters(): Promise<void>; | ||||
| 
 | ||||
| @ -276,9 +279,19 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO | ||||
| 
 | ||||
|   insertColumn(colId?: string|null, options?: InsertColOptions): Promise<NewFieldInfo>; | ||||
| 
 | ||||
|   /** | ||||
|    * Shows column (by adding a view field) | ||||
|    * @param col ColId or ColRef | ||||
|    * @param index Position to insert the column at | ||||
|    * @returns ViewField rowId | ||||
|    */ | ||||
|   showColumn(col: number|string, index?: number): Promise<number> | ||||
| 
 | ||||
|   removeField(colRef: number): Promise<void>; | ||||
|   /** | ||||
|    * Removes one or multiple fields. | ||||
|    * @param colRef | ||||
|    */ | ||||
|   removeField(colRef: number|Array<number>): Promise<void>; | ||||
| } | ||||
| 
 | ||||
| export type WidgetMappedColumn = number|number[]|null; | ||||
| @ -361,6 +374,7 @@ export interface Filter { | ||||
| } | ||||
| 
 | ||||
| export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): void { | ||||
|   this.widgetType = this.parentKey as any; | ||||
|   this.viewFields = recordSet(this, docModel.viewFields, 'parentId', {sortBy: 'parentPos'}); | ||||
|   this.linkedSections = recordSet(this, docModel.viewSections, 'linkSrcSectionRef'); | ||||
| 
 | ||||
| @ -872,8 +886,13 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): | ||||
|     return await docModel.viewFields.sendTableAction(['AddRecord', null, colInfo]); | ||||
|   }; | ||||
| 
 | ||||
|   this.removeField = async (fieldRef: number) => { | ||||
|     const action = ['RemoveRecord', fieldRef]; | ||||
|     await docModel.viewFields.sendTableAction(action); | ||||
|   this.removeField = async (fieldRef: number|number[]) => { | ||||
|     if (Array.isArray(fieldRef)) { | ||||
|       const action = ['BulkRemoveRecord', fieldRef]; | ||||
|       await docModel.viewFields.sendTableAction(action); | ||||
|     } else { | ||||
|       const action = ['RemoveRecord', fieldRef]; | ||||
|       await docModel.viewFields.sendTableAction(action); | ||||
|     } | ||||
|   }; | ||||
| } | ||||
|  | ||||
| @ -52,18 +52,20 @@ export function buildDescriptionConfig( | ||||
| export function buildTextInput( | ||||
|   owner: MultiHolder, | ||||
|   options: { | ||||
|     value: KoSaveableObservable<any>, | ||||
|     cursor: ko.Computed<CursorPos>, | ||||
|     label: string, | ||||
|     value: KoSaveableObservable<any>, | ||||
|     cursor?: ko.Computed<CursorPos>, | ||||
|     placeholder?: ko.Computed<string>, | ||||
|   }, | ||||
|   ...args: DomArg[] | ||||
| ) { | ||||
|   owner.autoDispose( | ||||
|     options.cursor.subscribe(() => { | ||||
|       options.value.save().catch(reportError); | ||||
|     }) | ||||
|   ); | ||||
|   if (options.cursor) { | ||||
|     owner.autoDispose( | ||||
|       options.cursor.subscribe(() => { | ||||
|         options.value.save().catch(reportError); | ||||
|       }) | ||||
|     ); | ||||
|   } | ||||
|   return [ | ||||
|     cssLabel(options.label), | ||||
|     cssRow( | ||||
| @ -84,7 +86,6 @@ const cssTextInput = styled(textInput, ` | ||||
|   border: 1px solid ${theme.inputBorder}; | ||||
|   width: 100%; | ||||
|   outline: none; | ||||
|   border-radius: 3px; | ||||
|   height: 28px; | ||||
|   border-radius: 3px; | ||||
|   padding: 0px 6px; | ||||
|  | ||||
| @ -59,6 +59,15 @@ export interface IPageWidget { | ||||
|   section: number; | ||||
| } | ||||
| 
 | ||||
| export const DefaultPageWidget: () => IPageWidget = () => ({ | ||||
|   type: 'record', | ||||
|   table: null, | ||||
|   summarize: false, | ||||
|   columns: [], | ||||
|   link: NoLink, | ||||
|   section: 0, | ||||
| }); | ||||
| 
 | ||||
| // Creates a IPageWidget from a ViewSectionRec.
 | ||||
| export function toPageWidget(section: ViewSectionRec): IPageWidget { | ||||
|   const link = linkId({ | ||||
|  | ||||
| @ -15,7 +15,9 @@ | ||||
|  */ | ||||
| 
 | ||||
| import * as commands from 'app/client/components/commands'; | ||||
| import {HiddenQuestionConfig} from 'app/client/components/Forms/HiddenQuestionConfig'; | ||||
| import {FieldModel} from 'app/client/components/Forms/Field'; | ||||
| import {FormView} from 'app/client/components/Forms/FormView'; | ||||
| import {UnmappedFieldsConfig} from 'app/client/components/Forms/UnmappedFieldsConfig'; | ||||
| import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc'; | ||||
| import {EmptyFilterState} from "app/client/components/LinkingState"; | ||||
| import {RefSelect} from 'app/client/components/RefSelect'; | ||||
| @ -27,9 +29,11 @@ import {createSessionObs, isBoolean, SessionObs} from 'app/client/lib/sessionObs | ||||
| import {reportError} from 'app/client/models/AppModel'; | ||||
| import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel'; | ||||
| import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig'; | ||||
| import {buildDescriptionConfig, buildTextInput} from 'app/client/ui/DescriptionConfig'; | ||||
| import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig'; | ||||
| import {BuildEditorOptions} from 'app/client/ui/FieldConfig'; | ||||
| import {autoGrow} from 'app/client/ui/forms'; | ||||
| import {GridOptions} from 'app/client/ui/GridOptions'; | ||||
| import {textarea} from 'app/client/ui/inputs'; | ||||
| import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; | ||||
| import {PredefinedCustomSectionConfig} from "app/client/ui/PredefinedCustomSectionConfig"; | ||||
| import {cssLabel} from 'app/client/ui/RightPanelStyles'; | ||||
| @ -37,6 +41,8 @@ import {linkId, selectBy} from 'app/client/ui/selectBy'; | ||||
| import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig'; | ||||
| import {widgetTypesMap} from "app/client/ui/widgetTypesMap"; | ||||
| import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; | ||||
| import {buttonSelect} from 'app/client/ui2018/buttonSelect'; | ||||
| import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox'; | ||||
| import {testId, theme, vars} from 'app/client/ui2018/cssVars'; | ||||
| import {textInput} from 'app/client/ui2018/editableLabel'; | ||||
| import {IconName} from 'app/client/ui2018/IconList'; | ||||
| @ -56,6 +62,7 @@ import { | ||||
|   DomContents, | ||||
|   DomElementArg, | ||||
|   DomElementMethod, | ||||
|   fromKo, | ||||
|   IDomComponent, | ||||
|   MultiHolder, | ||||
|   Observable, | ||||
| @ -74,7 +81,7 @@ const t = makeT('RightPanel'); | ||||
| const TopTab = StringUnion("pageWidget", "field"); | ||||
| 
 | ||||
| // Represents a subtab of pageWidget in the right side-pane.
 | ||||
| const PageSubTab = StringUnion("widget", "sortAndFilter", "data"); | ||||
| const PageSubTab = StringUnion("widget", "sortAndFilter", "data", "submission"); | ||||
| 
 | ||||
| // Returns the icon and label of a type, default to those associate to 'record' type.
 | ||||
| export function getFieldType(widgetType: IWidgetType|null) { | ||||
| @ -85,6 +92,7 @@ export function getFieldType(widgetType: IWidgetType|null) { | ||||
|     ['single', {label: t('Fields', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Fields', { count: 2 })}], | ||||
|     ['chart', {label: t('Series', { count: 1 }), icon: 'ChartLine', pluralLabel: t('Series', { count: 2 })}], | ||||
|     ['custom', {label: t('Columns', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Columns', { count: 2 })}], | ||||
|     ['form', {label: t('Fields', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Fields', { count: 2 })}], | ||||
|   ]); | ||||
| 
 | ||||
|   return fieldTypes.get(widgetType || 'record') || fieldTypes.get('record')!; | ||||
| @ -111,6 +119,10 @@ export class RightPanel extends Disposable { | ||||
|     return (use(section.parentKey) || null) as IWidgetType; | ||||
|   }); | ||||
| 
 | ||||
|   private _isForm = Computed.create(this, (use) => { | ||||
|     return use(this._pageWidgetType) === 'form'; | ||||
|   }); | ||||
| 
 | ||||
|   // Returns the active section if it's valid, null otherwise.
 | ||||
|   private _validSection = Computed.create(this, (use) => { | ||||
|     const sec = use(this._gristDoc.viewModel.activeSection); | ||||
| @ -135,6 +147,16 @@ export class RightPanel extends Disposable { | ||||
|       sortFilterTabOpen: () => this._openSortFilter(), | ||||
|       dataSelectionTabOpen: () => this._openDataSelection() | ||||
|     }, this, true)); | ||||
| 
 | ||||
|     // When a page widget is changed, subType might not be valid anymore, so reset it.
 | ||||
|     // TODO: refactor sub tabs and navigation using order of the tab.
 | ||||
|     this.autoDispose(subscribe((use) => { | ||||
|       if (!use(this._isForm) && use(this._subTab) === 'submission') { | ||||
|         setImmediate(() => !this._subTab.isDisposed() && this._subTab.set('sortAndFilter')); | ||||
|       } else if (use(this._isForm) && use(this._subTab) === 'sortAndFilter') { | ||||
|         setImmediate(() => !this._subTab.isDisposed() && this._subTab.set('submission')); | ||||
|       } | ||||
|     })); | ||||
|   } | ||||
| 
 | ||||
|   private _openFieldTab() { | ||||
| @ -216,13 +238,27 @@ export class RightPanel extends Disposable { | ||||
|       if (!use(this._isOpen)) { return null; } | ||||
|       const tool = use(this._extraTool); | ||||
|       if (tool) { return tabContentToDom(tool.content); } | ||||
|       const isForm = use(this._isForm); | ||||
| 
 | ||||
|       const topTab = use(this._topTab); | ||||
|       if (topTab === 'field') { | ||||
|         return dom.create(this._buildFieldContent.bind(this)); | ||||
|       } | ||||
|       if (topTab === 'pageWidget' && use(this._pageWidgetType)) { | ||||
|         return dom.create(this._buildPageWidgetContent.bind(this)); | ||||
|         if (isForm) { | ||||
|           return dom.create(this._buildQuestionContent.bind(this)); | ||||
|         } else { | ||||
|           return dom.create(this._buildFieldContent.bind(this)); | ||||
|         } | ||||
|       } else if (topTab === 'pageWidget') { | ||||
|         if (isForm) { | ||||
|           return [ | ||||
|             dom.create(this._buildPageFormHeader.bind(this)), | ||||
|             dom.create(this._buildPageWidgetContent.bind(this)), | ||||
|           ]; | ||||
|         } else { | ||||
|           return [ | ||||
|             dom.create(this._buildPageWidgetHeader.bind(this)), | ||||
|             dom.create(this._buildPageWidgetContent.bind(this)), | ||||
|           ]; | ||||
|         } | ||||
|       } | ||||
|       return null; | ||||
|     }); | ||||
| @ -264,18 +300,6 @@ export class RightPanel extends Disposable { | ||||
|     // Builder for the reference display column multiselect.
 | ||||
|     const refSelect = RefSelect.create(owner, {docModel, origColumn, fieldBuilder}); | ||||
| 
 | ||||
|     // The original selected field model.
 | ||||
|     const fieldRef = owner.autoDispose(ko.pureComputed(() => { | ||||
|       return ((fieldBuilder()?.field)?.id()) ?? 0; | ||||
|     })); | ||||
|     const selectedField = owner.autoDispose(docModel.viewFields.createFloatingRowModel(fieldRef)); | ||||
| 
 | ||||
|     // For forms we will show some extra options.
 | ||||
|     const isForm = owner.autoDispose(ko.computed(() => { | ||||
|       const vs = this._gristDoc.viewModel.activeSection(); | ||||
|       return vs.parentKey() === 'form'; | ||||
|     })); | ||||
| 
 | ||||
|     // build cursor position observable
 | ||||
|     const cursor = owner.autoDispose(ko.computed(() => { | ||||
|       const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance(); | ||||
| @ -289,14 +313,6 @@ export class RightPanel extends Disposable { | ||||
|           cssSection( | ||||
|             dom.create(buildNameConfig, origColumn, cursor, isMultiSelect), | ||||
|           ), | ||||
|           dom.maybe(isForm, () => [ | ||||
|             cssSection( | ||||
|               dom.create(buildTextInput, { | ||||
|                 cursor, label: 'Question', value: selectedField.question, | ||||
|                 placeholder: selectedField.origLabel | ||||
|               }), | ||||
|             ), | ||||
|           ]), | ||||
|           cssSection( | ||||
|             dom.create(buildDescriptionConfig, origColumn.description, { cursor, "testPrefix": "column" }), | ||||
|           ), | ||||
| @ -357,7 +373,48 @@ export class RightPanel extends Disposable { | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   private _buildPageWidgetContent(_owner: MultiHolder) { | ||||
|   private _buildPageWidgetContent() { | ||||
|     const content = (activeSection: ViewSectionRec, type: typeof PageSubTab.type) => { | ||||
|       switch(type){ | ||||
|         case 'widget': return dom.create(this._buildPageWidgetConfig.bind(this), activeSection); | ||||
|         case 'sortAndFilter': return [ | ||||
|           dom.create(this._buildPageSortFilterConfig.bind(this)), | ||||
|           cssConfigContainer.cls('-disabled', activeSection.isRecordCard), | ||||
|         ]; | ||||
|         case 'data': return dom.create(this._buildPageDataConfig.bind(this), activeSection); | ||||
|         case 'submission': return dom.create(this._buildPageSubmissionConfig.bind(this), activeSection); | ||||
|         default: return null; | ||||
|       } | ||||
|     }; | ||||
|     return dom.domComputed(this._subTab, (subTab) => ( | ||||
|       dom.maybe(this._validSection, (activeSection) => ( | ||||
|         buildConfigContainer( | ||||
|           content(activeSection, subTab) | ||||
|         ) | ||||
|       )) | ||||
|     )); | ||||
|   } | ||||
| 
 | ||||
|   private _buildPageFormHeader(_owner: MultiHolder) { | ||||
|     return [ | ||||
|       cssSubTabContainer( | ||||
|         cssSubTab(t("Configuration"), | ||||
|           cssSubTab.cls('-selected', (use) => use(this._subTab) === 'widget'), | ||||
|           dom.on('click', () => this._subTab.set("widget")), | ||||
|           testId('config-widget')), | ||||
|         cssSubTab(t("Submission"), | ||||
|           cssSubTab.cls('-selected', (use) => use(this._subTab) === 'submission'), | ||||
|           dom.on('click', () => this._subTab.set("submission")), | ||||
|           testId('config-submission')), | ||||
|         cssSubTab(t("Data"), | ||||
|           cssSubTab.cls('-selected', (use) => use(this._subTab) === 'data'), | ||||
|           dom.on('click', () => this._subTab.set("data")), | ||||
|           testId('config-data')), | ||||
|       ), | ||||
|     ]; | ||||
|   } | ||||
| 
 | ||||
|   private _buildPageWidgetHeader(_owner: MultiHolder) { | ||||
|     return [ | ||||
|       cssSubTabContainer( | ||||
|         cssSubTab(t("Widget"), | ||||
| @ -373,19 +430,6 @@ export class RightPanel extends Disposable { | ||||
|           dom.on('click', () => this._subTab.set("data")), | ||||
|           testId('config-data')), | ||||
|       ), | ||||
|       dom.domComputed(this._subTab, (subTab) => ( | ||||
|         dom.maybe(this._validSection, (activeSection) => ( | ||||
|           buildConfigContainer( | ||||
|             subTab === 'widget' ? dom.create(this._buildPageWidgetConfig.bind(this), activeSection) : | ||||
|               subTab === 'sortAndFilter' ? [ | ||||
|                 dom.create(this._buildPageSortFilterConfig.bind(this)), | ||||
|                 cssConfigContainer.cls('-disabled', activeSection.isRecordCard), | ||||
|               ] : | ||||
|               subTab === 'data' ? dom.create(this._buildPageDataConfig.bind(this), activeSection) : | ||||
|               null | ||||
|           ) | ||||
|         )) | ||||
|       )) | ||||
|     ]; | ||||
|   } | ||||
| 
 | ||||
| @ -449,21 +493,6 @@ export class RightPanel extends Disposable { | ||||
|         ), | ||||
|       ), | ||||
| 
 | ||||
|       cssSeparator(dom.hide(activeSection.isRecordCard)), | ||||
| 
 | ||||
|       dom.domComputed(use => { | ||||
|         const vs = use(activeSection.viewInstance); | ||||
|         if (!vs || use(activeSection.parentKey) !== 'form') { return null; } | ||||
|         return [ | ||||
|           cssRow( | ||||
|             primaryButton(t("Reset form"), dom.on('click', () => { | ||||
|               activeSection.layoutSpecObj.setAndSave(null).catch(reportError); | ||||
|             })), | ||||
|             cssRow.cls('-top-space') | ||||
|           ), | ||||
|         ]; | ||||
|       }), | ||||
| 
 | ||||
|       dom.maybe((use) => ['detail', 'single'].includes(use(this._pageWidgetType)!), () => [ | ||||
|         cssLabel(t("Theme")), | ||||
|         dom('div', | ||||
| @ -526,9 +555,9 @@ export class RightPanel extends Disposable { | ||||
|           dom.create(VisibleFieldsConfig, this._gristDoc, activeSection), | ||||
|         ]), | ||||
| 
 | ||||
|       dom.maybe(use => use(activeSection.parentKey) === 'form', () => [ | ||||
|       dom.maybe(this._isForm, () => [ | ||||
|         cssSeparator(), | ||||
|         dom.create(HiddenQuestionConfig, activeSection), | ||||
|         dom.create(UnmappedFieldsConfig, activeSection), | ||||
|       ]), | ||||
|     ]); | ||||
|   } | ||||
| @ -733,10 +762,6 @@ export class RightPanel extends Disposable { | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
|   private _buildPageDataConfig(owner: MultiHolder, activeSection: ViewSectionRec) { | ||||
|     const viewConfigTab = this._createViewConfigTab(owner); | ||||
|     const viewModel = this._gristDoc.viewModel; | ||||
| @ -874,6 +899,180 @@ export class RightPanel extends Disposable { | ||||
|       )); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private _buildPageSubmissionConfig(owner: MultiHolder, activeSection: ViewSectionRec) { | ||||
|     // All of those observables are backed by the layout config.
 | ||||
|     const submitButtonKo = activeSection.layoutSpecObj.prop('submitText'); | ||||
|     const toComputed = (obs: typeof submitButtonKo) => { | ||||
|       const result = Computed.create(owner, (use) => use(obs)); | ||||
|       result.onWrite(val => obs.setAndSave(val)); | ||||
|       return result; | ||||
|     }; | ||||
|     const submitButton = toComputed(submitButtonKo); | ||||
|     const successText = toComputed(activeSection.layoutSpecObj.prop('successText')); | ||||
|     const successURL = toComputed(activeSection.layoutSpecObj.prop('successURL')); | ||||
|     const anotherResponse = toComputed(activeSection.layoutSpecObj.prop('anotherResponse')); | ||||
|     const redirection = Observable.create(owner, Boolean(successURL.get())); | ||||
|     owner.autoDispose(redirection.addListener(val => { | ||||
|       if (!val) { | ||||
|         successURL.set(null); | ||||
|       } | ||||
|     })); | ||||
|     owner.autoDispose(successURL.addListener(val => { | ||||
|       if (val) { | ||||
|         redirection.set(true); | ||||
|       } | ||||
|     })); | ||||
|     return [ | ||||
|       cssLabel(t("Submit button label")), | ||||
|       cssRow( | ||||
|         cssTextInput(submitButton, (val) => submitButton.set(val)), | ||||
|       ), | ||||
|       cssLabel(t("Success text")), | ||||
|       cssRow( | ||||
|         cssTextArea(successText, {onInput: true}, autoGrow(successText)), | ||||
|       ), | ||||
|       cssLabel(t("Submit another response")), | ||||
|       cssRow( | ||||
|         labeledSquareCheckbox(anotherResponse, [ | ||||
|           t("Display button"), | ||||
|         ]), | ||||
|       ), | ||||
|       cssLabel(t("Redirection")), | ||||
|       cssRow( | ||||
|         labeledSquareCheckbox(redirection, t('Redirect automatically after submission')), | ||||
|       ), | ||||
|       cssRow( | ||||
|         cssTextInput(successURL, (val) => successURL.set(val)), | ||||
|         dom.show(redirection), | ||||
|       ), | ||||
|     ]; | ||||
|   } | ||||
| 
 | ||||
|   private _buildQuestionContent(owner: MultiHolder) { | ||||
|     const fieldBuilder = owner.autoDispose(ko.computed(() => { | ||||
|       const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance(); | ||||
|       return vsi && vsi.activeFieldBuilder(); | ||||
|     })); | ||||
| 
 | ||||
|     const formView = owner.autoDispose(ko.computed(() => { | ||||
|       const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance(); | ||||
|       return (vsi ?? null) as FormView|null; | ||||
|     })); | ||||
| 
 | ||||
|     const selectedBox = Computed.create(owner, (use) => use(formView) && use(use(formView)!.selectedBox)); | ||||
|     const selectedField = Computed.create(owner, (use) => { | ||||
|       const box = use(selectedBox); | ||||
|       if (!box) { return null; } | ||||
|       if (box.type !== 'Field') { return null; } | ||||
|       const fieldBox = box as FieldModel; | ||||
|       return use(fieldBox.field); | ||||
|     }); | ||||
|     const selectedColumn = Computed.create(owner, (use) => use(selectedField) && use(use(selectedField)!.origCol)); | ||||
| 
 | ||||
|     const hasText = Computed.create(owner, (use) => { | ||||
|       const box = use(selectedBox); | ||||
|       if (!box) { return false; } | ||||
|       switch (box.type) { | ||||
|         case 'Submit': | ||||
|         case 'Paragraph': | ||||
|         case 'Label': | ||||
|           return true; | ||||
|         default: | ||||
|           return false; | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     return cssSection( | ||||
|       // Field config.
 | ||||
|       dom.maybe(selectedField, (field) => { | ||||
|         const requiredField = field.widgetOptionsJson.prop('formRequired'); | ||||
|         // V2 thing.
 | ||||
|         // const hiddenField = field.widgetOptionsJson.prop('formHidden');
 | ||||
|         const defaultField = field.widgetOptionsJson.prop('formDefault'); | ||||
|         const toComputed = (obs: typeof defaultField) => { | ||||
|           const result = Computed.create(null, (use) => use(obs)); | ||||
|           result.onWrite(val => obs.setAndSave(val)); | ||||
|           return result; | ||||
|         }; | ||||
|         return [ | ||||
|           cssLabel(t("Field title")), | ||||
|           cssRow( | ||||
|             cssTextInput( | ||||
|               fromKo(field.label), | ||||
|               (val) => field.displayLabel.saveOnly(val), | ||||
|               dom.prop('readonly', use => use(field.disableModify)), | ||||
|             ), | ||||
|           ), | ||||
|           cssLabel(t("Table column name")), | ||||
|           cssRow( | ||||
|             cssTextInput( | ||||
|               fromKo(field.colId), | ||||
|               (val) => field.column().colId.saveOnly(val), | ||||
|               dom.prop('readonly', use => use(field.disableModify)), | ||||
|             ), | ||||
|           ), | ||||
|           // TODO: this is for V1 as it requires full cell editor here.
 | ||||
|           // cssLabel(t("Default field value")),
 | ||||
|           // cssRow(
 | ||||
|           //   cssTextInput(
 | ||||
|           //     fromKo(defaultField),
 | ||||
|           //     (val) => defaultField.setAndSave(val),
 | ||||
|           //   ),
 | ||||
|           // ),
 | ||||
|           dom.maybe<FieldBuilder|null>(fieldBuilder, builder => [ | ||||
|             cssSeparator(), | ||||
|             cssLabel(t("COLUMN TYPE")), | ||||
|             cssSection( | ||||
|               builder.buildSelectTypeDom(), | ||||
|             ), | ||||
|             // V2 thing
 | ||||
|             // cssSection(
 | ||||
|             //   builder.buildSelectWidgetDom(),
 | ||||
|             // ),
 | ||||
|             dom.maybe(use => ['Choice', 'ChoiceList', 'Ref', 'RefList'].includes(use(builder.field.pureType)), () => [ | ||||
|               cssSection( | ||||
|                 builder.buildConfigDom(), | ||||
|               ), | ||||
|             ]), | ||||
|           ]), | ||||
|           cssSeparator(), | ||||
|           cssLabel(t("Field rules")), | ||||
|           cssRow(labeledSquareCheckbox(toComputed(requiredField), t("Required field")),), | ||||
|           // V2 thing
 | ||||
|           // cssRow(labeledSquareCheckbox(toComputed(hiddenField), t("Hidden field")),),
 | ||||
|         ]; | ||||
|       }), | ||||
| 
 | ||||
|       // Box config
 | ||||
|       dom.maybe(use => use(selectedColumn) ? null : use(selectedBox), (box) => [ | ||||
|         cssLabel(dom.text(box.type)), | ||||
|         dom.maybe(hasText, () => [ | ||||
|           cssRow( | ||||
|             cssTextArea( | ||||
|               box.prop('text'), | ||||
|               {onInput: true, autoGrow: true}, | ||||
|               dom.on('blur', () => box.save().catch(reportError)), | ||||
|               {placeholder: t('Enter text')}, | ||||
|             ), | ||||
|           ), | ||||
|           cssRow( | ||||
|             buttonSelect(box.prop('alignment'), [ | ||||
|               {value: 'left',   icon: 'LeftAlign'}, | ||||
|               {value: 'center', icon: 'CenterAlign'}, | ||||
|               {value: 'right',  icon: 'RightAlign'} | ||||
|             ]), | ||||
|             dom.autoDispose(box.prop('alignment').addListener(() => box.save().catch(reportError))), | ||||
|           ) | ||||
|         ]), | ||||
|       ]), | ||||
| 
 | ||||
|       // Default.
 | ||||
|       dom.maybe(u => !u(selectedColumn) && !u(selectedBox), () => [ | ||||
|         cssLabel(t('Layout')), | ||||
|       ]) | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| function disabledSection() { | ||||
| @ -1115,6 +1314,27 @@ const cssListItem = styled('li', ` | ||||
|   padding: 4px 8px; | ||||
| `);
 | ||||
| 
 | ||||
| const cssTextArea = styled(textarea, ` | ||||
|   flex: 1 0 auto; | ||||
|   color: ${theme.inputFg}; | ||||
|   background-color: ${theme.inputBg}; | ||||
|   border: 1px solid ${theme.inputBorder}; | ||||
|   border-radius: 3px; | ||||
| 
 | ||||
|   outline: none; | ||||
|   padding: 3px 7px; | ||||
|   /* Make space at least for two lines: size of line * 2 * line height + 2 * padding + border * 2 */ | ||||
|   min-height: calc(2em * 1.5 + 2 * 3px + 2px); | ||||
|   line-height: 1.5; | ||||
|   resize: none; | ||||
| 
 | ||||
|   &:disabled { | ||||
|     color: ${theme.inputDisabledFg}; | ||||
|     background-color: ${theme.inputDisabledBg}; | ||||
|     pointer-events: none; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssTextInput = styled(textInput, ` | ||||
|   flex: 1 0 auto; | ||||
|   color: ${theme.inputFg}; | ||||
|  | ||||
| @ -7,6 +7,7 @@ import {testId} from 'app/client/ui2018/cssVars'; | ||||
| import {menuDivider, menuItemCmd, menuItemLink} from 'app/client/ui2018/menus'; | ||||
| import {GristDoc} from 'app/client/components/GristDoc'; | ||||
| import {dom, UseCB} from 'grainjs'; | ||||
| import {WidgetType} from 'app/common/widgetTypes'; | ||||
| 
 | ||||
| const t = makeT('ViewLayoutMenu'); | ||||
| 
 | ||||
| @ -63,8 +64,11 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool | ||||
|            ; | ||||
|   }; | ||||
| 
 | ||||
|   const isCard = (use: UseCB) => use(viewSection.widgetType) === WidgetType.Card; | ||||
|   const isTable = (use: UseCB) => use(viewSection.widgetType) === WidgetType.Table; | ||||
| 
 | ||||
|   return [ | ||||
|     dom.maybe((use) => ['single'].includes(use(viewSection.parentKey)), () => contextMenu), | ||||
|     dom.maybe(isCard, () => contextMenu), | ||||
|     dom.maybe(showRawData, | ||||
|       () => menuItemLink( | ||||
|         { href: rawUrl}, t("Show raw data"), testId('show-raw-data'), | ||||
| @ -91,6 +95,7 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool | ||||
|       menuItemCmd(allCommands.viewTabOpen, t("Widget options"), testId('widget-options')), | ||||
|       menuItemCmd(allCommands.sortFilterTabOpen, t("Advanced Sort & Filter"), dom.hide(viewSection.isRecordCard)), | ||||
|       menuItemCmd(allCommands.dataSelectionTabOpen, t("Data selection"), dom.hide(viewSection.isRecordCard)), | ||||
|       menuItemCmd(allCommands.createForm, t("Create a form"), dom.show(isTable)), | ||||
|     ]), | ||||
| 
 | ||||
|     menuDivider(dom.hide(viewSection.isRecordCard)), | ||||
| @ -133,7 +138,7 @@ export function makeCollapsedLayoutMenu(viewSection: ViewSectionRec, gristDoc: G | ||||
|       ) | ||||
|     ), | ||||
|     menuDivider(), | ||||
|     menuItemCmd(allCommands.expandSection, t("Add to page"), | ||||
|     menuItemCmd(allCommands.restoreSection, t("Add to page"), | ||||
|       dom.cls('disabled', isReadonly), | ||||
|       testId('section-expand')), | ||||
|     menuItemCmd(allCommands.deleteCollapsedSection, t("Delete widget"), | ||||
|  | ||||
| @ -160,7 +160,7 @@ export function viewSectionMenu( | ||||
|       cssExpandIconWrapper( | ||||
|         cssSmallIcon('Grow'), | ||||
|         testId('expandSection'), | ||||
|         dom.on('click', () =>  allCommands.maximizeActiveSection.run()), | ||||
|         dom.on('click', () =>  allCommands.expandSection.run()), | ||||
|         hoverTooltip('Expand section', {key: 'expandSection'}), | ||||
|       ), | ||||
|     ) | ||||
|  | ||||
| @ -84,9 +84,20 @@ function resize(el: HTMLTextAreaElement) { | ||||
| } | ||||
| 
 | ||||
| export function autoGrow(text: Observable<string>) { | ||||
|    // If this should autogrow we need to monitor width of this element.
 | ||||
|   return (el: HTMLTextAreaElement) => { | ||||
|     let width = 0; | ||||
|     const resizeObserver = new ResizeObserver((entries) => { | ||||
|       const elem = entries[0].target as HTMLTextAreaElement; | ||||
|       if (elem.offsetWidth !== width && width) { | ||||
|         resize(elem); | ||||
|       } | ||||
|       width = elem.offsetWidth; | ||||
|     }); | ||||
|     resizeObserver.observe(el); | ||||
|     dom.onDisposeElem(el, () => resizeObserver.disconnect()); | ||||
|     el.addEventListener('input', () => resize(el)); | ||||
|     dom.autoDisposeElem(el, text.addListener(() => resize(el))); | ||||
|     dom.autoDisposeElem(el, text.addListener(() => setImmediate(() => resize(el)))); | ||||
|     setTimeout(() => resize(el), 10); | ||||
|     dom.autoDisposeElem(el, text.addListener(val => { | ||||
|       // Changes to the text are not reflected by the input event (witch is used by the autoGrow)
 | ||||
|  | ||||
| @ -1,3 +1,4 @@ | ||||
| import {autoGrow} from 'app/client/ui/forms'; | ||||
| import {theme, vars} from 'app/client/ui2018/cssVars'; | ||||
| import {dom, DomElementArg, IDomArgs, IInputOptions, Observable, styled, subscribe} from 'grainjs'; | ||||
| 
 | ||||
| @ -47,24 +48,50 @@ export function textInput(obs: Observable<string|undefined>, ...args: DomElement | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export interface ITextAreaOptions extends IInputOptions { | ||||
|   autoGrow?: boolean; | ||||
|   save?: (value: string) => void; | ||||
| } | ||||
| 
 | ||||
| export function textarea( | ||||
|   obs: Observable<string>, options: IInputOptions, ...args: IDomArgs<HTMLTextAreaElement> | ||||
|   obs: Observable<string>, options?: ITextAreaOptions|null, ...args: IDomArgs<HTMLTextAreaElement> | ||||
| ): HTMLTextAreaElement { | ||||
| 
 | ||||
|   const isValid = options.isValid; | ||||
|   const isValid = options?.isValid; | ||||
| 
 | ||||
|   function setValue(elem: HTMLTextAreaElement) { | ||||
|     obs.set(elem.value); | ||||
|     if (options?.save) { options.save(elem.value); } | ||||
|     else { obs.set(elem.value); } | ||||
|     if (isValid) { isValid.set(elem.validity.valid); } | ||||
|   } | ||||
| 
 | ||||
|   const value = options?.autoGrow ? Observable.create(null, obs.get()) : null; | ||||
|   const trackInput = Boolean(options?.onInput || options?.autoGrow); | ||||
|   const onInput = trackInput ? dom.on('input', (e, elem: HTMLTextAreaElement) => { | ||||
|     if (options?.onInput) { | ||||
|       setValue(elem); | ||||
|     } | ||||
|     if (options?.autoGrow) { | ||||
|       value?.set(elem.value); | ||||
|     } | ||||
|   }) : null; | ||||
| 
 | ||||
| 
 | ||||
|   return dom('textarea', ...args, | ||||
|     dom.prop('value', obs), | ||||
|     value ? [ | ||||
|       dom.autoDispose(value), | ||||
|       dom.autoDispose(obs.addListener(v => value.set(v))), | ||||
|     ] : null, | ||||
|     dom.prop('value', use => use(obs) ?? ''), | ||||
|     (isValid ? | ||||
|       (elem) => dom.autoDisposeElem(elem, | ||||
|         subscribe(obs, (use) => isValid.set(elem.checkValidity()))) : | ||||
|       null), | ||||
|     options.onInput ? dom.on('input', (e, elem) => setValue(elem)) : null, | ||||
|     onInput, | ||||
|     options?.autoGrow ? [ | ||||
|       autoGrow(value!), | ||||
|       dom.style('resize', 'none') | ||||
|     ] : null, | ||||
|     dom.on('change', (e, elem) => setValue(elem)), | ||||
|   ); | ||||
| } | ||||
|  | ||||
| @ -49,11 +49,13 @@ export type IconName = "ChartArea" | | ||||
|   "Chat" | | ||||
|   "Code" | | ||||
|   "Collapse" | | ||||
|   "Columns" | | ||||
|   "Convert" | | ||||
|   "Copy" | | ||||
|   "CrossBig" | | ||||
|   "CrossSmall" | | ||||
|   "Database" | | ||||
|   "Desktop" | | ||||
|   "Dots" | | ||||
|   "Download" | | ||||
|   "DragDrop" | | ||||
| @ -94,6 +96,7 @@ export type IconName = "ChartArea" | | ||||
|   "Message" | | ||||
|   "Minimize" | | ||||
|   "Minus" | | ||||
|   "Mobile" | | ||||
|   "MobileChat" | | ||||
|   "MobileChat2" | | ||||
|   "NewNotification" | | ||||
| @ -102,6 +105,7 @@ export type IconName = "ChartArea" | | ||||
|   "Page" | | ||||
|   "PanelLeft" | | ||||
|   "PanelRight" | | ||||
|   "Paragraph" | | ||||
|   "Pencil" | | ||||
|   "PinBig" | | ||||
|   "PinSmall" | | ||||
| @ -123,6 +127,8 @@ export type IconName = "ChartArea" | | ||||
|   "Robot" | | ||||
|   "Script" | | ||||
|   "Search" | | ||||
|   "Section" | | ||||
|   "Separator" | | ||||
|   "Settings" | | ||||
|   "Share" | | ||||
|   "Sort" | | ||||
| @ -198,11 +204,13 @@ export const IconList: IconName[] = ["ChartArea", | ||||
|   "Chat", | ||||
|   "Code", | ||||
|   "Collapse", | ||||
|   "Columns", | ||||
|   "Convert", | ||||
|   "Copy", | ||||
|   "CrossBig", | ||||
|   "CrossSmall", | ||||
|   "Database", | ||||
|   "Desktop", | ||||
|   "Dots", | ||||
|   "Download", | ||||
|   "DragDrop", | ||||
| @ -243,6 +251,7 @@ export const IconList: IconName[] = ["ChartArea", | ||||
|   "Message", | ||||
|   "Minimize", | ||||
|   "Minus", | ||||
|   "Mobile", | ||||
|   "MobileChat", | ||||
|   "MobileChat2", | ||||
|   "NewNotification", | ||||
| @ -251,6 +260,7 @@ export const IconList: IconName[] = ["ChartArea", | ||||
|   "Page", | ||||
|   "PanelLeft", | ||||
|   "PanelRight", | ||||
|   "Paragraph", | ||||
|   "Pencil", | ||||
|   "PinBig", | ||||
|   "PinSmall", | ||||
| @ -272,6 +282,8 @@ export const IconList: IconName[] = ["ChartArea", | ||||
|   "Robot", | ||||
|   "Script", | ||||
|   "Search", | ||||
|   "Section", | ||||
|   "Separator", | ||||
|   "Settings", | ||||
|   "Share", | ||||
|   "Sort", | ||||
|  | ||||
| @ -110,6 +110,7 @@ export const cssLabelText = styled('span', ` | ||||
|   font-weight: initial;   /* negate bootstrap */ | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   line-height: 16px; | ||||
| `);
 | ||||
| 
 | ||||
| type CheckboxArg = DomArg<HTMLInputElement>; | ||||
|  | ||||
| @ -132,7 +132,7 @@ export function menuItemSubmenu( | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Subheader as a menu item. | ||||
|  * Header with a submenu (used in collapsed menus scenarios). | ||||
|  */ | ||||
| export function menuSubHeaderMenu( | ||||
|   submenu: weasel.MenuCreateFunc, | ||||
| @ -557,7 +557,7 @@ export const menuItem = styled(weasel.menuItem, menuItemStyle); | ||||
| 
 | ||||
| export const menuItemLink = styled(weasel.menuItemLink, menuItemStyle); | ||||
| 
 | ||||
| // when element name is, to long, it will be trimmed with ellipsis ("...")
 | ||||
| // when element name is too long, it will be trimmed with ellipsis ("...")
 | ||||
| export function menuItemTrimmed( | ||||
|   action: (item: HTMLElement, ev: Event) => void, label: string, ...args: DomElementArg[]) { | ||||
|   return menuItem(action, cssEllipsisLabel(label), ...args); | ||||
| @ -584,7 +584,7 @@ export function menuItemCmd( | ||||
|     typeof label === 'string' | ||||
|           ? dom('span', label, testId('cmd-name')) | ||||
|           : dom('div', label(), testId('cmd-name')), | ||||
|     cmd.humanKeys.length ? cssCmdKey(cmd.humanKeys[0]) : null, | ||||
|     cmd.humanKeys?.length ? cssCmdKey(cmd.humanKeys[0]) : null, | ||||
|     cssMenuItemCmd.cls(''), // overrides some menu item styles
 | ||||
|     ...args | ||||
|   ); | ||||
| @ -826,3 +826,52 @@ const cssMenuSearchInput = styled('input', ` | ||||
|     color: ${theme.inputPlaceholderFg}; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| type MenuDefinition = Array<MenuItem>; | ||||
| 
 | ||||
| 
 | ||||
| interface MenuItem { | ||||
|   label?: string; | ||||
|   header?: string; | ||||
|   action?: string | (() => void); | ||||
|   disabled?: boolean; | ||||
|   icon?: IconName; | ||||
|   shortcut?: string; | ||||
|   submenu?: MenuDefinition; | ||||
|   maxSubmenu?: number; | ||||
|   type?: 'header' | 'separator' | 'item'; // default to item.
 | ||||
| } | ||||
| 
 | ||||
| export function buildMenu(definition: MenuDefinition, onclick?: (action: string) => any) { | ||||
|   function *buildMenuItems(current: MenuDefinition): IterableIterator<Element> { | ||||
|     for (const item of current) { | ||||
|       const isHeader = item.type === 'header' || item.header; | ||||
|       // If this is header with submenu.
 | ||||
|       if (isHeader && item.submenu) { | ||||
|         yield menuSubHeaderMenu(() => [...buildMenuItems(item.submenu!)], {}, item.header ?? item.label); | ||||
|         continue; | ||||
|       } else if (isHeader) { | ||||
|         yield menuSubHeader(item.header ?? item.label); | ||||
|         continue; | ||||
|       } | ||||
| 
 | ||||
|       // Not a header, so it's an item or a separator.
 | ||||
|       if (item.type === 'separator') { | ||||
|         yield menuDivider(); | ||||
|         continue; | ||||
|       } | ||||
| 
 | ||||
|       // If this is an item with submenu.
 | ||||
|       if (item.submenu) { | ||||
|         yield menuItemSubmenu(() => [...buildMenuItems(item.submenu!)], {}, item.label); | ||||
|         continue; | ||||
|       } | ||||
| 
 | ||||
|       // Not a submenu, so it's a regular item.
 | ||||
|       const action = typeof item.action === 'function' ? item.action : () => onclick?.(item.action as string); | ||||
|       yield menuItem(action, item.icon && menuIcon(item.icon), item.label, item.shortcut && cssCmdKey(item.shortcut)); | ||||
| 
 | ||||
|     } | ||||
|   } | ||||
|   return menu((ctl) => [...buildMenuItems(definition)], {}); | ||||
| } | ||||
|  | ||||
| @ -9,7 +9,8 @@ import {icon} from 'app/client/ui2018/icons'; | ||||
| import {ChoiceListEntry} from 'app/client/widgets/ChoiceListEntry'; | ||||
| import {choiceToken, DEFAULT_BACKGROUND_COLOR, DEFAULT_COLOR} from 'app/client/widgets/ChoiceToken'; | ||||
| import {NTextBox} from 'app/client/widgets/NTextBox'; | ||||
| import {Computed, dom, styled} from 'grainjs'; | ||||
| import {WidgetType} from 'app/common/widgetTypes'; | ||||
| import {Computed, dom, styled, UseCB} from 'grainjs'; | ||||
| 
 | ||||
| export type IChoiceOptions = Style | ||||
| export type ChoiceOptions = Record<string, IChoiceOptions | undefined>; | ||||
| @ -84,8 +85,14 @@ export class ChoiceTextBox extends NTextBox { | ||||
|       use => !use(disabled) | ||||
|         && (use(this.field.config.options.mixed('choices')) || use(this.field.config.options.mixed('choiceOptions'))) | ||||
|       ); | ||||
| 
 | ||||
|     // If we are on forms, we don't want to show alignment options.
 | ||||
|     const notForm = (use: UseCB) => { | ||||
|       return use(use(this.field.viewSection).parentKey) !== WidgetType.Form; | ||||
|     }; | ||||
| 
 | ||||
|     return [ | ||||
|       super.buildConfigDom(), | ||||
|       dom.maybe(notForm, () => super.buildConfigDom()), | ||||
|       cssLabel(t('CHOICES')), | ||||
|       cssRow( | ||||
|         dom.autoDispose(disabled), | ||||
|  | ||||
| @ -35,6 +35,7 @@ import moment from 'moment'; | ||||
| import maxSize from 'popper-max-size-modifier'; | ||||
| import flatMap = require('lodash/flatMap'); | ||||
| import {autoGrow} from 'app/client/ui/forms'; | ||||
| import {autoFocus} from 'app/client/lib/domUtils'; | ||||
| 
 | ||||
| const testId = makeTestId('test-discussion-'); | ||||
| const t = makeT('DiscussionEditor'); | ||||
| @ -919,9 +920,6 @@ function bindProp(text: Observable<string>) { | ||||
|   ]; | ||||
| } | ||||
| 
 | ||||
| function autoFocus() { | ||||
|   return (el: HTMLElement) => void setTimeout(() => el.focus(), 10); | ||||
| } | ||||
| 
 | ||||
| function buildPopup( | ||||
|   owner: Disposable, | ||||
|  | ||||
| @ -22,7 +22,6 @@ import { FieldSettingsMenu } from 'app/client/ui/FieldMenus'; | ||||
| import { cssBlockedCursor, cssLabel, cssRow } from 'app/client/ui/RightPanelStyles'; | ||||
| import { textButton } from 'app/client/ui2018/buttons'; | ||||
| import { buttonSelect, cssButtonSelect } from 'app/client/ui2018/buttonSelect'; | ||||
| import { theme } from 'app/client/ui2018/cssVars'; | ||||
| import { IOptionFull, menu, select } from 'app/client/ui2018/menus'; | ||||
| import { DiffBox } from 'app/client/widgets/DiffBox'; | ||||
| import { buildErrorDom } from 'app/client/widgets/ErrorDom'; | ||||
| @ -473,7 +472,7 @@ export class FieldBuilder extends Disposable { | ||||
|     // the dom created by the widgetImpl to get out of sync.
 | ||||
|     return dom('div', | ||||
|       kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) => | ||||
|         dom('div', widget.buildConfigDom(), cssSeparator()) | ||||
|         dom('div', widget.buildConfigDom()) | ||||
|       ) | ||||
|     ); | ||||
|   } | ||||
| @ -904,10 +903,6 @@ const cssTypeSelectMenu = styled('div', ` | ||||
|   max-height: 500px; | ||||
| `);
 | ||||
| 
 | ||||
| const cssSeparator = styled('div', ` | ||||
|   border-bottom: 1px solid ${theme.pagePanelsBorder}; | ||||
|   margin-top: 16px; | ||||
| `);
 | ||||
| 
 | ||||
| // Simple helper that removes transparency from a HEX or rgba color.
 | ||||
| // User can set a transparent fill color using doc actions, but we don't want to show it well
 | ||||
|  | ||||
| @ -9,8 +9,9 @@ import {icon} from 'app/client/ui2018/icons'; | ||||
| import {IOptionFull, select} from 'app/client/ui2018/menus'; | ||||
| import {NTextBox} from 'app/client/widgets/NTextBox'; | ||||
| import {isFullReferencingType, isVersions} from 'app/common/gristTypes'; | ||||
| import {WidgetType} from 'app/common/widgetTypes'; | ||||
| import {UIRowId} from 'app/plugin/GristAPI'; | ||||
| import {Computed, dom, styled} from 'grainjs'; | ||||
| import {Computed, dom, styled, UseCB} from 'grainjs'; | ||||
| 
 | ||||
| 
 | ||||
| const t = makeT('Reference'); | ||||
| @ -48,10 +49,16 @@ export class Reference extends NTextBox { | ||||
|   } | ||||
| 
 | ||||
|   public buildConfigDom() { | ||||
|     // If we are on forms, we don't want to show alignment options.
 | ||||
|     const notForm = (use: UseCB) => { | ||||
|       return use(use(this.field.viewSection).parentKey) !== WidgetType.Form; | ||||
|     }; | ||||
|     return [ | ||||
|       this.buildTransformConfigDom(), | ||||
|       cssLabel(t('CELL FORMAT')), | ||||
|       super.buildConfigDom() | ||||
|       dom.maybe(notForm, () => [ | ||||
|         cssLabel(t('CELL FORMAT')), | ||||
|         super.buildConfigDom() | ||||
|       ]) | ||||
|     ]; | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -3,6 +3,7 @@ | ||||
|   margin: -1px auto; | ||||
|   width: 30px; | ||||
|   height: 17px; | ||||
|   flex: none; | ||||
| } | ||||
| 
 | ||||
| .switch_slider { | ||||
|  | ||||
| @ -1,10 +1,24 @@ | ||||
| import {GristType} from 'app/plugin/GristData'; | ||||
| import {CellValue, GristType} from 'app/plugin/GristData'; | ||||
| import {MaybePromise} from 'app/plugin/gutil'; | ||||
| import _ from 'lodash'; | ||||
| import {marked} from 'marked'; | ||||
| 
 | ||||
| /** | ||||
|  * This file is a part of the Forms project. It contains a logic to render an HTML form from a JSON definition. | ||||
|  * TODO: Client version has its own implementation, we should merge them but it is hard to tell currently | ||||
|  * what are the similarities and differences as a Client code should also support browsing. | ||||
|  */ | ||||
| 
 | ||||
| /** | ||||
|  * All allowed boxes. | ||||
|  */ | ||||
| export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' | 'Placeholder' | 'Layout' | 'Field'; | ||||
| export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' | 'Placeholder' | 'Layout' | 'Field' | | ||||
|  'Label'; | ||||
| 
 | ||||
| /** | ||||
|  * Number of fields to show in the form by default. | ||||
|  */ | ||||
| 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 | ||||
| @ -13,21 +27,37 @@ export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' | 'Placehol | ||||
| export interface Box extends Record<string, any> { | ||||
|   type: BoxType, | ||||
|   children?: Array<Box>, | ||||
| 
 | ||||
|   // Some properties used by some boxes (like form itself)
 | ||||
|   submitText?: string, | ||||
|   successURL?: string, | ||||
|   successText?: string, | ||||
|   anotherResponse?: boolean, | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * When a form is rendered, it is given a context that can be used to access Grist data and sanitize HTML. | ||||
|  */ | ||||
| export interface RenderContext { | ||||
|   root: Box; | ||||
|   field(id: number): FieldModel; | ||||
| } | ||||
| 
 | ||||
| export interface FieldOptions { | ||||
|   formRequired?: boolean; | ||||
|   choices?: string[]; | ||||
| } | ||||
| 
 | ||||
| export interface FieldModel { | ||||
|   /** | ||||
|    * The question to ask. Fallbacks to column's label than column's id. | ||||
|    */ | ||||
|   question: string; | ||||
|   description: string; | ||||
|   colId: string; | ||||
|   type: string; | ||||
|   options: Record<string, any>; | ||||
|   options: FieldOptions; | ||||
|   values(): MaybePromise<[number, CellValue][]>; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
| @ -36,9 +66,7 @@ export interface FieldModel { | ||||
|  */ | ||||
| export class RenderBox { | ||||
|   public static new(box: Box, ctx: RenderContext): RenderBox { | ||||
|     console.assert(box, `Box is not defined`); | ||||
|     const ctr = elements[box.type]; | ||||
|     console.assert(ctr, `Box ${box.type} is not defined`); | ||||
|     const ctr = elements[box.type] ?? Paragraph; | ||||
|     return new ctr(box, ctx); | ||||
|   } | ||||
| 
 | ||||
| @ -46,48 +74,69 @@ export class RenderBox { | ||||
| 
 | ||||
|   } | ||||
| 
 | ||||
|   public toHTML(): string { | ||||
|     return (this.box.children || []).map((child) => RenderBox.new(child, this.ctx).toHTML()).join(''); | ||||
|   public async toHTML(): Promise<string> { | ||||
|     const proms = (this.box.children || []).map((child) => RenderBox.new(child, this.ctx).toHTML()); | ||||
|     const parts = await Promise.all(proms); | ||||
|     return parts.join(''); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class Label extends RenderBox { | ||||
|   public override async toHTML() { | ||||
|     const text = this.box['text']; | ||||
|     const cssClass = this.box['cssClass'] || ''; | ||||
|     return ` | ||||
|       <div class="grist-label ${cssClass}">${text || ''}</div> | ||||
|     `;
 | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class Paragraph extends RenderBox { | ||||
|   public override toHTML(): string { | ||||
|   public override async toHTML() { | ||||
|     const text = this.box['text'] || '**Lorem** _ipsum_ dolor'; | ||||
|     const alignment = this.box['alignment'] || 'left'; | ||||
|     const html = marked(text); | ||||
|     return ` | ||||
|       <div class="grist-paragraph">${html}</div> | ||||
|       <div class="grist-paragraph grist-text-${alignment}">${html}</div> | ||||
|     `;
 | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class Section extends RenderBox { | ||||
|   /** Nothing, default is enough */ | ||||
|   public override async toHTML() { | ||||
|     return ` | ||||
|       <div class="grist-section"> | ||||
|         ${await super.toHTML()} | ||||
|       </div> | ||||
|     `;
 | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class Columns extends RenderBox { | ||||
|   public override toHTML(): string { | ||||
|     const kids = this.box.children || []; | ||||
|   public override async toHTML() { | ||||
|     const size = this.box.children?.length || 1; | ||||
|     const content = await super.toHTML(); | ||||
|     return ` | ||||
|       <div class="grist-columns" style='--grist-columns-count: ${kids.length}'> | ||||
|         ${kids.map((child) => child.toHTML()).join('\n')} | ||||
|       <div class="grist-columns" style='--grist-columns-count: ${size}'> | ||||
|         ${content} | ||||
|       </div> | ||||
|     `;
 | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class Submit extends RenderBox { | ||||
|   public override toHTML() { | ||||
|   public override async toHTML() { | ||||
|     const text = _.escape(this.ctx.root['submitText'] || 'Submit'); | ||||
|     return ` | ||||
|       <div> | ||||
|         <input type='submit' value='Submit' /> | ||||
|       <div class='grist-submit'> | ||||
|         <input type='submit' value='${text}' /> | ||||
|       </div> | ||||
|     `;
 | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class Placeholder extends RenderBox { | ||||
|   public override toHTML() { | ||||
|   public override async toHTML() { | ||||
|     return ` | ||||
|       <div> | ||||
|       </div> | ||||
| @ -105,93 +154,131 @@ class Layout extends RenderBox { | ||||
|  */ | ||||
| class Field extends RenderBox { | ||||
| 
 | ||||
|   public static render(field: FieldModel, context: RenderContext): string { | ||||
|   public build(field: FieldModel, context: RenderContext) { | ||||
|     const ctr = (questions as any)[field.type as any] as { new(): Question } || Text; | ||||
|     return new ctr().toHTML(field, context); | ||||
|     return new ctr(); | ||||
|   } | ||||
| 
 | ||||
|   public toHTML(): string { | ||||
|   public async toHTML() { | ||||
|     const field = this.ctx.field(this.box['leaf']); | ||||
|     if (!field) { | ||||
|       return `<div class="grist-field">Field not found</div>`; | ||||
|     } | ||||
|     const label = field.question ? field.question : field.colId; | ||||
|     const name = field.colId; | ||||
|     let description = field.description || ''; | ||||
|     if (description) { | ||||
|       description = `<div class='grist-field-description'>${description}</div>`; | ||||
|     } | ||||
|     const html = `<div class='grist-field-content'>${Field.render(field, this.ctx)}</div>`; | ||||
|     const renderer = this.build(field, this.ctx); | ||||
|     return ` | ||||
|       <div class="grist-field"> | ||||
|         <label for='${name}'>${label}</label> | ||||
|         ${html} | ||||
|         ${description} | ||||
|         ${await renderer.toHTML(field, this.ctx)} | ||||
|       </div> | ||||
|     `;
 | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| interface Question { | ||||
|   toHTML(field: FieldModel, context: RenderContext): string; | ||||
|   toHTML(field: FieldModel, context: RenderContext): Promise<string>|string; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| class Text implements Question { | ||||
|   public toHTML(field: FieldModel, context: RenderContext): string { | ||||
| abstract class BaseQuestion implements Question { | ||||
|   public async toHTML(field: FieldModel, context: RenderContext): Promise<string> { | ||||
|     return ` | ||||
|       <input type='text' name='${field.colId}' /> | ||||
|       <div class='grist-question'> | ||||
|         ${this.label(field)} | ||||
|         <div class='grist-field-content'> | ||||
|           ${await this.input(field, context)} | ||||
|         </div> | ||||
|       </div> | ||||
|     `;
 | ||||
|   } | ||||
| 
 | ||||
|   public label(field: FieldModel): string { | ||||
|     // This might be HTML.
 | ||||
|     const label = field.question; | ||||
|     const name = field.colId; | ||||
|     return ` | ||||
|       <label class='grist-label' for='${name}'>${label}</label> | ||||
|     `;
 | ||||
|   } | ||||
| 
 | ||||
|   public abstract input(field: FieldModel, context: RenderContext): string|Promise<string>; | ||||
| } | ||||
| 
 | ||||
| class Text extends BaseQuestion { | ||||
|   public input(field: FieldModel, context: RenderContext): string { | ||||
|     const required = field.options.formRequired ? 'required' : ''; | ||||
|     return ` | ||||
|       <input type='text' name='${field.colId}' ${required}/> | ||||
|     `;
 | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class Date implements Question { | ||||
|   public toHTML(field: FieldModel, context: RenderContext): string { | ||||
| class Date extends BaseQuestion  { | ||||
|   public input(field: FieldModel, context: RenderContext): string { | ||||
|     const required = field.options.formRequired ? 'required' : ''; | ||||
|     return ` | ||||
|       <input type='date' name='${field.colId}' /> | ||||
|       <input type='date' name='${field.colId}' ${required}/> | ||||
|     `;
 | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class DateTime implements Question { | ||||
|   public toHTML(field: FieldModel, context: RenderContext): string { | ||||
| class DateTime extends BaseQuestion { | ||||
|   public input(field: FieldModel, context: RenderContext): string { | ||||
|     const required = field.options.formRequired ? 'required' : ''; | ||||
|     return ` | ||||
|       <input type='datetime-local' name='${field.colId}' /> | ||||
|       <input type='datetime-local' name='${field.colId}' ${required}/> | ||||
|     `;
 | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class Choice implements Question { | ||||
|   public toHTML(field: FieldModel, context: RenderContext): string { | ||||
| class Choice extends BaseQuestion  { | ||||
|   public input(field: FieldModel, context: RenderContext): string { | ||||
|     const required = field.options.formRequired ? 'required' : ''; | ||||
|     const choices: string[] = field.options.choices || []; | ||||
|     return ` | ||||
|       <select name='${field.colId}'> | ||||
|       <select name='${field.colId}' ${required} > | ||||
|         ${choices.map((choice) => `<option value='${choice}'>${choice}</option>`).join('')} | ||||
|       </select> | ||||
|     `;
 | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class Bool implements Question { | ||||
|   public toHTML(field: FieldModel, context: RenderContext): string { | ||||
| class Bool extends BaseQuestion { | ||||
|   public async toHTML(field: FieldModel, context: RenderContext) { | ||||
|     return ` | ||||
|       <label> | ||||
|         <input type='checkbox' name='${field.colId}' value="1" /> | ||||
|         Yes | ||||
|       <div class='grist-question'> | ||||
|         <div class='grist-field-content'> | ||||
|         ${this.input(field, context)} | ||||
|         </div> | ||||
|       </div> | ||||
|     `;
 | ||||
|   } | ||||
| 
 | ||||
|   public input(field: FieldModel, context: RenderContext): string { | ||||
|     const required = field.options.formRequired ? 'required' : ''; | ||||
|     const label = field.question ? field.question : field.colId; | ||||
|     return ` | ||||
|       <label class='grist-switch'> | ||||
|         <input type='checkbox' name='${field.colId}' value="1" ${required}  /> | ||||
|         <div class="grist-widget_switch grist-switch_transition"> | ||||
|           <div class="grist-switch_slider"></div> | ||||
|           <div class="grist-switch_circle"></div> | ||||
|         </div> | ||||
|         <span>${label}</span> | ||||
|       </label> | ||||
|     `;
 | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class ChoiceList implements Question { | ||||
|   public toHTML(field: FieldModel, context: RenderContext): string { | ||||
| class ChoiceList extends BaseQuestion  { | ||||
|   public input(field: FieldModel, context: RenderContext): string { | ||||
|     const required = field.options.formRequired ? 'required' : ''; | ||||
|     const choices: string[] = field.options.choices || []; | ||||
|     return ` | ||||
|       <div name='${field.colId}' class='grist-choice-list'> | ||||
|       <div name='${field.colId}' class='grist-choice-list ${required}'> | ||||
|         ${choices.map((choice) => ` | ||||
|           <label> | ||||
|             <input type='checkbox' name='${field.colId}[]' value='${choice}' /> | ||||
|             ${choice} | ||||
|             <span> | ||||
|               ${choice} | ||||
|             </span> | ||||
|           </label> | ||||
|         `).join('')}
 | ||||
|       </div> | ||||
| @ -199,6 +286,44 @@ class ChoiceList implements Question { | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class RefList extends BaseQuestion { | ||||
|   public async input(field: FieldModel, context: RenderContext) { | ||||
|     const choices: [number, CellValue][] = (await field.values()) ?? []; | ||||
|     // Sort by the second value, which is the display value.
 | ||||
|     choices.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); | ||||
|     // Support for 20 choices, TODO: make it dynamic.
 | ||||
|     choices.splice(20); | ||||
|     return ` | ||||
|       <div name='${field.colId}' class='grist-ref-list'> | ||||
|         ${choices.map((choice) => ` | ||||
|           <label class='grist-checkbox'> | ||||
|             <input type='checkbox' name='${field.colId}[]' value='${String(choice[0])}' /> | ||||
|             <span> | ||||
|               ${String(choice[1] ?? '')} | ||||
|             </span> | ||||
|           </label> | ||||
|         `).join('')}
 | ||||
|       </div> | ||||
|     `;
 | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class Ref extends BaseQuestion { | ||||
|   public async input(field: FieldModel) { | ||||
|     const choices: [number, CellValue][] = (await field.values()) ?? []; | ||||
|     // Sort by the second value, which is the display value.
 | ||||
|     choices.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); | ||||
|     // Support for 1000 choices, TODO: make it dynamic.
 | ||||
|     choices.splice(1000); | ||||
|     // <option type='number' is not standard, we parse it ourselves.
 | ||||
|     return ` | ||||
|       <select name='${field.colId}' class='grist-ref' data-grist-type='${field.type}'> | ||||
|         ${choices.map((choice) => `<option value='${String(choice[0])}'>${String(choice[1] ?? '')}</option>`).join('')} | ||||
|       </select> | ||||
|     `;
 | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * List of all available questions we will render of the form. | ||||
|  * TODO: add other renderers. | ||||
| @ -210,6 +335,8 @@ const questions: Partial<Record<GristType, new () => Question>> = { | ||||
|   'ChoiceList': ChoiceList, | ||||
|   'Date': Date, | ||||
|   'DateTime': DateTime, | ||||
|   'Ref': Ref, | ||||
|   'RefList': RefList, | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
| @ -223,4 +350,5 @@ const elements = { | ||||
|   'Placeholder': Placeholder, | ||||
|   'Layout': Layout, | ||||
|   'Field': Field, | ||||
|   'Label': Label, | ||||
| }; | ||||
|  | ||||
| @ -982,7 +982,7 @@ export function useBindable<T>(use: UseCBOwner, obs: BindableValue<T>): T { | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Use helper for simple boolean negation. | ||||
|  * Useful helper for simple boolean negation. | ||||
|  */ | ||||
| export const not = (obs: Observable<any>|IKnockoutReadObservable<any>) => (use: UseCBOwner) => !use(obs); | ||||
| 
 | ||||
|  | ||||
| @ -9,3 +9,12 @@ export type IAttachedCustomWidget = typeof AttachedCustomWidgets.type; | ||||
| 
 | ||||
| // all widget types
 | ||||
| export type IWidgetType = 'record' | 'detail' | 'single' | 'chart' | 'custom' | 'form' | IAttachedCustomWidget; | ||||
| export enum WidgetType { | ||||
|   Table = 'record', | ||||
|   Card = 'single', | ||||
|   CardList = 'detail', | ||||
|   Chart = 'chart', | ||||
|   Custom = 'custom', | ||||
|   Form = 'form', | ||||
|   Calendar = 'custom.calendar', | ||||
| } | ||||
|  | ||||
| @ -7,3 +7,5 @@ import times = require('lodash/times'); | ||||
| export function arrayRepeat<T>(count: number, value: T): T[] { | ||||
|   return times(count, constant(value)); | ||||
| } | ||||
| 
 | ||||
| export type MaybePromise<T> = T | Promise<T>; | ||||
|  | ||||
| @ -12,8 +12,8 @@ import { | ||||
|   UserAction | ||||
| } from 'app/common/DocActions'; | ||||
| import {DocData} from 'app/common/DocData'; | ||||
| import {isRaisedException} from "app/common/gristTypes"; | ||||
| import {Box, RenderBox, RenderContext} from "app/common/Forms"; | ||||
| import {extractTypeFromColType, isRaisedException} from "app/common/gristTypes"; | ||||
| import {Box, BoxType, FieldModel, INITIAL_FIELDS_COUNT, RenderBox, RenderContext} from "app/common/Forms"; | ||||
| import {buildUrlId, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls"; | ||||
| import {isAffirmative, safeJsonParse, timeoutReached} from "app/common/gutil"; | ||||
| import {SchemaTypes} from "app/common/schema"; | ||||
| @ -86,11 +86,11 @@ import {fetchDoc, globalUploadSet, handleOptionalUpload, handleUpload, | ||||
| import * as assert from 'assert'; | ||||
| import contentDisposition from 'content-disposition'; | ||||
| import {Application, NextFunction, Request, RequestHandler, Response} from "express"; | ||||
| import jsesc from 'jsesc'; | ||||
| import * as fse from 'fs-extra'; | ||||
| import * as handlebars from 'handlebars'; | ||||
| import * as _ from "lodash"; | ||||
| import LRUCache from 'lru-cache'; | ||||
| import * as moment from 'moment'; | ||||
| import * as fse from 'fs-extra'; | ||||
| import fetch from 'node-fetch'; | ||||
| import * as path from 'path'; | ||||
| import * as t from "ts-interface-checker"; | ||||
| @ -159,6 +159,17 @@ function validateCore(checker: Checker, req: Request, body: any) { | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Helper used in forms rendering for purifying html. | ||||
|  */ | ||||
| handlebars.registerHelper('dompurify', (html: string) => { | ||||
|   return new handlebars.SafeString(` | ||||
|     <script data-html="${handlebars.escapeExpression(html)}"> | ||||
|       document.write(DOMPurify.sanitize(document.currentScript.getAttribute('data-html'))); | ||||
|     </script> | ||||
|   `);
 | ||||
| }); | ||||
| 
 | ||||
| export class DocWorkerApi { | ||||
|   // Map from docId to number of requests currently being handled for that doc
 | ||||
|   private _currentUsage = new Map<string, number>(); | ||||
| @ -1398,90 +1409,129 @@ export class DocWorkerApi { | ||||
|             sectionId, | ||||
|           }); | ||||
|         } | ||||
| 
 | ||||
|         // Get the viewSection record for the specified id.
 | ||||
|         const records = asRecords(await readTable( | ||||
|           req, activeDoc, '_grist_Views_section', { id: [sectionId] }, {} | ||||
|         )); | ||||
|         const section = records.find(r => r.id === sectionId); | ||||
|         const Views_section = activeDoc.docData!.getMetaTable('_grist_Views_section'); | ||||
|         const section = Views_section.getRecord(sectionId); | ||||
|         if (!section) { | ||||
|           throw new ApiError('Form not found', 404); | ||||
|         } | ||||
| 
 | ||||
|         // Prepare the context that will be needed for rendering this form.
 | ||||
|         const fields = asRecords(await readTable( | ||||
|           req, activeDoc, '_grist_Views_section_field', { parentId: [sectionId] }, {  } | ||||
|         )); | ||||
|         const cols = asRecords(await readTable( | ||||
|           req, activeDoc, '_grist_Tables_column', { parentId: [section.fields.tableRef] }, {  } | ||||
|         )); | ||||
|         const Tables = activeDoc.docData!.getMetaTable('_grist_Tables'); | ||||
|         const Views_section_field = activeDoc.docData!.getMetaTable('_grist_Views_section_field'); | ||||
|         const fields = Views_section_field.filterRecords({parentId: sectionId}); | ||||
|         const Tables_column = activeDoc.docData!.getMetaTable('_grist_Tables_column'); | ||||
| 
 | ||||
|         // Read the box specs
 | ||||
|         const spec = section.fields.layoutSpec; | ||||
|         const spec = section.layoutSpec; | ||||
|         let box: Box = safeJsonParse(spec ? String(spec) : '', null); | ||||
|         if (!box) { | ||||
|           const editable = fields.filter(f => { | ||||
|             const col = cols.find(c => c.id === f.fields.colRef); | ||||
|             const col = Tables_column.getRecord(f.colRef); | ||||
|             // Can't do attachments and formulas.
 | ||||
|             return col && !(col.fields.isFormula && col.fields.formula) && col.fields.type !== 'Attachment'; | ||||
|             return col && !(col.isFormula && col.formula) && col.type !== 'Attachment'; | ||||
|           }); | ||||
|           box = { | ||||
|             type: 'Layout', | ||||
|             children: editable.map(f => ({ | ||||
|               type: 'Field', | ||||
|               leaf: f.id | ||||
|             })) | ||||
|             children: [ | ||||
|               {type: 'Label'}, | ||||
|               {type: 'Label'}, | ||||
|               { | ||||
|                 type: 'Section', | ||||
|                 children: [ | ||||
|                   {type: 'Label'}, | ||||
|                   {type: 'Label'}, | ||||
|                   ...editable.slice(0, INITIAL_FIELDS_COUNT).map(f => ({ | ||||
|                     type: 'Field' as BoxType, | ||||
|                     leaf: f.id | ||||
|                   })) | ||||
|                 ] | ||||
|               } | ||||
|             ], | ||||
|           }; | ||||
|           box.children!.push({ | ||||
|             type: 'Submit' | ||||
|           }); | ||||
|         } | ||||
| 
 | ||||
|         // Cache the table reads based on tableId. We are caching only the promise, not the result,
 | ||||
|         const table = _.memoize( | ||||
|           (tableId: string) => readTable(req, activeDoc, tableId, {  }, {  }).then(r => asRecords(r)) | ||||
|         ); | ||||
| 
 | ||||
|         const readValues = async (tId: string, colId: string) => { | ||||
|           const records = await table(tId); | ||||
|           return records.map(r => [r.id as number, r.fields[colId]]); | ||||
|         }; | ||||
| 
 | ||||
|         const refValues = (col: MetaRowRecord<'_grist_Tables_column'>) => { | ||||
|           return async () => { | ||||
|             const refId = col.visibleCol; | ||||
|             if (!refId) { return [] as any; } | ||||
|             const refCol = Tables_column.getRecord(refId); | ||||
|             if (!refCol) { return []; } | ||||
|             const refTable = Tables.getRecord(refCol.parentId); | ||||
|             if (!refTable) { return []; } | ||||
|             const refTableId = refTable.tableId as string; | ||||
|             const refColId = refCol.colId as string; | ||||
|             if (!refTableId || !refColId) { return () => []; } | ||||
|             if (typeof refTableId !== 'string' || typeof refColId !== 'string') { return []; } | ||||
|             return await readValues(refTableId, refColId); | ||||
|           }; | ||||
|         }; | ||||
| 
 | ||||
|         const context: RenderContext = { | ||||
|           field(fieldRef: number) { | ||||
|             const field = fields.find(f => f.id === fieldRef); | ||||
|           field(fieldRef: number): FieldModel { | ||||
|             const field = Views_section_field.getRecord(fieldRef); | ||||
|             if (!field) { throw new Error(`Field ${fieldRef} not found`); } | ||||
|             const col = cols.find(c => c.id === field.fields.colRef); | ||||
|             if (!col) { throw new Error(`Column ${field.fields.colRef} not found`); } | ||||
|             const fieldOptions = safeJsonParse(field.fields.widgetOptions as string, {}); | ||||
|             const colOptions = safeJsonParse(col.fields.widgetOptions as string, {}); | ||||
|             const col = Tables_column.getRecord(field.colRef); | ||||
|             if (!col) { throw new Error(`Column ${field.colRef} not found`); } | ||||
|             const fieldOptions = safeJsonParse(field.widgetOptions as string, {}); | ||||
|             const colOptions = safeJsonParse(col.widgetOptions as string, {}); | ||||
|             const options = {...colOptions, ...fieldOptions}; | ||||
|             const type = extractTypeFromColType(col.type as string); | ||||
|             const colId = col.colId as string; | ||||
| 
 | ||||
|             return { | ||||
|               colId: col.fields.colId as string, | ||||
|               description: options.description, | ||||
|               question: options.question, | ||||
|               type: (col.fields.type as string).split(':')[0], | ||||
|               colId, | ||||
|               description: fieldOptions.description || col.description, | ||||
|               question: options.question || col.label || colId, | ||||
|               options, | ||||
|               type, | ||||
|               // If this is reference field, we will need to fetch the referenced table.
 | ||||
|               values: refValues(col) | ||||
|             }; | ||||
|           } | ||||
|           }, | ||||
|           root: box | ||||
|         }; | ||||
| 
 | ||||
|         // Now render the box to HTML.
 | ||||
|         const html = RenderBox.new(box, context).toHTML(); | ||||
| 
 | ||||
|         // The html will be inserted into a form as a replacement for:
 | ||||
|         //   document.write(sanitize(`<!-- INSERT CONTENT -->`))
 | ||||
|         // We need to properly escape `
 | ||||
|         const escaped = jsesc(html, {isScriptContext: true, quotes: 'backtick'}); | ||||
|         let redirectUrl = !box.successURL ? '' : box.successURL; | ||||
|         // Make sure it is a valid URL.
 | ||||
|         try { | ||||
|           new URL(redirectUrl); | ||||
|         } catch (e) { | ||||
|           redirectUrl = ''; | ||||
|         } | ||||
| 
 | ||||
|         const html = await RenderBox.new(box, context).toHTML(); | ||||
|         // And wrap it with the form template.
 | ||||
|         const form = await fse.readFile(path.join(getAppPathTo(this._staticPath, 'static'), | ||||
|                                               'forms/form.html'), 'utf8'); | ||||
|         // TODO: externalize css. Currently the redirect mechanism depends on the relative base URL, so
 | ||||
|         // we can't change it at this moment. But once custom success page will be implemented this should
 | ||||
|         // be possible.
 | ||||
| 
 | ||||
|         const staticOrigin = process.env.APP_STATIC_URL || ""; | ||||
|         const staticBaseUrl = `${staticOrigin}/v/${this._grist.getTag()}/`; | ||||
|         // Fill out the blanks and send the result.
 | ||||
|         const doc = await this._dbManager.getDoc(req); | ||||
|         const docUrl = await this._grist.getResourceUrl(doc, 'html'); | ||||
|         const tableId = await getRealTableId(String(section.fields.tableRef), {activeDoc, req}); | ||||
|         res.status(200).send(form | ||||
|           .replace('<!-- INSERT CONTENT -->', escaped || '') | ||||
|           .replace("<!-- INSERT BASE -->", `<base href="${staticBaseUrl}">`) | ||||
|           .replace('<!-- INSERT DOC URL -->', docUrl) | ||||
|           .replace('<!-- INSERT TABLE ID -->', tableId) | ||||
|         ); | ||||
|         const tableId = await getRealTableId(String(section.tableRef), {activeDoc, req}); | ||||
| 
 | ||||
|         const template = handlebars.compile(form); | ||||
|         const renderedHtml = template({ | ||||
|           // Trusted content generated by us.
 | ||||
|           BASE: staticBaseUrl, | ||||
|           DOC_URL: await this._grist.getResourceUrl(doc, 'html'), | ||||
|           TABLE_ID: tableId, | ||||
|           ANOTHER_RESPONSE: Boolean(box.anotherResponse), | ||||
|           // Not trusted content entered by user.
 | ||||
|           CONTENT: html, | ||||
|           SUCCESS_TEXT: box.successText || `Thank you! Your response has been recorded.`, | ||||
|           SUCCESS_URL: redirectUrl, | ||||
|         }); | ||||
|         res.status(200).send(renderedHtml); | ||||
|       }) | ||||
|     ); | ||||
|   } | ||||
| @ -1501,7 +1551,7 @@ export class DocWorkerApi { | ||||
| 
 | ||||
|     // Check that the request is for a valid section in the document.
 | ||||
|     const sections = docData.getMetaTable('_grist_Views_section'); | ||||
|     const section = sections.getRecords().find(s => s.id === sectionId); | ||||
|     const section = sections.getRecord(sectionId); | ||||
|     if (!section) { | ||||
|       throw new ApiError('Form not found', 404); | ||||
|     } | ||||
|  | ||||
| @ -2116,9 +2116,11 @@ class UserActions(object): | ||||
|       title = '' | ||||
|     section = self._docmodel.add(view_sections, tableRef=tableRef, parentKey=section_type, | ||||
|                                  title=title, borderWidth=1, defaultWidth=100)[0] | ||||
|     # TODO: We should address the automatic selection of fields for charts in a better way. | ||||
|     # TODO: We should address the automatic selection of fields for charts | ||||
|     # and forms in a better way. | ||||
|     limit = 2 if section_type == 'chart' else 9 if section_type == 'form' else None | ||||
|     self._RebuildViewFields(tableId, section.id, | ||||
|                             limit=(2 if section_type == 'chart' else None)) | ||||
|                             limit=limit) | ||||
|     return section | ||||
| 
 | ||||
|   @useraction | ||||
|  | ||||
							
								
								
									
										434
									
								
								static/forms/form.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,434 @@ | ||||
| html, | ||||
| body { | ||||
|   padding: 0px; | ||||
|   margin: 0px; | ||||
|   background-color: #f7f7f7; | ||||
|   line-height: 1.42857143; | ||||
| } | ||||
| 
 | ||||
| * { | ||||
|   box-sizing: border-box; | ||||
| } | ||||
| 
 | ||||
| .grist-form-container { | ||||
|   --icon-Tick: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjYxODMwNjksNC42NzcwMjg0NyBDMTEuNzk2Njc4OSw0LjQ2NjIyNTE3IDEyLjExMjE2NzgsNC40Mzk5MzQ0MyAxMi4zMjI5NzExLDQuNjE4MzA2NDUgQzEyLjUzMzc3NDQsNC43OTY2Nzg0OCAxMi41NjAwNjUyLDUuMTEyMTY3NDEgMTIuMzgxNjkzMSw1LjMyMjk3MDcxIEw2LjUzMDY4ODI3LDEyLjIzNzc5NDYgTDMuNjQ2NDQ2NjEsOS4zNTM1NTI5OCBDMy40NTExODQ0Niw5LjE1ODI5MDg0IDMuNDUxMTg0NDYsOC44NDE3MDgzNSAzLjY0NjQ0NjYxLDguNjQ2NDQ2MiBDMy44NDE3MDg3Niw4LjQ1MTE4NDA2IDQuMTU4MjkxMjQsOC40NTExODQwNiA0LjM1MzU1MzM5LDguNjQ2NDQ2MiBMNi40NjkzMTE3MywxMC43NjIyMDQ1IEwxMS42MTgzMDY5LDQuNjc3MDI4NDcgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+); | ||||
|   --icon-Minus: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB4PSIyIiB5PSI3LjUiIHdpZHRoPSIxMiIgaGVpZ2h0PSIxIiByeD0iLjUiLz48L3N2Zz4=); | ||||
|   --primary: #16b378; | ||||
|   --primary-dark: #009058; | ||||
|   --dark-gray: #D9D9D9; | ||||
|   --light-gray: #bfbfbf; | ||||
|   --light: white; | ||||
| 
 | ||||
|   color: #262633; | ||||
|   background-color: #f7f7f7; | ||||
|   min-height: 100%; | ||||
|   width: 100%; | ||||
|   padding-top: 52px; | ||||
|   font-size: 15px; | ||||
|   font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Liberation Sans", Helvetica, Arial, sans-serif, | ||||
|     "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| .grist-form-container .grist-form-confirm { | ||||
|   text-align: center; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   align-items: center; | ||||
|   gap: 16px; | ||||
| } | ||||
| 
 | ||||
| .grist-form { | ||||
|   margin: 0px auto; | ||||
|   background-color: white; | ||||
|   border: 1px solid #E8E8E8; | ||||
|   width: 600px; | ||||
|   border-radius: 8px; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   max-width: calc(100% - 32px); | ||||
|   margin-bottom: 16px; | ||||
|   padding-top: 20px; | ||||
|   --grist-form-padding: 48px; | ||||
|   padding-left: var(--grist-form-padding); | ||||
|   padding-right: var(--grist-form-padding); | ||||
| } | ||||
| 
 | ||||
| @media screen and (max-width: 600px) { | ||||
|   .grist-form-container { | ||||
|     padding-top: 20px; | ||||
|   } | ||||
| 
 | ||||
|   .grist-form { | ||||
|     --grist-form-padding: 20px; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .grist-form > div + div { | ||||
|   margin-top: 16px; | ||||
| } | ||||
| 
 | ||||
| .grist-form .grist-section { | ||||
|   border-radius: 3px; | ||||
|   border: 1px solid #D9D9D9; | ||||
|   padding: 16px 24px; | ||||
|   padding: 24px; | ||||
|   margin-top: 24px; | ||||
| } | ||||
| 
 | ||||
| .grist-form .grist-section > div + div { | ||||
|   margin-top: 16px; | ||||
| } | ||||
| 
 | ||||
| .grist-form input[type="text"], | ||||
| .grist-form input[type="date"], | ||||
| .grist-form input[type="datetime-local"], | ||||
| .grist-form input[type="number"] { | ||||
|   padding: 4px 8px; | ||||
|   border: 1px solid #D9D9D9; | ||||
|   border-radius: 3px; | ||||
|   outline: none; | ||||
| } | ||||
| 
 | ||||
| .grist-form .grist-field { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
| } | ||||
| 
 | ||||
| .grist-form .grist-field .grist-field-description { | ||||
|   color: #222; | ||||
|   font-size: 12px; | ||||
|   font-weight: 400; | ||||
|   margin-top: 4px; | ||||
|   white-space: pre-wrap; | ||||
|   font-style: italic; | ||||
|   font-weight: 400; | ||||
|   line-height: 1.6; | ||||
| } | ||||
| 
 | ||||
| .grist-form .grist-field input[type="text"] { | ||||
|   padding: 4px 8px; | ||||
|   border-radius: 3px; | ||||
|   border: 1px solid #D9D9D9; | ||||
|   font-size: 13px; | ||||
|   outline-color: #16b378; | ||||
|   outline-width: 1px; | ||||
|   line-height: inherit; | ||||
|   width: 100%; | ||||
| } | ||||
| 
 | ||||
| .grist-form .grist-submit,  .grist-form-container button { | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
|   align-items: center; | ||||
| } | ||||
| 
 | ||||
| .grist-form input[type="submit"], .grist-form-container button { | ||||
|   background-color: #16b378; | ||||
|   border: 1px solid #16b378; | ||||
|   color: white; | ||||
|   padding: 10px 24px; | ||||
|   border-radius: 4px; | ||||
|   font-size: 13px; | ||||
|   cursor: pointer; | ||||
|   line-height: inherit; | ||||
| } | ||||
| 
 | ||||
| .grist-form input[type="datetime-local"] { | ||||
|   width: 100%; | ||||
|   line-height: inherit; | ||||
| } | ||||
| 
 | ||||
| .grist-form input[type="date"] { | ||||
|   width: 100%; | ||||
|   line-height: inherit; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| .grist-form input[type="checkbox"] { | ||||
|   margin: 0px; | ||||
| } | ||||
| 
 | ||||
| .grist-form .grist-columns { | ||||
|   display: grid; | ||||
|   grid-template-columns: repeat(var(--grist-columns-count), 1fr); | ||||
|   gap: 4px; | ||||
| } | ||||
| 
 | ||||
| .grist-form select { | ||||
|   padding: 4px 8px; | ||||
|   border-radius: 3px; | ||||
|   border: 1px solid #D9D9D9; | ||||
|   font-size: 13px; | ||||
|   outline-color: #16b378; | ||||
|   outline-width: 1px; | ||||
|   background: white; | ||||
|   line-height: inherit; | ||||
|   flex: auto; | ||||
|   width: 100%; | ||||
| } | ||||
| 
 | ||||
| .grist-form .grist-choice-list { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   gap: 4px; | ||||
| } | ||||
| 
 | ||||
| .grist-form .grist-checkbox { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 4px; | ||||
|   --color: var(--dark-gray); | ||||
| } | ||||
| .grist-form .grist-checkbox:hover { | ||||
|   --color: var(--light-gray); | ||||
| } | ||||
| 
 | ||||
| .grist-form input[type="checkbox"] { | ||||
|   -webkit-appearance: none; | ||||
|   -moz-appearance: none; | ||||
|   padding: 0; | ||||
|   flex-shrink: 0; | ||||
|   display: inline-block; | ||||
|   width: 16px; | ||||
|   height: 16px; | ||||
|   outline: none !important; | ||||
|   --radius: 3px; | ||||
|   position: relative; | ||||
|   margin: 0; | ||||
|   margin-right: 4px; | ||||
|   vertical-align: baseline; | ||||
| } | ||||
| 
 | ||||
| .grist-form input[type="checkbox"]:checked:enabled, .grist-form input[type="checkbox"]:indeterminate:enabled { | ||||
|   --color: var(--primary); | ||||
| } | ||||
| 
 | ||||
| .grist-form input[type="checkbox"]:disabled { | ||||
|   --color: var(--dark-gray); | ||||
|   cursor: not-allowed; | ||||
| } | ||||
| 
 | ||||
| .grist-form input[type="checkbox"]::before, .grist-form input[type="checkbox"]::after { | ||||
|   content: ''; | ||||
| 
 | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
| 
 | ||||
|   height: 16px; | ||||
|   width: 16px; | ||||
| 
 | ||||
|   box-sizing: border-box; | ||||
|   border: 1px solid var(--color, var(--dark-gray)); | ||||
|   border-radius: var(--radius); | ||||
| } | ||||
| 
 | ||||
| .grist-form input[type="checkbox"]:checked::before, .grist-form input[type="checkbox"]:disabled::before, .grist-form input[type="checkbox"]:indeterminate::before { | ||||
|   background-color: var(--color); | ||||
| } | ||||
| 
 | ||||
| .grist-form input[type="checkbox"]:not(:checked):indeterminate::after { | ||||
|   -webkit-mask-image: var(--icon-Minus); | ||||
| } | ||||
| 
 | ||||
| .grist-form input[type="checkbox"]:not(:disabled)::after { | ||||
|   background-color: var(--light); | ||||
| } | ||||
| 
 | ||||
| .grist-form input[type="checkbox"]:checked::after, .grist-form input[type="checkbox"]:indeterminate::after { | ||||
|   content: ''; | ||||
|   position: absolute; | ||||
|   height: 16px; | ||||
|   width: 16px; | ||||
|   -webkit-mask-image: var(--icon-Tick); | ||||
|   -webkit-mask-size: contain; | ||||
|   -webkit-mask-position: center; | ||||
|   -webkit-mask-repeat: no-repeat; | ||||
|   background-color: var(--light); | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| .grist-form .grist-submit input[type="submit"]:hover, .grist-form-container button:hover { | ||||
|   border-color: var(--primary-dark); | ||||
|   background-color: var(--primary-dark); | ||||
| } | ||||
| 
 | ||||
| .grist-power-by { | ||||
|   margin-top: 24px; | ||||
|   color: var(--dark-text, #494949); | ||||
|   font-size: 13px; | ||||
|   font-style: normal; | ||||
|   font-weight: 600; | ||||
|   line-height: 16px; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   border-top: 1px solid var(--dark-gray); | ||||
|   padding: 10px; | ||||
|   margin-left: calc(-1 * var(--grist-form-padding)); | ||||
|   margin-right: calc(-1 * var(--grist-form-padding)); | ||||
| } | ||||
| 
 | ||||
| .grist-power-by a { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   gap: 8px; | ||||
|   color: var(--dark-text, #494949); | ||||
|   text-decoration: none; | ||||
| } | ||||
| 
 | ||||
| .grist-logo { | ||||
|   width: 58px; | ||||
|   height: 20.416px; | ||||
|   flex-shrink: 0; | ||||
|   background: url(logo.png); | ||||
|   background-position: 0 0; | ||||
|   background-size: contain; | ||||
|   background-color: transparent; | ||||
|   background-repeat: no-repeat; | ||||
|   margin-top: 3px; | ||||
| } | ||||
| 
 | ||||
| .grist-question > .grist-label { | ||||
|   color: var(--dark, #262633); | ||||
|   font-size: 12px; | ||||
|   font-style: normal; | ||||
|   font-weight: 700; | ||||
|   line-height: 16px; /* 145.455% */ | ||||
|   margin-bottom: 8px; | ||||
|   display: block; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| /* Markdown reset */ | ||||
| 
 | ||||
| .grist-form h1, | ||||
| .grist-form h2, | ||||
| .grist-form h3, | ||||
| .grist-form h4, | ||||
| .grist-form h5, | ||||
| .grist-form h6 { | ||||
|   margin: 4px 0px; | ||||
|   font-weight: normal; | ||||
| } | ||||
| .grist-form h1 { | ||||
|   font-size: 24px; | ||||
| } | ||||
| .grist-form h2 { | ||||
|   font-size: 22px; | ||||
| } | ||||
| .grist-form h3 { | ||||
|   font-size: 16px; | ||||
| } | ||||
| .grist-form  h4 { | ||||
|   font-size: 13px; | ||||
| } | ||||
| .grist-form h5 { | ||||
|   font-size: 11px; | ||||
| } | ||||
| .grist-form h6 { | ||||
|   font-size: 10px; | ||||
| } | ||||
| .grist-form p { | ||||
|   margin: 0px; | ||||
| } | ||||
| .grist-form strong { | ||||
|   font-weight: 600; | ||||
| } | ||||
| .grist-form hr { | ||||
|   border: 0px; | ||||
|   border-top: 1px solid var(--dark-gray); | ||||
|   margin: 4px 0px; | ||||
| } | ||||
| 
 | ||||
| .grist-text-left { | ||||
|   text-align: left; | ||||
| } | ||||
| .grist-text-right { | ||||
|   text-align: right; | ||||
| } | ||||
| .grist-text-center { | ||||
|   text-align: center; | ||||
| } | ||||
| 
 | ||||
| .grist-switch { | ||||
|   cursor: pointer; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
| } | ||||
| .grist-switch input[type='checkbox']::after { | ||||
|   content: none; | ||||
| } | ||||
| .grist-switch input[type='checkbox']::before { | ||||
|   content: none; | ||||
| } | ||||
| .grist-switch input[type='checkbox'] { | ||||
|   position: absolute; | ||||
| } | ||||
| .grist-switch > span { | ||||
|   margin-left: 8px; | ||||
| } | ||||
| 
 | ||||
| /* Slider component */ | ||||
| .grist-widget_switch { | ||||
|   position: relative; | ||||
|   width: 30px; | ||||
|   height: 17px; | ||||
|   display: inline-block; | ||||
|   flex: none; | ||||
| } | ||||
| 
 | ||||
| .grist-switch_slider { | ||||
|   position: absolute; | ||||
|   cursor: pointer; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   right: 0; | ||||
|   bottom: 0; | ||||
|   background-color: var(--grist-theme-switch-slider-fg, #ccc); | ||||
|   border-radius: 17px; | ||||
| } | ||||
| 
 | ||||
| .grist-switch_slider:hover { | ||||
|   box-shadow: 0 0 1px #2196F3; | ||||
| } | ||||
| 
 | ||||
| .grist-switch_circle { | ||||
|   position: absolute; | ||||
|   cursor: pointer; | ||||
|   content: ""; | ||||
|   height: 13px; | ||||
|   width: 13px; | ||||
|   left: 2px; | ||||
|   bottom: 2px; | ||||
|   background-color: var(--grist-theme-switch-circle-fg, white); | ||||
|   border-radius: 17px; | ||||
| } | ||||
| 
 | ||||
| input:checked + .grist-switch_transition > .grist-switch_slider { | ||||
|   background-color: var(--primary, #16b378); | ||||
| } | ||||
| 
 | ||||
| input:checked + .grist-switch_transition > .grist-switch_circle { | ||||
|   -webkit-transform: translateX(13px); | ||||
|   -ms-transform: translateX(13px); | ||||
|   transform: translateX(13px); | ||||
| } | ||||
| 
 | ||||
| .grist-switch_on > .grist-switch_slider { | ||||
|   background-color: var(--grist-actual-cell-color, #2CB0AF); | ||||
| } | ||||
| 
 | ||||
| .grist-switch_on > .grist-switch_circle { | ||||
|   -webkit-transform: translateX(13px); | ||||
|   -ms-transform: translateX(13px); | ||||
|   transform: translateX(13px); | ||||
| } | ||||
| 
 | ||||
| .grist-switch_transition > .grist-switch_slider, .grist-switch_transition > .grist-switch_circle { | ||||
|   -webkit-transition: .4s; | ||||
|   transition: .4s; | ||||
| } | ||||
| @ -1,156 +1,64 @@ | ||||
| <!doctype html> | ||||
| <html> | ||||
| 
 | ||||
| <head> | ||||
|   <meta charset="utf8"> | ||||
|   <!-- INSERT BASE --> | ||||
|   {{#if BASE}} | ||||
|   <base href="{{ BASE }}"> | ||||
|   {{/if}} | ||||
|   <style> | ||||
|     html, | ||||
|     body { | ||||
|       padding: 0px; | ||||
|       margin: 0px; | ||||
|       background-color: #f7f7f7; | ||||
|       line-height: 1.42857143; | ||||
|     } | ||||
| 
 | ||||
|     * { | ||||
|       box-sizing: border-box; | ||||
|     } | ||||
|   </style> | ||||
|   <script src="forms/grist-form-submit.js"></script> | ||||
|   <script src="forms/purify.min.js"></script> | ||||
|   <style> | ||||
|     .grist-form-container { | ||||
|       color: #262633; | ||||
|       background-color: #f7f7f7; | ||||
|       min-height: 100%; | ||||
|       width: 100%; | ||||
|       padding-top: 52px; | ||||
|       padding-bottom: 32px; | ||||
|       font-size: 13px; | ||||
|       font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Liberation Sans", Helvetica, Arial, sans-serif, | ||||
|         "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; | ||||
|     } | ||||
| 
 | ||||
|     .grist-form-container .grist-form-confirm { | ||||
|       text-align: center; | ||||
|     } | ||||
| 
 | ||||
|     form.grist-form { | ||||
|       padding: 32px; | ||||
|       margin: 0px auto; | ||||
|       background-color: white; | ||||
|       border: 1px solid #E8E8E8; | ||||
|       width: 640px; | ||||
|       border-radius: 8px; | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       gap: 16px; | ||||
|       max-width: calc(100% - 32px); | ||||
|     } | ||||
| 
 | ||||
|     form.grist-form .grist-field { | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|     } | ||||
| 
 | ||||
|     form.grist-form .grist-field label { | ||||
|       font-size: 15px; | ||||
|       margin-bottom: 8px; | ||||
|       font-weight: normal; | ||||
|     } | ||||
| 
 | ||||
|     form.grist-form .grist-field .grist-field-description { | ||||
|       font-size: 10px; | ||||
|       font-weight: 400; | ||||
|       margin-top: 4px; | ||||
|       color: #929299; | ||||
|       white-space: pre-wrap; | ||||
|     } | ||||
| 
 | ||||
|     form.grist-form .grist-field input[type="text"] { | ||||
|       padding: 4px 8px; | ||||
|       border-radius: 3px; | ||||
|       border: 1px solid #D9D9D9; | ||||
|       font-size: 13px; | ||||
|       outline-color: #16b378; | ||||
|       outline-width: 1px; | ||||
|       line-height: inherit; | ||||
|       width: 100%; | ||||
|     } | ||||
| 
 | ||||
|     form.grist-form input[type="submit"] { | ||||
|       background-color: #16b378; | ||||
|       border: 1px solid #16b378; | ||||
|       color: white; | ||||
|       padding: 4px 8px; | ||||
|       border-radius: 4px; | ||||
|       font-size: 13px; | ||||
|       cursor: pointer; | ||||
|       line-height: inherit; | ||||
|     } | ||||
| 
 | ||||
|     form.grist-form input[type="datetime-local"] { | ||||
|       width: 100%; | ||||
|       line-height: inherit; | ||||
|     } | ||||
| 
 | ||||
|     form.grist-form input[type="date"] { | ||||
|       width: 100%; | ||||
|       line-height: inherit; | ||||
|     } | ||||
| 
 | ||||
|     form.grist-form input[type="submit"]:hover { | ||||
|       border-color: #009058; | ||||
|       background-color: #009058; | ||||
|     } | ||||
| 
 | ||||
|     form.grist-form input[type="checkbox"] { | ||||
|       margin: 0px; | ||||
|     } | ||||
| 
 | ||||
|     form.grist-form .grist-columns { | ||||
|       display: grid; | ||||
|       grid-template-columns: repeat(var(--grist-columns-count), 1fr); | ||||
|       gap: 4px; | ||||
|     } | ||||
| 
 | ||||
|     form.grist-form select { | ||||
|       padding: 4px 8px; | ||||
|       border-radius: 3px; | ||||
|       border: 1px solid #D9D9D9; | ||||
|       font-size: 13px; | ||||
|       outline-color: #16b378; | ||||
|       outline-width: 1px; | ||||
|       background: white; | ||||
|       line-height: inherit; | ||||
|       flex: auto; | ||||
|       width: 100%; | ||||
|     } | ||||
| 
 | ||||
|     form.grist-form .grist-choice-list { | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       gap: 4px; | ||||
|     } | ||||
|   </style> | ||||
| 
 | ||||
|   <link rel="stylesheet" href="forms/form.css"> | ||||
|   <meta name="viewport" content="width=device-width, initial-scale=1"> | ||||
| </head> | ||||
| 
 | ||||
| <body> | ||||
|   <main class='grist-form-container'> | ||||
|     <form class='grist-form' | ||||
|           onsubmit="event.target.parentElement.querySelector('.grist-form-confirm').style.display = 'block', event.target.style.display = 'none'" | ||||
|           data-grist-doc="<!-- INSERT DOC URL -->" | ||||
|           data-grist-table="<!-- INSERT TABLE ID -->"> | ||||
|       <script> | ||||
|         document.write(DOMPurify.sanitize(`<!-- INSERT CONTENT -->`)); | ||||
|       </script> | ||||
|           onsubmit="event.target.parentElement.querySelector('.grist-form-confirm').style.display = 'flex', event.target.style.display = 'none'" | ||||
|           data-grist-doc="{{ DOC_URL }}" | ||||
|           data-grist-table="{{ TABLE_ID }}" | ||||
|           data-grist-success-url="{{ SUCCESS_URL }}" | ||||
|           > | ||||
|       {{ dompurify CONTENT }} | ||||
|       <div class="grist-power-by"> | ||||
|         <a href="https://getgrist.com" target="_blank"> | ||||
|             <div>Powered by</div> | ||||
|             <div class="grist-logo"></div> | ||||
|           </a> | ||||
|       </div> | ||||
|     </form> | ||||
|     <div class='grist-form-confirm' style='display: none'> | ||||
|       Thank you! Your response has been recorded. | ||||
|       <div> | ||||
|         {{ SUCCESS_TEXT }} | ||||
|       </div> | ||||
|       {{#if ANOTHER_RESPONSE }} | ||||
|       <button onclick="window.location.reload()">Submit another response</button> | ||||
|       {{/if}} | ||||
|     </div> | ||||
|   </main> | ||||
|   <script> | ||||
|     // Validate choice list on submit | ||||
|     document.querySelector('.grist-form input[type="submit"]').addEventListener('click', function(event) { | ||||
|       // When submit is pressed make sure that all choice lists that are required | ||||
|       // have at least one option selected | ||||
|       const choiceLists = document.querySelectorAll('.grist-choice-list.required:not(:has(input:checked))'); | ||||
|       Array.from(choiceLists).forEach(function(choiceList) { | ||||
|         // If the form has at least one checkbox make it required | ||||
|         const firstCheckbox = choiceList.querySelector('input[type="checkbox"]'); | ||||
|         firstCheckbox?.setAttribute('required', '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)'); | ||||
|       Array.from(choiceListsRequired).forEach(function(choiceList) { | ||||
|         // If the form has at least one checkbox make it required | ||||
|         const firstCheckbox = choiceList.querySelector('input[type="checkbox"]'); | ||||
|         firstCheckbox?.removeAttribute('required'); | ||||
|       }); | ||||
|     }); | ||||
|   </script> | ||||
| </body> | ||||
| 
 | ||||
| </html> | ||||
|  | ||||
| @ -9,12 +9,13 @@ if (!window.gristFormSubmit) { | ||||
|  *  - `formData` should be a [FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
 | ||||
|  *    object, typically obtained as `new FormData(formElement)`. Inside the `submit` event handler, it | ||||
|  *    can be convenient to use `new FormData(event.target)`. | ||||
|  *  - formElement is the form element that was submitted. | ||||
|  * | ||||
|  * This function sends values from `formData` to add a new record in the specified Grist table. It | ||||
|  * returns a promise for the result of the add-record API call. In case of an error, the promise | ||||
|  * will be rejected with an error message. | ||||
|  */ | ||||
| async function gristFormSubmit(docUrl, tableId, formData) { | ||||
| async function gristFormSubmit(docUrl, tableId, formData, formElement) { | ||||
|   // Pick out the server and docId from the docUrl.
 | ||||
|   const match = /^(https?:\/\/[^\/]+(?:\/o\/[^\/]+)?)\/(?:doc\/([^\/?#]+)|([^\/?#]{12,})\/)/.exec(docUrl); | ||||
|   if (!match) { throw new Error("Invalid Grist doc URL " + docUrl); } | ||||
| @ -24,7 +25,7 @@ async function gristFormSubmit(docUrl, tableId, formData) { | ||||
|   // Construct the URL to use for the add-record API endpoint.
 | ||||
|   const destUrl = server + "/api/docs/" + docId + "/tables/" + tableId + "/records"; | ||||
| 
 | ||||
|   const payload = {records: [{fields: formDataToJson(formData)}]}; | ||||
|   const payload = {records: [{fields: formDataToJson(formData, formElement)}]}; | ||||
|   const options = { | ||||
|     method: 'POST', | ||||
|     headers: {'Content-Type': 'application/json'}, | ||||
| @ -58,6 +59,35 @@ function formDataToJson(f) { | ||||
|     k.endsWith('[]') ? [k.slice(0, -2), ['L', ...f.getAll(k)]] : [k, f.get(k)])); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * TypedFormData is a wrapper around FormData that provides type information for the fields. | ||||
|  */ | ||||
| class TypedFormData { | ||||
|   constructor(formElement, formData) { | ||||
|     if (!(formElement instanceof HTMLFormElement)) throw new Error("formElement must be a form"); | ||||
|     if (formData && !(formData instanceof FormData)) throw new Error("formData must be a FormData"); | ||||
|     this._formData = formData ?? new FormData(formElement); | ||||
|     this._formElement = formElement; | ||||
|   } | ||||
|   keys() { return this._formData.keys(); } | ||||
|   type(key) { | ||||
|     return this._formElement?.querySelector(`[name="${key}"]`)?.getAttribute('data-grist-type'); | ||||
|   } | ||||
|   get(key) { | ||||
|     const value = this._formData.get(key); | ||||
|     if (value === null) { return null; } | ||||
|     const type = this.type(key); | ||||
|     return type === 'Ref' || type === 'RefList' ? Number(value) : value; | ||||
|   } | ||||
|   getAll(key) { | ||||
|     const values = Array.from(this._formData.getAll(key)); | ||||
|     if (['Ref', 'RefList'].includes(this.type(key))) { | ||||
|       return values.map(v => Number(v)); | ||||
|     } | ||||
|     return values; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| // Handle submissions for plain forms that include special data-grist-* attributes.
 | ||||
| async function handleSubmitPlainForm(ev) { | ||||
| @ -76,7 +106,7 @@ async function handleSubmitPlainForm(ev) { | ||||
| 
 | ||||
|     const successUrl = ev.target.getAttribute('data-grist-success-url'); | ||||
| 
 | ||||
|     await gristFormSubmit(docUrl, tableId, new FormData(ev.target)); | ||||
|     await gristFormSubmit(docUrl, tableId, new TypedFormData(ev.target)); | ||||
| 
 | ||||
|     // On success, redirect to the requested URL.
 | ||||
|     if (successUrl) { | ||||
| @ -111,7 +141,7 @@ async function handleSubmitWPCF7(ev) { | ||||
|     if (!docUrl) { throw new Error("Missing attribute data-grist-doc='GRIST_DOC_URL'"); } | ||||
|     if (!tableId) { throw new Error("Missing attribute data-grist-table='GRIST_TABLE_ID'"); } | ||||
| 
 | ||||
|     await gristFormSubmit(docUrl, tableId, new FormData(ev.target)); | ||||
|     await gristFormSubmit(docUrl, tableId, new TypedFormData(ev.target)); | ||||
|     console.log("grist-form-submit WPCF7 Form %s: Added record", formId); | ||||
| 
 | ||||
|   } catch (err) { | ||||
| @ -135,7 +165,7 @@ async function handleSubmitGravityForm(ev, options) { | ||||
|     if (!docUrl) { throw new Error("setUpGravityForm: missing docUrl option"); } | ||||
|     if (!tableId) { throw new Error("setUpGravityForm: missing tableId option"); } | ||||
| 
 | ||||
|     const f = new FormData(ev.target); | ||||
|     const f = new TypedFormData(ev.target); | ||||
|     for (const key of Array.from(f.keys())) { | ||||
|       // Skip fields other than input fields.
 | ||||
|       if (!key.startsWith("input_")) { | ||||
|  | ||||
							
								
								
									
										
											BIN
										
									
								
								static/forms/logo.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 6.7 KiB | 
| @ -50,11 +50,13 @@ | ||||
|   --icon-Chat: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTkuNTYyIDNhNy41IDcuNSAwIDAgMC02Ljc5OCAxMC42NzNsLS43MjQgMi44NDJhMS4yNSAxLjI1IDAgMCAwIDEuNTA0IDEuNTI0Yy43NS0uMTggMS45MDMtLjQ1NyAyLjkzLS43MDJBNy41IDcuNSAwIDEgMCA5LjU2MSAzWm0tNiA3LjVhNiA2IDAgMSAxIDMuMzMgNS4zNzVsLS4yNDQtLjEyMS0uMjY0LjA2M2MtLjkyMy4yMi0xLjk5LjQ3NS0yLjc4OC42NjdsLjY5LTIuNzA4LjA3LS4yNzYtLjEzLS4yNTNhNS45NzEgNS45NzEgMCAwIDEtLjY2NC0yLjc0N1ptMTEgMTAuNWMtMS45NyAwLTMuNzYyLS43NTktNS4xLTJoLjFjLjcxOCAwIDEuNDE1LS4wODkgMi4wOC0uMjU3Ljg2NS40ODIgMS44Ni43NTcgMi45Mi43NTcuOTYgMCAxLjg2Ni0uMjI1IDIuNjctLjYyNWwuMjQzLS4xMjEuMjY0LjA2M2MuOTIyLjIyIDEuOTY2LjQ0NSAyLjc0LjYxLS4xNzUtLjc1MS0uNDE0LTEuNzU2LS42NDItMi42NTFsLS4wNy0uMjc2LjEzLS4yNTNhNS45NzEgNS45NzEgMCAwIDAgLjY2NS0yLjc0NyA1Ljk5NSA1Ljk5NSAwIDAgMC0yLjc0Ny01LjA0MiA4LjQ0IDguNDQgMCAwIDAtLjgtMi4wNDcgNy41MDMgNy41MDMgMCAwIDEgNC4zNDQgMTAuMjYzYy4yNTMgMS4wMDguNTA5IDIuMS42NzEgMi44MDNhMS4yNDQgMS4yNDQgMCAwIDEtMS40NjcgMS41IDEzMi42MiAxMzIuNjIgMCAwIDEtMi45MTMtLjY0IDcuNDc2IDcuNDc2IDAgMCAxLTMuMDg4LjY2M1oiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); | ||||
|   --icon-Code: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEwLjAzMzMyNzIsMC4zMjA1MTA0NiBDMTAuMTMyNDU2NSwwLjA2Mjc3NDE4OCAxMC40MjE3NTMzLC0wLjA2NTgwMjEzODYgMTAuNjc5NDg5NSwwLjAzMzMyNzE5NjkgQzEwLjkzNzIyNTgsMC4xMzI0NTY1MzIgMTEuMDY1ODAyMSwwLjQyMTc1MzI2NyAxMC45NjY2NzI4LDAuNjc5NDg5NTQgTDUuOTY2NjcyOCwxMy42Nzk0ODk1IEM1Ljg2NzU0MzQ3LDEzLjkzNzIyNTggNS41NzgyNDY3MywxNC4wNjU4MDIxIDUuMzIwNTEwNDYsMTMuOTY2NjcyOCBDNS4wNjI3NzQxOSwxMy44Njc1NDM1IDQuOTM0MTk3ODYsMTMuNTc4MjQ2NyA1LjAzMzMyNzIsMTMuMzIwNTEwNSBMMTAuMDMzMzI3MiwwLjMyMDUxMDQ2IFogTTMuODUzNTUzMzksOS4xNDY0NDY2MSBDNC4wNDg4MTU1NCw5LjM0MTcwODc2IDQuMDQ4ODE1NTQsOS42NTgyOTEyNCAzLjg1MzU1MzM5LDkuODUzNTUzMzkgQzMuNjU4MjkxMjQsMTAuMDQ4ODE1NSAzLjM0MTcwODc2LDEwLjA0ODgxNTUgMy4xNDY0NDY2MSw5Ljg1MzU1MzM5IEwwLjE0NjQ0NjYwOSw2Ljg1MzU1MzM5IEMtMC4wNDg4MTU1MzY1LDYuNjU4MjkxMjQgLTAuMDQ4ODE1NTM2NSw2LjM0MTcwODc2IDAuMTQ2NDQ2NjA5LDYuMTQ2NDQ2NjEgTDMuMTQ2NDQ2NjEsMy4xNDY0NDY2MSBDMy4zNDE3MDg3NiwyLjk1MTE4NDQ2IDMuNjU4MjkxMjQsMi45NTExODQ0NiAzLjg1MzU1MzM5LDMuMTQ2NDQ2NjEgQzQuMDQ4ODE1NTQsMy4zNDE3MDg3NiA0LjA0ODgxNTU0LDMuNjU4MjkxMjQgMy44NTM1NTMzOSwzLjg1MzU1MzM5IEwxLjIwNzEwNjc4LDYuNSBMMy44NTM1NTMzOSw5LjE0NjQ0NjYxIFogTTEyLjE0NjQ0NjYsMy44NTM1NTMzOSBDMTEuOTUxMTg0NSwzLjY1ODI5MTI0IDExLjk1MTE4NDUsMy4zNDE3MDg3NiAxMi4xNDY0NDY2LDMuMTQ2NDQ2NjEgQzEyLjM0MTcwODgsMi45NTExODQ0NiAxMi42NTgyOTEyLDIuOTUxMTg0NDYgMTIuODUzNTUzNCwzLjE0NjQ0NjYxIEwxNS44NTM1NTM0LDYuMTQ2NDQ2NjEgQzE2LjA0ODgxNTUsNi4zNDE3MDg3NiAxNi4wNDg4MTU1LDYuNjU4MjkxMjQgMTUuODUzNTUzNCw2Ljg1MzU1MzM5IEwxMi44NTM1NTM0LDkuODUzNTUzMzkgQzEyLjY1ODI5MTIsMTAuMDQ4ODE1NSAxMi4zNDE3MDg4LDEwLjA0ODgxNTUgMTIuMTQ2NDQ2Niw5Ljg1MzU1MzM5IEMxMS45NTExODQ1LDkuNjU4MjkxMjQgMTEuOTUxMTg0NSw5LjM0MTcwODc2IDEyLjE0NjQ0NjYsOS4xNDY0NDY2MSBMMTQuNzkyODkzMiw2LjUgTDEyLjE0NjQ0NjYsMy44NTM1NTMzOSBaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIDEpIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4='); | ||||
|   --icon-Collapse: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsOS4xNzQ2MzA1MiBMMTAuOTIxODI3Myw2LjE4OTAyMzIgQzExLjE2ODQ3NDIsNS45MzY5OTIyNyAxMS41NjgzNjc5LDUuOTM2OTkyMjcgMTEuODE1MDE0OCw2LjE4OTAyMzIgQzEyLjA2MTY2MTcsNi40NDEwNTQxMyAxMi4wNjE2NjE3LDYuODQ5Njc3MDEgMTEuODE1MDE0OCw3LjEwMTcwNzk0IEw4LDExIEw0LjE4NDk4NTE5LDcuMTAxNzA3OTQgQzMuOTM4MzM4MjcsNi44NDk2NzcwMSAzLjkzODMzODI3LDYuNDQxMDU0MTMgNC4xODQ5ODUxOSw2LjE4OTAyMzIgQzQuNDMxNjMyMTEsNS45MzY5OTIyNyA0LjgzMTUyNTc4LDUuOTM2OTkyMjcgNS4wNzgxNzI3LDYuMTg5MDIzMiBMOCw5LjE3NDYzMDUyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); | ||||
|   --icon-Columns: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzEwNjFfNzU3OSkiPjxwYXRoIGQ9Ik0wIDJWMTQuMjM1M0gxNlYySDBaTTkuNDExNzYgMy44ODIzNVYxMi4zNTI5SDYuNTg4MjRWMy44ODIzNUg5LjQxMTc2Wk0xLjg4MjM1IDMuODgyMzVINC43MDU4OFYxMi4zNTI5SDEuODgyMzVWMy44ODIzNVpNMTQuMTE3NiAxMi4zNTI5SDExLjI5NDFWMy44ODIzNUgxNC4xMTc2VjEyLjM1MjlaIiBmaWxsPSIjMDAwIi8+PC9nPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDBfMTA2MV83NTc5Ij48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDE2VjE2SDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PC9zdmc+'); | ||||
|   --icon-Convert: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuNzU2MDEzNDUsMTIuMjQzOTg2NSBMMiwxNCBMMiw5IEw2LjUsOS41IEw0LjQ2MzE0MTk4LDExLjUzNjg1OCBDNS41MTUxNDg5NCwxMi41ODk5NDY4IDYuOTk5MzIzODMsMTMuMTMzMjk5OCA4LjUxNTU5MDEzLDEyLjk3NjIzNTIgQzEwLjM3OTM1LDEyLjc4MzE3NDggMTEuOTc4NzQxMSwxMS41NjQyNzc0IDEyLjY1OTEyODYsOS44MTg0NDA2IEMxMi43NTk0MDExLDkuNTYxMTQ2OTIgMTMuMDQ5MjY1Nyw5LjQzMzg1NjA5IDEzLjMwNjU1OTQsOS41MzQxMjg1OCBDMTMuNTYzODUzMSw5LjYzNDQwMTA2IDEzLjY5MTE0MzksOS45MjQyNjU3MiAxMy41OTA4NzE0LDEwLjE4MTU1OTQgQzEyLjc3NDQwNjQsMTIuMjc2NTYzNiAxMC44NTUxMzcyLDEzLjczOTI0MDUgOC42MTg2MjUyOSwxMy45NzA5MTI5IEM2Ljc5OTI2MjMyLDE0LjE1OTM3NDMgNS4wMTg0MDI0MSwxMy41MDc0NzkxIDMuNzU2MDEzNDUsMTIuMjQzOTg2NSBaIE0xMi4yNDM5ODY1LDMuNzU2MDEzNDUgTDE0LDIgTDE0LDcgTDkuNSw2LjUgTDExLjUzNjg1OCw0LjQ2MzE0MTk4IEMxMC40ODQ4NTExLDMuNDEwMDUzMTggOS4wMDA2NzYxNywyLjg2NjcwMDE1IDcuNDg0NDA5ODcsMy4wMjM3NjQ4MyBDNS42MjA2NDk5NiwzLjIxNjgyNTE2IDQuMDIxMjU4OSw0LjQzNTcyMjU5IDMuMzQwODcxNDIsNi4xODE1NTk0IEMzLjI0MDU5ODk0LDYuNDM4ODUzMDggMi45NTA3MzQyOCw2LjU2NjE0MzkxIDIuNjkzNDQwNiw2LjQ2NTg3MTQyIEMyLjQzNjE0NjkyLDYuMzY1NTk4OTQgMi4zMDg4NTYwOSw2LjA3NTczNDI4IDIuNDA5MTI4NTgsNS44MTg0NDA2IEMzLjIyNTU5MzU1LDMuNzIzNDM2NDMgNS4xNDQ4NjI4MiwyLjI2MDc1OTUxIDcuMzgxMzc0NzEsMi4wMjkwODcxMiBDOS4yMDA3Mzc2OCwxLjg0MDYyNTcyIDEwLjk4MTU5NzYsMi40OTI1MjA5MiAxMi4yNDM5ODY1LDMuNzU2MDEzNDUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); | ||||
|   --icon-Copy: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLDMgTDExLDE1IEwyLDE1IEwyLDMgTDExLDMgWiBNMTAsMy45OTkgTDMsMy45OTkgTDMsMTMuOTk5IEwxMCwxMy45OTkgTDEwLDMuOTk5IFogTTE0LDAgTDE0LDEyIEwxMS41LDEyIEwxMS41LDExIEwxMywxMSBMMTMsMSBMNiwxIEw2LDIuNSBMNSwyLjUgTDUsMCBMMTQsMCBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4='); | ||||
|   --icon-CrossBig: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEzLjQxNDIxMzYsMTIgTDE5LjcwNzEwNjgsMTguMjkyODkzMiBDMjAuMDk3NjMxMSwxOC42ODM0MTc1IDIwLjA5NzYzMTEsMTkuMzE2NTgyNSAxOS43MDcxMDY4LDE5LjcwNzEwNjggQzE5LjMxNjU4MjUsMjAuMDk3NjMxMSAxOC42ODM0MTc1LDIwLjA5NzYzMTEgMTguMjkyODkzMiwxOS43MDcxMDY4IEwxMiwxMy40MTQyMTM2IEw1LjcwNzEwNjc4LDE5LjcwNzEwNjggQzUuMzE2NTgyNDksMjAuMDk3NjMxMSA0LjY4MzQxNzUxLDIwLjA5NzYzMTEgNC4yOTI4OTMyMiwxOS43MDcxMDY4IEMzLjkwMjM2ODkzLDE5LjMxNjU4MjUgMy45MDIzNjg5MywxOC42ODM0MTc1IDQuMjkyODkzMjIsMTguMjkyODkzMiBMMTAuNTg1Nzg2NCwxMiBMNC4yOTI4OTMyMiw1LjcwNzEwNjc4IEMzLjkwMjM2ODkzLDUuMzE2NTgyNDkgMy45MDIzNjg5Myw0LjY4MzQxNzUxIDQuMjkyODkzMjIsNC4yOTI4OTMyMiBDNC42ODM0MTc1MSwzLjkwMjM2ODkzIDUuMzE2NTgyNDksMy45MDIzNjg5MyA1LjcwNzEwNjc4LDQuMjkyODkzMjIgTDEyLDEwLjU4NTc4NjQgTDE4LjI5Mjg5MzIsNC4yOTI4OTMyMiBDMTguNjgzNDE3NSwzLjkwMjM2ODkzIDE5LjMxNjU4MjUsMy45MDIzNjg5MyAxOS43MDcxMDY4LDQuMjkyODkzMjIgQzIwLjA5NzYzMTEsNC42ODM0MTc1MSAyMC4wOTc2MzExLDUuMzE2NTgyNDkgMTkuNzA3MTA2OCw1LjcwNzEwNjc4IEwxMy40MTQyMTM2LDEyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); | ||||
|   --icon-CrossSmall: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTguNSw3LjUgTDEyLjUsNy41IEMxMi43NzYxNDI0LDcuNSAxMyw3LjcyMzg1NzYzIDEzLDggQzEzLDguMjc2MTQyMzcgMTIuNzc2MTQyNCw4LjUgMTIuNSw4LjUgTDguNSw4LjUgTDguNSwxMi41IEM4LjUsMTIuNzc2MTQyNCA4LjI3NjE0MjM3LDEzIDgsMTMgQzcuNzIzODU3NjMsMTMgNy41LDEyLjc3NjE0MjQgNy41LDEyLjUgTDcuNSw4LjUgTDMuNSw4LjUgQzMuMjIzODU3NjMsOC41IDMsOC4yNzYxNDIzNyAzLDggQzMsNy43MjM4NTc2MyAzLjIyMzg1NzYzLDcuNSAzLjUsNy41IEw3LjUsNy41IEw3LjUsMy41IEM3LjUsMy4yMjM4NTc2MyA3LjcyMzg1NzYzLDMgOCwzIEM4LjI3NjE0MjM3LDMgOC41LDMuMjIzODU3NjMgOC41LDMuNSBMOC41LDcuNSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iIHRyYW5zZm9ybT0icm90YXRlKC00NSA4IDgpIi8+PC9zdmc+'); | ||||
|   --icon-Database: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTYiIHdpZHRoPSIxNiI+PGcgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIiBzdHJva2U9IiMyMTIxMjEiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgY2xhc3M9Im5jLWljb24td3JhcHBlciIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PGVsbGlwc2UgY3g9IjgiIGN5PSIzIiByeD0iNi41IiByeT0iMi41IiBkYXRhLWNhcD0iYnV0dCIvPjxwYXRoIGQ9Ik0xLjUsNi41VjEzIGMwLDEuMzgxLDIuOTEsMi41LDYuNSwyLjVzNi41LTEuMTE5LDYuNS0yLjVWNi41IiBkYXRhLWNhcD0iYnV0dCIvPjwvZz48L3N2Zz4='); | ||||
|   --icon-Desktop: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzEwNjFfNzYwMSkiPjxwYXRoIGQ9Ik0xNC41NDU1IDFIMS40NTQ1NUMwLjY1NDU0NSAxIDAgMS42NTQ1NSAwIDIuNDU0NTVWMTEuMTgxOEMwIDExLjk4MTggMC42NTQ1NDUgMTIuNjM2NCAxLjQ1NDU1IDEyLjYzNjRINi41NDU0NUw1LjA5MDkxIDE0LjgxODJWMTUuNTQ1NUgxMC45MDkxVjE0LjgxODJMOS40NTQ1NSAxMi42MzY0SDE0LjU0NTVDMTUuMzQ1NSAxMi42MzY0IDE2IDExLjk4MTggMTYgMTEuMTgxOFYyLjQ1NDU1QzE2IDEuNjU0NTUgMTUuMzQ1NSAxIDE0LjU0NTUgMVpNMTQuNTQ1NSA5LjcyNzI3SDEuNDU0NTVWMi40NTQ1NUgxNC41NDU1VjkuNzI3MjdaIiBmaWxsPSIjMDAwIi8+PC9nPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDBfMTA2MV83NjAxIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDE2VjE2SDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PC9zdmc+'); | ||||
|   --icon-Dots: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsOS41IEM3LjE3MTU3Mjg4LDkuNSA2LjUsOC44Mjg0MjcxMiA2LjUsOCBDNi41LDcuMTcxNTcyODggNy4xNzE1NzI4OCw2LjUgOCw2LjUgQzguODI4NDI3MTIsNi41IDkuNSw3LjE3MTU3Mjg4IDkuNSw4IEM5LjUsOC44Mjg0MjcxMiA4LjgyODQyNzEyLDkuNSA4LDkuNSBaIE0xMi41LDkuNSBDMTEuNjcxNTcyOSw5LjUgMTEsOC44Mjg0MjcxMiAxMSw4IEMxMSw3LjE3MTU3Mjg4IDExLjY3MTU3MjksNi41IDEyLjUsNi41IEMxMy4zMjg0MjcxLDYuNSAxNCw3LjE3MTU3Mjg4IDE0LDggQzE0LDguODI4NDI3MTIgMTMuMzI4NDI3MSw5LjUgMTIuNSw5LjUgWiBNMy41LDkuNSBDMi42NzE1NzI4OCw5LjUgMiw4LjgyODQyNzEyIDIsOCBDMiw3LjE3MTU3Mjg4IDIuNjcxNTcyODgsNi41IDMuNSw2LjUgQzQuMzI4NDI3MTIsNi41IDUsNy4xNzE1NzI4OCA1LDggQzUsOC44Mjg0MjcxMiA0LjMyODQyNzEyLDkuNSAzLjUsOS41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); | ||||
|   --icon-Download: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEyLDEzLjI5Mjg5MzIgTDE0LjE0NjQ0NjYsMTEuMTQ2NDQ2NiBDMTQuMzQxNzA4OCwxMC45NTExODQ1IDE0LjY1ODI5MTIsMTAuOTUxMTg0NSAxNC44NTM1NTM0LDExLjE0NjQ0NjYgQzE1LjA0ODgxNTUsMTEuMzQxNzA4OCAxNS4wNDg4MTU1LDExLjY1ODI5MTIgMTQuODUzNTUzNCwxMS44NTM1NTM0IEwxMS44NTM1NTM0LDE0Ljg1MzU1MzQgQzExLjY1ODI5MTIsMTUuMDQ4ODE1NSAxMS4zNDE3MDg4LDE1LjA0ODgxNTUgMTEuMTQ2NDQ2NiwxNC44NTM1NTM0IEw4LjE0NjQ0NjYxLDExLjg1MzU1MzQgQzcuOTUxMTg0NDYsMTEuNjU4MjkxMiA3Ljk1MTE4NDQ2LDExLjM0MTcwODggOC4xNDY0NDY2MSwxMS4xNDY0NDY2IEM4LjM0MTcwODc2LDEwLjk1MTE4NDUgOC42NTgyOTEyNCwxMC45NTExODQ1IDguODUzNTUzMzksMTEuMTQ2NDQ2NiBMMTEsMTMuMjkyODkzMiBMMTEsNy41IEMxMSw3LjIyMzg1NzYzIDExLjIyMzg1NzYsNyAxMS41LDcgQzExLjc3NjE0MjQsNyAxMiw3LjIyMzg1NzYzIDEyLDcuNSBMMTIsMTMuMjkyODkzMiBaIE0xLjA4NTM1Mjg1LDExIEMxLjI5MTI3MTA2LDExLjU4MjU5NjIgMS44NDY4OTA1OSwxMiAyLjUsMTIgTDYuNSwxMiBDNi43NzYxNDIzNywxMiA3LDEyLjIyMzg1NzYgNywxMi41IEM3LDEyLjc3NjE0MjQgNi43NzYxNDIzNywxMyA2LjUsMTMgTDIuNSwxMyBDMS4xMTkyODgxMywxMyAxLjM4Nzc3ODc4ZS0xNiwxMS44ODA3MTE5IDAsMTAuNSBDMCwxMC4yMjM4NTc2IDAuMjIzODU3NjI1LDEwIDAuNSwxMCBMNi41LDEwIEM2Ljc3NjE0MjM3LDEwIDcsMTAuMjIzODU3NiA3LDEwLjUgQzcsMTAuNzc2MTQyNCA2Ljc3NjE0MjM3LDExIDYuNSwxMSBMMS4wODUzNTI4NSwxMSBaIE0yLDguNSBDMiw4Ljc3NjE0MjM3IDEuNzc2MTQyMzcsOSAxLjUsOSBDMS4yMjM4NTc2Myw5IDEsOC43NzYxNDIzNyAxLDguNSBMMSwyLjUgQzEsMS42NzE1NzI4OCAxLjY3MTU3Mjg4LDEgMi41LDEgTDEzLjUsMSBDMTQuMzI4NDI3MSwxIDE1LDEuNjcxNTcyODggMTUsMi41IEwxNSw4LjUgQzE1LDguNzc2MTQyMzcgMTQuNzc2MTQyNCw5IDE0LjUsOSBDMTQuMjIzODU3Niw5IDE0LDguNzc2MTQyMzcgMTQsOC41IEwxNCwyLjUgQzE0LDIuMjIzODU3NjMgMTMuNzc2MTQyNCwyIDEzLjUsMiBMMi41LDIgQzIuMjIzODU3NjMsMiAyLDIuMjIzODU3NjMgMiwyLjUgTDIsOC41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); | ||||
|   --icon-DragDrop: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYuNSwzIEM2Ljc3NjE0MjM3LDMgNywzLjIyMzg1NzYzIDcsMy41IEw3LDEyLjUgQzcsMTIuNzc2MTQyNCA2Ljc3NjE0MjM3LDEzIDYuNSwxMyBDNi4yMjM4NTc2MywxMyA2LDEyLjc3NjE0MjQgNiwxMi41IEw2LDMuNSBDNiwzLjIyMzg1NzYzIDYuMjIzODU3NjMsMyA2LjUsMyBaIE05LjUsMyBDOS43NzYxNDIzNywzIDEwLDMuMjIzODU3NjMgMTAsMy41IEwxMCwxMi41IEMxMCwxMi43NzYxNDI0IDkuNzc2MTQyMzcsMTMgOS41LDEzIEM5LjIyMzg1NzYzLDEzIDksMTIuNzc2MTQyNCA5LDEyLjUgTDksMy41IEM5LDMuMjIzODU3NjMgOS4yMjM4NTc2MywzIDkuNSwzIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); | ||||
| @ -95,6 +97,7 @@ | ||||
|   --icon-Message: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE1LjUgN0MxNS41IDMuNDEgMTIuMTQyIDAuNSA4IDAuNUMzLjg1OCAwLjUgMC41IDMuNDEgMC41IDdDMC41IDEwLjU5IDMuODU4IDEzLjUgOCAxMy41QzguNTI1IDEzLjUgOS4wMzcgMTMuNDUyIDkuNTMyIDEzLjM2M0wxMy41IDE1LjVWMTEuNDA5QzE0LjczOCAxMC4yNSAxNS41IDguNzA0IDE1LjUgN1oiIHN0cm9rZT0iIzkyOTI5OSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4='); | ||||
|   --icon-Minimize: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzI0MF83MjAwKSIgc3Ryb2tlPSIjZmZmIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xNC41IDYuNUg5LjVWMS41TTE1LjUuNUw5LjUgNi41TTYuNSAxNC41VjkuNUgxLjVNLjUgMTUuNUw2LjUgOS41Ii8+PC9nPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDBfMjQwXzcyMDAiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0wIDBIMTZWMTZIMHoiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4='); | ||||
|   --icon-Minus: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB4PSIyIiB5PSI3LjUiIHdpZHRoPSIxMiIgaGVpZ2h0PSIxIiByeD0iLjUiLz48L3N2Zz4='); | ||||
|   --icon-Mobile: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzEwNjFfNzU5OCkiPjxwYXRoIGQ9Ik0xMiAwSDRDMy40Njk1NyAwIDIuOTYwODYgMC4yMTA3MTQgMi41ODU3OSAwLjU4NTc4NkMyLjIxMDcxIDAuOTYwODU5IDIgMS40Njk1NyAyIDJWMTRDMiAxNC41MzA0IDIuMjEwNzEgMTUuMDM5MSAyLjU4NTc5IDE1LjQxNDJDMi45NjA4NiAxNS43ODkzIDMuNDY5NTcgMTYgNCAxNkgxMkMxMi41MzA0IDE2IDEzLjAzOTEgMTUuNzg5MyAxMy40MTQyIDE1LjQxNDJDMTMuNzg5MyAxNS4wMzkxIDE0IDE0LjUzMDQgMTQgMTRWMkMxNCAxLjQ2OTU3IDEzLjc4OTMgMC45NjA4NTkgMTMuNDE0MiAwLjU4NTc4NkMxMy4wMzkxIDAuMjEwNzE0IDEyLjUzMDQgMCAxMiAwWk00IDEzVjNIMTJWMTNINFoiIGZpbGw9IiMwMDAiLz48L2c+PGRlZnM+PGNsaXBQYXRoIGlkPSJjbGlwMF8xMDYxXzc1OTgiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0wIDBIMTZWMTZIMHoiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4='); | ||||
|   --icon-MobileChat: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjUgOC41VjE0LjVDMTEuNSAxNC43NjUyIDExLjM5NDYgMTUuMDE5NiAxMS4yMDcxIDE1LjIwNzFDMTEuMDE5NiAxNS4zOTQ2IDEwLjc2NTIgMTUuNSAxMC41IDE1LjVIMi41QzIuMjM0NzggMTUuNSAxLjk4MDQzIDE1LjM5NDYgMS43OTI4OSAxNS4yMDcxQzEuNjA1MzYgMTUuMDE5NiAxLjUgMTQuNzY1MiAxLjUgMTQuNVYyLjVDMS41IDIuMjM0NzggMS42MDUzNiAxLjk4MDQzIDEuNzkyODkgMS43OTI4OUMxLjk4MDQzIDEuNjA1MzYgMi4yMzQ3OCAxLjUgMi41IDEuNUg0LjUiIHN0cm9rZT0iIzI2MjYzMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTE0LjUgNi41SDkuNUw2LjUgOC41VjEuNUM2LjUgMS4yMzQ3OCA2LjYwNTM2IDAuOTgwNDMgNi43OTI4OSAwLjc5Mjg5M0M2Ljk4MDQzIDAuNjA1MzU3IDcuMjM0NzggMC41IDcuNSAwLjVIMTQuNUMxNC43NjUyIDAuNSAxNS4wMTk2IDAuNjA1MzU3IDE1LjIwNzEgMC43OTI4OTNDMTUuMzk0NiAwLjk4MDQzIDE1LjUgMS4yMzQ3OCAxNS41IDEuNVY1LjVDMTUuNSA1Ljc2NTIyIDE1LjM5NDYgNi4wMTk1NyAxNS4yMDcxIDYuMjA3MTFDMTUuMDE5NiA2LjM5NDY0IDE0Ljc2NTIgNi41IDE0LjUgNi41WiIgc3Ryb2tlPSIjMjYyNjMzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4='); | ||||
|   --icon-MobileChat2: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTggMEgxNUMxNS4yNjUyIDAgMTUuNTE5NiAwLjEwNTM1NyAxNS43MDcxIDAuMjkyODkzQzE1Ljg5NDYgMC40ODA0MyAxNiAwLjczNDc4NCAxNiAxVjVDMTYgNS4yNjUyMiAxNS44OTQ2IDUuNTE5NTcgMTUuNzA3MSA1LjcwNzExQzE1LjUxOTYgNS44OTQ2NCAxNS4yNjUyIDYgMTUgNkgxMEw3IDhWMUM3IDAuNzM0Nzg0IDcuMTA1MzYgMC40ODA0MyA3LjI5Mjg5IDAuMjkyODkzQzcuNDgwNDMgMC4xMDUzNTcgNy43MzQ3OCAwIDggMFYwWiIgZmlsbD0iIzE2QjM3OCIvPjxwYXRoIGQ9Ik0xMCA3VjEzSDNWNEg2VjFIM0MyLjQ2OTU3IDEgMS45NjA4NiAxLjIxMDcxIDEuNTg1NzkgMS41ODU3OUMxLjIxMDcxIDEuOTYwODYgMSAyLjQ2OTU3IDEgM1YxNEMxIDE0LjUzMDQgMS4yMTA3MSAxNS4wMzkxIDEuNTg1NzkgMTUuNDE0MkMxLjk2MDg2IDE1Ljc4OTMgMi40Njk1NyAxNiAzIDE2SDEwQzEwLjUzMDQgMTYgMTEuMDM5MSAxNS43ODkzIDExLjQxNDIgMTUuNDE0MkMxMS43ODkzIDE1LjAzOTEgMTIgMTQuNTMwNCAxMiAxNFY3SDEwWiIgZmlsbD0iIzE2QjM3OCIvPjwvc3ZnPg=='); | ||||
|   --icon-NewNotification: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iIGN4PSI4IiBjeT0iOCIgcj0iMyIvPjwvc3ZnPg=='); | ||||
| @ -103,6 +106,7 @@ | ||||
|   --icon-Page: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIsMSBMMiwxNSBMMTQsMTUgTDE0LDEgTDIsMSBaIE0xLjUsMCBMMTQuNSwwIEMxNC43NzYxNDI0LDAgMTUsMC4yMjM4NTc2MjUgMTUsMC41IEwxNSwxNS41IEMxNSwxNS43NzYxNDI0IDE0Ljc3NjE0MjQsMTYgMTQuNSwxNiBMMS41LDE2IEMxLjIyMzg1NzYzLDE2IDEsMTUuNzc2MTQyNCAxLDE1LjUgTDEsMC41IEMxLDAuMjIzODU3NjI1IDEuMjIzODU3NjMsMCAxLjUsMCBaIE05LjUsNCBDOS4yMjM4NTc2Myw0IDksMy43NzYxNDIzNyA5LDMuNSBDOSwzLjIyMzg1NzYzIDkuMjIzODU3NjMsMyA5LjUsMyBMMTEuNSwzIEMxMS43NzYxNDI0LDMgMTIsMy4yMjM4NTc2MyAxMiwzLjUgQzEyLDMuNzc2MTQyMzcgMTEuNzc2MTQyNCw0IDExLjUsNCBMOS41LDQgWiBNOS41LDcgQzkuMjIzODU3NjMsNyA5LDYuNzc2MTQyMzcgOSw2LjUgQzksNi4yMjM4NTc2MyA5LjIyMzg1NzYzLDYgOS41LDYgTDExLjUsNiBDMTEuNzc2MTQyNCw2IDEyLDYuMjIzODU3NjMgMTIsNi41IEMxMiw2Ljc3NjE0MjM3IDExLjc3NjE0MjQsNyAxMS41LDcgTDkuNSw3IFogTTQuNSwxMCBDNC4yMjM4NTc2MywxMCA0LDkuNzc2MTQyMzcgNCw5LjUgQzQsOS4yMjM4NTc2MyA0LjIyMzg1NzYzLDkgNC41LDkgTDExLjUsOSBDMTEuNzc2MTQyNCw5IDEyLDkuMjIzODU3NjMgMTIsOS41IEMxMiw5Ljc3NjE0MjM3IDExLjc3NjE0MjQsMTAgMTEuNSwxMCBMNC41LDEwIFogTTQuNSwxMyBDNC4yMjM4NTc2MywxMyA0LDEyLjc3NjE0MjQgNCwxMi41IEM0LDEyLjIyMzg1NzYgNC4yMjM4NTc2MywxMiA0LjUsMTIgTDExLjUsMTIgQzExLjc3NjE0MjQsMTIgMTIsMTIuMjIzODU3NiAxMiwxMi41IEMxMiwxMi43NzYxNDI0IDExLjc3NjE0MjQsMTMgMTEuNSwxMyBMNC41LDEzIFogTTUsNCBMNSw2IEw3LDYgTDcsNCBMNSw0IFogTTQuNSwzIEw3LjUsMyBDNy43NzYxNDIzNywzIDgsMy4yMjM4NTc2MyA4LDMuNSBMOCw2LjUgQzgsNi43NzYxNDIzNyA3Ljc3NjE0MjM3LDcgNy41LDcgTDQuNSw3IEM0LjIyMzg1NzYzLDcgNCw2Ljc3NjE0MjM3IDQsNi41IEw0LDMuNSBDNCwzLjIyMzg1NzYzIDQuMjIzODU3NjMsMyA0LjUsMyBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4='); | ||||
|   --icon-PanelLeft: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuNzA3MTA2NzgsOC41IEw1Ljg1MzU1MzM5LDEwLjY0NjQ0NjYgQzYuMDQ4ODE1NTQsMTAuODQxNzA4OCA2LjA0ODgxNTU0LDExLjE1ODI5MTIgNS44NTM1NTMzOSwxMS4zNTM1NTM0IEM1LjY1ODI5MTI0LDExLjU0ODgxNTUgNS4zNDE3MDg3NiwxMS41NDg4MTU1IDUuMTQ2NDQ2NjEsMTEuMzUzNTUzNCBMMi4xNDY0NDY2MSw4LjM1MzU1MzM5IEMxLjk1MTE4NDQ2LDguMTU4MjkxMjQgMS45NTExODQ0Niw3Ljg0MTcwODc2IDIuMTQ2NDQ2NjEsNy42NDY0NDY2MSBMNS4xNDY0NDY2MSw0LjY0NjQ0NjYxIEM1LjM0MTcwODc2LDQuNDUxMTg0NDYgNS42NTgyOTEyNCw0LjQ1MTE4NDQ2IDUuODUzNTUzMzksNC42NDY0NDY2MSBDNi4wNDg4MTU1NCw0Ljg0MTcwODc2IDYuMDQ4ODE1NTQsNS4xNTgyOTEyNCA1Ljg1MzU1MzM5LDUuMzUzNTUzMzkgTDMuNzA3MTA2NzgsNy41IEw4LjUsNy41IEM4Ljc3NjE0MjM3LDcuNSA5LDcuNzIzODU3NjMgOSw4IEM5LDguMjc2MTQyMzcgOC43NzYxNDIzNyw4LjUgOC41LDguNSBMMy43MDcxMDY3OCw4LjUgWiBNMTAsMTMuNSBMMTAsMi41IEMxMCwyLjIyMzg1NzYzIDEwLjIyMzg1NzYsMiAxMC41LDIgTDEzLjUsMiBDMTMuNzc2MTQyNCwyIDE0LDIuMjIzODU3NjMgMTQsMi41IEwxNCwxMy41IEMxNCwxMy43NzYxNDI0IDEzLjc3NjE0MjQsMTQgMTMuNSwxNCBMMTAuNSwxNCBDMTAuMjIzODU3NiwxNCAxMCwxMy43NzYxNDI0IDEwLDEzLjUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); | ||||
|   --icon-PanelRight: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuNzA3MTA2NzgsOC41IEw1Ljg1MzU1MzM5LDEwLjY0NjQ0NjYgQzYuMDQ4ODE1NTQsMTAuODQxNzA4OCA2LjA0ODgxNTU0LDExLjE1ODI5MTIgNS44NTM1NTMzOSwxMS4zNTM1NTM0IEM1LjY1ODI5MTI0LDExLjU0ODgxNTUgNS4zNDE3MDg3NiwxMS41NDg4MTU1IDUuMTQ2NDQ2NjEsMTEuMzUzNTUzNCBMMi4xNDY0NDY2MSw4LjM1MzU1MzM5IEMxLjk1MTE4NDQ2LDguMTU4MjkxMjQgMS45NTExODQ0Niw3Ljg0MTcwODc2IDIuMTQ2NDQ2NjEsNy42NDY0NDY2MSBMNS4xNDY0NDY2MSw0LjY0NjQ0NjYxIEM1LjM0MTcwODc2LDQuNDUxMTg0NDYgNS42NTgyOTEyNCw0LjQ1MTE4NDQ2IDUuODUzNTUzMzksNC42NDY0NDY2MSBDNi4wNDg4MTU1NCw0Ljg0MTcwODc2IDYuMDQ4ODE1NTQsNS4xNTgyOTEyNCA1Ljg1MzU1MzM5LDUuMzUzNTUzMzkgTDMuNzA3MTA2NzgsNy41IEw4LjUsNy41IEM4Ljc3NjE0MjM3LDcuNSA5LDcuNzIzODU3NjMgOSw4IEM5LDguMjc2MTQyMzcgOC43NzYxNDIzNyw4LjUgOC41LDguNSBMMy43MDcxMDY3OCw4LjUgWiBNMTAsMTMuNSBMMTAsMi41IEMxMCwyLjIyMzg1NzYzIDEwLjIyMzg1NzYsMiAxMC41LDIgTDEzLjUsMiBDMTMuNzc2MTQyNCwyIDE0LDIuMjIzODU3NjMgMTQsMi41IEwxNCwxMy41IEMxNCwxMy43NzYxNDI0IDEzLjc3NjE0MjQsMTQgMTMuNSwxNCBMMTAuNSwxNCBDMTAuMjIzODU3NiwxNCAxMCwxMy43NzYxNDI0IDEwLDEzLjUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB0cmFuc2Zvcm09Im1hdHJpeCgtMSAwIDAgMSAxNiAwKSIvPjwvc3ZnPg=='); | ||||
|   --icon-Paragraph: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzEwNjFfNzU4MikiPjxwYXRoIGQ9Ik01LjkyMzA4IDIuNDYxNTRWNy4zODQ2MkM0LjU2OTIzIDcuMzg0NjIgMy40NjE1NCA2LjI3NjkyIDMuNDYxNTQgNC45MjMwOEMzLjQ2MTU0IDMuNTY5MjMgNC41NjkyMyAyLjQ2MTU0IDUuOTIzMDggMi40NjE1NFpNMTQgMEg1LjkyMzA4QzMuMjAzMDggMCAxIDIuMjAzMDggMSA0LjkyMzA4QzEgNy42NDMwOCAzLjIwMzA4IDkuODQ2MTUgNS45MjMwOCA5Ljg0NjE1VjE2SDcuNVYyLjQ2MTU0SDkuNTk2MTVWMTZIMTEuMjVMMTEuMjUgMi40NjE1NEgxNFYwWiIgZmlsbD0iIzAwMCIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImNsaXAwXzEwNjFfNzU4MiI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTAgMEgxNlYxNkgweiIvPjwvY2xpcFBhdGg+PC9kZWZzPjwvc3ZnPg=='); | ||||
|   --icon-Pencil: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9Ii0yIC0yIDIwIDIwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxnIGNsYXNzPSJuYy1pY29uLXdyYXBwZXIiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzIxMjEyMSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xMyAuNUwxNS41IDMgNy41IDExIDQgMTIgNSA4LjV6TTExIDIuNUwxMy41IDUiLz48cGF0aCBkPSJNMTMuNSw5LjV2NSBjMCwwLjU1Mi0wLjQ0OCwxLTEsMWgtMTFjLTAuNTUyLDAtMS0wLjQ0OC0xLTF2LTExYzAtMC41NTIsMC40NDgtMSwxLTFoNSIvPjwvZz48L3N2Zz4='); | ||||
|   --icon-PinBig: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjgyOTcyODEsLTcuNjAyODA3MjdlLTEzIEw0LjE5Mjk3NDkxLC03LjU3NjE2MTkyZS0xMyBDMy42MjcyODk0OCwtNy41MzE3NTNlLTEzIDMuMjAzMDI1NDEsMC40MTAyNTY0MSAzLjIwMzAyNTQxLDAuOTU3MjY0OTU3IEMzLjIwMzAyNTQxLDEuNTA0MjczNSAzLjYyNzI4OTQ4LDEuOTE0NTI5OTEgNC4xOTI5NzQ5MSwxLjkxNDUyOTkxIEw1LjA0MTUwMzA0LDEuOTE0NTI5OTEgTDIuODQ5NDcyMDIsOS4wOTQwMTcwOSBMMS40MzUyNTg0Niw5LjA5NDAxNzA5IEMwLjg2OTU3MzAzNiw5LjA5NDAxNzA5IDAuNDQ1MzA4OTY3LDkuNTA0MjczNSAwLjQ0NTMwODk2NywxMC4wNTEyODIxIEMwLjQ0NTMwODk2NywxMC41OTgyOTA2IDAuODY5NTczMDM2LDExLjAwODU0NyAxLjQzNTI1ODQ2LDExLjAwODU0NyBMMy41NTY1Nzg4LDExLjAwODU0NyBMNi45NTA2OTEzNSwxMS4wMDg1NDcgTDcuMDIxNDAyMDMsMTYgTDkuMDAxMzAxMDIsMTYgTDkuMDAxMzAxMDIsMTAuOTQwMTcwOSBMMTQuNjU4MTU1MywxMC45NDAxNzA5IEMxNS4zNjUyNjIxLDEwLjk0MDE3MDkgMTUuNjQ4MTA0OCwxMC4yNTY0MTAzIDE1LjY0ODEwNDgsOS45ODI5MDU5OCBDMTUuNjQ4MTA0OCw5LjQzNTg5NzQ0IDE1LjIyMzg0MDcsOS4wMjU2NDEwMyAxNC42NTgxNTUzLDkuMDI1NjQxMDMgTDEzLjI0Mzk0MTcsOS4wMjU2NDEwMyBMMTEuMTIyNjIxNCwxLjkxNDUyOTkxIEwxMS45NzExNDk1LDEuOTE0NTI5OTEgQzEyLjgxOTY3NzYsMS45MTQ1Mjk5MSAxMi45NjEwOTksMS4yMzA3NjkyMyAxMi45NjEwOTksMC45NTcyNjQ5NTcgQzEyLjg5MDM4ODMsMC40Nzg2MzI0NzkgMTIuMzk1NDEzNiwtNy42NjQ5Nzk3NmUtMTMgMTEuODI5NzI4MSwtNy42MDI4MDcyN2UtMTMgWiBNMTEuMDUxOTEwNyw5LjA5NDAxNzA5IEw0Ljk3MDc5MjM3LDkuMDk0MDE3MDkgTDcuMDkyMTEyNzEsMS45ODI5MDU5OCBMOC45MzA1OTAzNCwxLjk4MjkwNTk4IEwxMS4wNTE5MTA3LDkuMDk0MDE3MDkgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); | ||||
|   --icon-PinSmall: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuNSwxMSBMMi41LDExIEMyLjIyMzg1NzYzLDExIDIsMTAuNzc2MTQyNCAyLDEwLjUgQzIsMTAuMjIzODU3NiAyLjIyMzg1NzYzLDEwIDIuNSwxMCBMNC4yNSwxMCBMNS43NSw0IEw0LjUsNCBDNC4yMjM4NTc2Myw0IDQsMy43NzYxNDIzNyA0LDMuNSBDNCwzLjIyMzg1NzYzIDQuMjIzODU3NjMsMyA0LjUsMyBMMTEuNSwzIEMxMS43NzYxNDI0LDMgMTIsMy4yMjM4NTc2MyAxMiwzLjUgQzEyLDMuNzc2MTQyMzcgMTEuNzc2MTQyNCw0IDExLjUsNCBMMTAuMjUsNCBMMTEuNzUsMTAgTDEzLjUsMTAgQzEzLjc3NjE0MjQsMTAgMTQsMTAuMjIzODU3NiAxNCwxMC41IEMxNCwxMC43NzYxNDI0IDEzLjc3NjE0MjQsMTEgMTMuNSwxMSBMOC41LDExIEw4LjUsMTQgTDcuNSwxNCBMNy41LDExIFogTTUuMjgwNzc2NDEsMTAgTDEwLjcxOTIyMzYsMTAgTDkuMjE5MjIzNTksNCBMNi43ODA3NzY0MSw0IEw1LjI4MDc3NjQxLDEwIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); | ||||
| @ -124,6 +128,8 @@ | ||||
|   --icon-Robot: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE2IDJWMEgxMFYySDEyVjRINFYySDZWMEgwVjJIMlY0SDBWMTZIMTZWNEgxNFYySDE2Wk0xMCA3QzEwLjYgNyAxMSA3LjQgMTEgOEMxMSA4LjYgMTAuNiA5IDEwIDlDOS40IDkgOSA4LjYgOSA4QzkgNy40IDkuNCA3IDEwIDdaTTYgN0M2LjYgNyA3IDcuNCA3IDhDNyA4LjYgNi42IDkgNiA5QzUuNCA5IDUgOC42IDUgOEM1IDcuNCA1LjQgNyA2IDdaTTEyIDEzSDRWMTFIMTJWMTNaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4='); | ||||
|   --icon-Script: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0iZmVhdGhlciBmZWF0aGVyLWRhdGFiYXNlIj48cmVjdCB3aWR0aD0iMTkuMzI4IiBoZWlnaHQ9IjE5LjMyOCIgeD0iMi4zMzYiIHk9IjIuMzM2IiByeT0iNC4wMjYiIHJ4PSIzLjc0NSIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJidXR0Ii8+PGcgc3Ryb2tlLXdpZHRoPSIxLjU0MyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0iYnV0dCIgc3Ryb2tlLWxpbmVqb2luPSJtaXRlciI+PHBhdGggZD0iTTE4LjczNDQ0OSA5LjAyMTI1NTZMNS4yNjU1NTEyIDkuMDA5NTM2OE0xOC43MzQ0NDkgMTQuNzM4NTg2TDUuMjY1NTUxMiAxNC43MjY4NjciIHRyYW5zZm9ybT0ibWF0cml4KC45Mzk2OSAwIDAgMS4wMDUzMSAuNzI0IC0uMDYzKSIvPjwvZz48L3N2Zz4='); | ||||
|   --icon-Search: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjQzNjIxMjcsMTAuNzI5MTA1OSBMMTQuODUzNTUzNCwxNC4xNDY0NDY2IEMxNS4wNDg4MTU1LDE0LjM0MTcwODggMTUuMDQ4ODE1NSwxNC42NTgyOTEyIDE0Ljg1MzU1MzQsMTQuODUzNTUzNCBDMTQuNjU4MjkxMiwxNS4wNDg4MTU1IDE0LjM0MTcwODgsMTUuMDQ4ODE1NSAxNC4xNDY0NDY2LDE0Ljg1MzU1MzQgTDEwLjcyOTEwNTksMTEuNDM2MjEyNyBDOS41OTIzMzg0OCwxMi40MTEwNDg3IDguMTE0OTQ3NzEsMTMgNi41LDEzIEMyLjkxMDE0OTEzLDEzIDAsMTAuMDg5ODUwOSAwLDYuNSBDMCwyLjkxMDE0OTEzIDIuOTEwMTQ5MTMsMCA2LjUsMCBDMTAuMDg5ODUwOSwwIDEzLDIuOTEwMTQ5MTMgMTMsNi41IEMxMyw4LjExNDk0NzcxIDEyLjQxMTA0ODcsOS41OTIzMzg0OCAxMS40MzYyMTI3LDEwLjcyOTEwNTkgWiBNMTAuNDA5NTc0NywxMC4zNjg0OTIxIEMxMS4zOTI4MzI1LDkuMzc0ODU3OCAxMiw4LjAwODMzNDY4IDEyLDYuNSBDMTIsMy40NjI0MzM4OCA5LjUzNzU2NjEyLDEgNi41LDEgQzMuNDYyNDMzODgsMSAxLDMuNDYyNDMzODggMSw2LjUgQzEsOS41Mzc1NjYxMiAzLjQ2MjQzMzg4LDEyIDYuNSwxMiBDOC4wMDgzMzQ2OCwxMiA5LjM3NDg1NzgsMTEuMzkyODMyNSAxMC4zNjg0OTIxLDEwLjQwOTU3NDcgQzEwLjM3NDkwMDEsMTAuNDAyMzg3OSAxMC4zODE1NTE2LDEwLjM5NTM0MTYgMTAuMzg4NDQ2NiwxMC4zODg0NDY2IEMxMC4zOTUzNDE2LDEwLjM4MTU1MTYgMTAuNDAyMzg3OSwxMC4zNzQ5MDAxIDEwLjQwOTU3NDcsMTAuMzY4NDkyMSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4='); | ||||
|   --icon-Section: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzEwNjFfNzU5MykiIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjMDAwIj48cGF0aCBkPSJNMi4xMDUyNiA0LjIxMDk0SDEzLjg5NDdDMTUuMDU3NCA0LjIxMDk0IDE2IDUuMTUzNSAxNiA2LjMxNjJWOS42ODQ2MkMxNiAxMC44NDczIDE1LjA1NzQgMTEuNzg5OSAxMy44OTQ3IDExLjc4OTlIMi4xMDUyNkMuOTQyNTU4IDExLjc4OTkgMCAxMC44NDczIDAgOS42ODQ2MlY2LjMxNjJDMCA1LjE1MzUuOTQyNTU4IDQuMjEwOTQgMi4xMDUyNiA0LjIxMDk0ek0yLjEwNTI2IDUuNDc0MUMxLjY0MDE4IDUuNDc0MSAxLjI2MzE2IDUuODUxMTIgMS4yNjMxNiA2LjMxNjJWOS42ODQ2MkMxLjI2MzE2IDEwLjE0OTcgMS42NDAxOCAxMC41MjY3IDIuMTA1MjYgMTAuNTI2N0gxMy44OTQ3QzE0LjM1OTggMTAuNTI2NyAxNC43MzY4IDEwLjE0OTcgMTQuNzM2OCA5LjY4NDYyVjYuMzE2MkMxNC43MzY4IDUuODUxMTIgMTQuMzU5OCA1LjQ3NDEgMTMuODk0NyA1LjQ3NDFIMi4xMDUyNnpNMS4yNjMxNiAxNi4wMDAxSDBWMTUuMTU4TC4wMDAwNDMwNjU5IDE1LjE0NDRDLjAwNzM2NzA4IDEzLjk4NzkuOTQ3MSAxMy4wNTI3IDIuMTA1MjYgMTMuMDUyN0gzLjU3ODk1VjE0LjMxNTlIMi4xMDUyNkMxLjY0MDE4IDE0LjMxNTkgMS4yNjMxNiAxNC42OTI5IDEuMjYzMTYgMTUuMTU4VjE2LjAwMDF6TTkuNDczNjggMTQuMzE1OUg2LjUyNjMyVjEzLjA1MjdIOS40NzM2OFYxNC4zMTU5ek0xNC43MzY4IDE2LjAwMDFWMTUuMTU4QzE0LjczNjggMTQuNjkyOSAxNC4zNTk4IDE0LjMxNTkgMTMuODk0NyAxNC4zMTU5SDEyLjQyMTFWMTMuMDUyN0gxMy44OTQ3QzE1LjA1NzQgMTMuMDUyNyAxNiAxMy45OTUzIDE2IDE1LjE1OFYxNi4wMDAxSDE0LjczNjh6TTE0LjczNjgtLjAwMDEwMjgxOEgxNlYuODQyMDAyTDE2IC44NTU2MThDMTUuOTkyNiAyLjAxMjA2IDE1LjA1MjkgMi45NDcyNyAxMy44OTQ3IDIuOTQ3MjdIMTIuNDIxMVYxLjY4NDExTDEzLjg5NDcgMS42ODQxMUMxNC4zNTk4IDEuNjg0MTEgMTQuNzM2OCAxLjMwNzA4IDE0LjczNjguODQyMDAyVi0uMDAwMTAyODE4ek02LjUyNjMyIDEuNjg0MTFMOS40NzM2OCAxLjY4NDExVjIuOTQ3MjdMNi41MjYzMiAyLjk0NzI3VjEuNjg0MTF6TTEuMjYzMTYtLjAwMDEwMjgxOFYuODQyMDAyQzEuMjYzMTYgMS4zMDcwOCAxLjY0MDE4IDEuNjg0MTEgMi4xMDUyNiAxLjY4NDExTDMuNTc4OTUgMS42ODQxMVYyLjk0NzI3TDIuMTA1MjYgMi45NDcyN0MuOTQyNTU4IDIuOTQ3MjctMS4xOTIwOWUtNyAyLjAwNDcxLTEuMTkyMDllLTcuODQyMDAyVi0uMDAwMTAyODE4SDEuMjYzMTZ6Ii8+PC9nPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDBfMTA2MV83NTkzIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDE2VjE2SDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PC9zdmc+'); | ||||
|   --icon-Separator: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzEwNjFfNzU4OCkiIGZpbGw9IiMwMDAiPjxwYXRoIGQ9Ik0xLjMzMyA3SDE0LjY2NlY4LjMzM0gxLjMzM3pNMCA1SDEuMzMzVjEwLjMzM0gwek0xNC42NjcgNUgxNlYxMC4zMzNIMTQuNjY3eiIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImNsaXAwXzEwNjFfNzU4OCI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTAgMEgxNlYxNkgweiIvPjwvY2xpcFBhdGg+PC9kZWZzPjwvc3ZnPg=='); | ||||
|   --icon-Settings: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuOTgwNDcxOSwzLjI3MzM2NTEyIEM0LjgxNDE0NDY1LDIuNTc4NDA2ODQgNS44NTY2MjI5MSwyLjEyNTQ1Njc0IDcsMi4wMjI0MjE1MSBMNywwLjUgQzcsMC4yMjM4NTc2MjUgNy4yMjM4NTc2MywwIDcuNSwwIEM3Ljc3NjE0MjM3LDAgOCwwLjIyMzg1NzYyNSA4LDAuNSBMOCwyLjAyMjQyMTUxIEM5LjE0MzM3NzA5LDIuMTI1NDU2NzQgMTAuMTg1ODU1NCwyLjU3ODQwNjg0IDExLjAxOTUyODEsMy4yNzMzNjUxMiBMMTIuMDk2NDQ2NiwyLjE5NjQ0NjYxIEMxMi4yOTE3MDg4LDIuMDAxMTg0NDYgMTIuNjA4MjkxMiwyLjAwMTE4NDQ2IDEyLjgwMzU1MzQsMi4xOTY0NDY2MSBDMTIuOTk4ODE1NSwyLjM5MTcwODc2IDEyLjk5ODgxNTUsMi43MDgyOTEyNCAxMi44MDM1NTM0LDIuOTAzNTUzMzkgTDExLjcyNjYzNDksMy45ODA0NzE5IEMxMi40MjE1OTMyLDQuODE0MTQ0NjUgMTIuODc0NTQzMyw1Ljg1NjYyMjkxIDEyLjk3NzU3ODUsNyBMMTQuNSw3IEMxNC43NzYxNDI0LDcgMTUsNy4yMjM4NTc2MyAxNSw3LjUgQzE1LDcuNzc2MTQyMzcgMTQuNzc2MTQyNCw4IDE0LjUsOCBMMTIuOTc3NTc4NSw4IEMxMi44NzQ1NDMzLDkuMTQzMzc3MDkgMTIuNDIxNTkzMiwxMC4xODU4NTU0IDExLjcyNjYzNDksMTEuMDE5NTI4MSBMMTIuODAzNTUzNCwxMi4wOTY0NDY2IEMxMi45OTg4MTU1LDEyLjI5MTcwODggMTIuOTk4ODE1NSwxMi42MDgyOTEyIDEyLjgwMzU1MzQsMTIuODAzNTUzNCBDMTIuNjA4MjkxMiwxMi45OTg4MTU1IDEyLjI5MTcwODgsMTIuOTk4ODE1NSAxMi4wOTY0NDY2LDEyLjgwMzU1MzQgTDExLjAxOTUyODEsMTEuNzI2NjM0OSBDMTAuMTg1ODU1NCwxMi40MjE1OTMyIDkuMTQzMzc3MDksMTIuODc0NTQzMyA4LDEyLjk3NzU3ODUgTDgsMTQuNSBDOCwxNC43NzYxNDI0IDcuNzc2MTQyMzcsMTUgNy41LDE1IEM3LjIyMzg1NzYzLDE1IDcsMTQuNzc2MTQyNCA3LDE0LjUgTDcsMTIuOTc3NTc4NSBDNS44NTY2MjI5MSwxMi44NzQ1NDMzIDQuODE0MTQ0NjUsMTIuNDIxNTkzMiAzLjk4MDQ3MTksMTEuNzI2NjM0OSBMMi45MDM1NTMzOSwxMi44MDM1NTM0IEMyLjcwODI5MTI0LDEyLjk5ODgxNTUgMi4zOTE3MDg3NiwxMi45OTg4MTU1IDIuMTk2NDQ2NjEsMTIuODAzNTUzNCBDMi4wMDExODQ0NiwxMi42MDgyOTEyIDIuMDAxMTg0NDYsMTIuMjkxNzA4OCAyLjE5NjQ0NjYxLDEyLjA5NjQ0NjYgTDMuMjczMzY1MTIsMTEuMDE5NTI4MSBDMi41Nzg0MDY4NCwxMC4xODU4NTU0IDIuMTI1NDU2NzQsOS4xNDMzNzcwOSAyLjAyMjQyMTUxLDggTDAuNSw4IEMwLjIyMzg1NzYyNSw4IDAsNy43NzYxNDIzNyAwLDcuNSBDMCw3LjIyMzg1NzYzIDAuMjIzODU3NjI1LDcgMC41LDcgTDIuMDIyNDIxNTEsNyBDMi4xMjU0NTY3NCw1Ljg1NjYyMjkxIDIuNTc4NDA2ODQsNC44MTQxNDQ2NSAzLjI3MzM2NTEyLDMuOTgwNDcxOSBMMi4xOTY0NDY2MSwyLjkwMzU1MzM5IEMyLjAwMTE4NDQ2LDIuNzA4MjkxMjQgMi4wMDExODQ0NiwyLjM5MTcwODc2IDIuMTk2NDQ2NjEsMi4xOTY0NDY2MSBDMi4zOTE3MDg3NiwyLjAwMTE4NDQ2IDIuNzA4MjkxMjQsMi4wMDExODQ0NiAyLjkwMzU1MzM5LDIuMTk2NDQ2NjEgTDMuOTgwNDcxOSwzLjI3MzM2NTEyIFogTTcuNSwxMCBDNi4xMTkyODgxMywxMCA1LDguODgwNzExODcgNSw3LjUgQzUsNi4xMTkyODgxMyA2LjExOTI4ODEzLDUgNy41LDUgQzguODgwNzExODcsNSAxMCw2LjExOTI4ODEzIDEwLDcuNSBDMTAsOC44ODA3MTE4NyA4Ljg4MDcxMTg3LDEwIDcuNSwxMCBaIE03LjUsOSBDOC4zMjg0MjcxMiw5IDksOC4zMjg0MjcxMiA5LDcuNSBDOSw2LjY3MTU3Mjg4IDguMzI4NDI3MTIsNiA3LjUsNiBDNi42NzE1NzI4OCw2IDYsNi42NzE1NzI4OCA2LDcuNSBDNiw4LjMyODQyNzEyIDYuNjcxNTcyODgsOSA3LjUsOSBaIE03LjUsMTIgQzkuOTg1MjgxMzcsMTIgMTIsOS45ODUyODEzNyAxMiw3LjUgQzEyLDUuMDE0NzE4NjMgOS45ODUyODEzNywzIDcuNSwzIEM1LjAxNDcxODYzLDMgMyw1LjAxNDcxODYzIDMsNy41IEMzLDkuOTg1MjgxMzcgNS4wMTQ3MTg2MywxMiA3LjUsMTIgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); | ||||
|   --icon-Share: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYuNzc0MTEwMDUsOS4xNDQxMzAyOSBMOS43NTY4Nzg3NiwxMS4wMDc4NjUxIEMxMC4zMDY0MDY1LDEwLjM4OTU1MjMgMTEuMTA3NzEwOSwxMCAxMiwxMCBDMTMuNjU2ODU0MiwxMCAxNSwxMS4zNDMxNDU4IDE1LDEzIEMxNSwxNC42NTY4NTQyIDEzLjY1Njg1NDIsMTYgMTIsMTYgQzEwLjM0MzE0NTgsMTYgOSwxNC42NTY4NTQyIDksMTMgQzksMTIuNTk0NjU3MiA5LjA4MDM4OTUzLDEyLjIwODA5MDQgOS4yMjYwOTUwNywxMS44NTUzNzMgTDYuMjQzNDc4MjQsOS45OTE3MzMxNyBDNS42OTM5NDA1NSwxMC42MTAyNzkgNC44OTI0ODIzOSwxMSA0LDExIEMyLjM0MzE0NTc1LDExIDEsOS42NTY4NTQyNSAxLDggQzEsNi4zNDMxNDU3NSAyLjM0MzE0NTc1LDUgNCw1IEM0Ljg5MjQ4MjM5LDUgNS42OTM5NDA1NSw1LjM4OTcyMTAzIDYuMjQzNDc4MjQsNi4wMDgyNjY4MyBMOS4yMjYwOTUwNyw0LjE0NDYyNjk2IEM5LjA4MDM4OTUzLDMuNzkxOTA5NjMgOSwzLjQwNTM0MjggOSwzIEM5LDEuMzQzMTQ1NzUgMTAuMzQzMTQ1OCwwIDEyLDAgQzEzLjY1Njg1NDIsMCAxNSwxLjM0MzE0NTc1IDE1LDMgQzE1LDQuNjU2ODU0MjUgMTMuNjU2ODU0Miw2IDEyLDYgQzExLjEwNzcxMDksNiAxMC4zMDY0MDY1LDUuNjEwNDQ3NzMgOS43NTY4Nzg3Niw0Ljk5MjEzNDkzIEw2Ljc3NDExMDA1LDYuODU1ODY5NzEgQzYuOTE5Njg1OTIsNy4yMDg0NTMyNSA3LDcuNTk0ODQ3NDQgNyw4IEM3LDguNDA1MTUyNTYgNi45MTk2ODU5Miw4Ljc5MTU0Njc1IDYuNzc0MTEwMDUsOS4xNDQxMzAyOSBaIE01LjcwNDEyNDg1LDkuMDQ3NDE3MTYgQzUuODkxNzYwNjYsOC43NDI3ODczNCA2LDguMzg0MDM0IDYsOCBDNiw3LjYxNTk2NiA1Ljg5MTc2MDY2LDcuMjU3MjEyNjYgNS43MDQxMjQ4NSw2Ljk1MjU4Mjg0IEM1LjcwMTM1MzQ2LDYuOTQ4NDI1NzcgNS42OTg2MzQ0Miw2Ljk0NDIxNDMyIDUuNjk1OTY5MTgsNi45Mzk5NDg4IEM1LjY5MzQ2MjQ3LDYuOTM1OTM2OTkgNS42OTEwMTk1OSw2LjkzMTkwMzM4IDUuNjg4NjQwMjksNi45Mjc4NDkxNSBDNS4zMzM3NDk5MSw2LjM3MDA2MTY1IDQuNzEwMDkxMjIsNiA0LDYgQzIuODk1NDMwNSw2IDIsNi44OTU0MzA1IDIsOCBDMiw5LjEwNDU2OTUgMi44OTU0MzA1LDEwIDQsMTAgQzQuNzEwMDkxMjIsMTAgNS4zMzM3NDk5MSw5LjYyOTkzODM1IDUuNjg4NjQwMjksOS4wNzIxNTA4NSBDNS42OTEwMTk1OSw5LjA2ODA5NjYyIDUuNjkzNDYyNDcsOS4wNjQwNjMwMSA1LjY5NTk2OTE4LDkuMDYwMDUxMiBDNS42OTg2MzQ0Miw5LjA1NTc4NTY4IDUuNzAxMzUzNDYsOS4wNTE1NzQyMyA1LjcwNDEyNDg1LDkuMDQ3NDE3MTYgWiBNMTAuMzE5OTA5MiwxMS45MTQ1MjgzIEMxMC4zMTUyMjY4LDExLjkyMzA4OTcgMTAuMzEwMjY4MSwxMS45MzE1NjY5IDEwLjMwNTAzMDgsMTEuOTM5OTQ4OCBDMTAuMjk5NTk2LDExLjk0ODY0NjggMTAuMjkzOTM3NiwxMS45NTcxMTk5IDEwLjI4ODA2NzYsMTEuOTY1MzY1NSBDMTAuMTA1MjQsMTIuMjY3MjI4NiAxMCwxMi42MjEzMjQzIDEwLDEzIEMxMCwxNC4xMDQ1Njk1IDEwLjg5NTQzMDUsMTUgMTIsMTUgQzEzLjEwNDU2OTUsMTUgMTQsMTQuMTA0NTY5NSAxNCwxMyBDMTQsMTEuODk1NDMwNSAxMy4xMDQ1Njk1LDExIDEyLDExIEMxMS4yOTU1NzY3LDExIDEwLjY3NjIxMTcsMTEuMzY0MTc3NSAxMC4zMTk5MDkyLDExLjkxNDUyODMgWiBNMTAuMjg4MDY3Niw0LjAzNDYzNDU0IEMxMC4yOTM5Mzc2LDQuMDQyODgwMDkgMTAuMjk5NTk2LDQuMDUxMzUzMjUgMTAuMzA1MDMwOCw0LjA2MDA1MTIgQzEwLjMxMDI2ODEsNC4wNjg0MzMxNSAxMC4zMTUyMjY4LDQuMDc2OTEwMjUgMTAuMzE5OTA5Miw0LjA4NTQ3MTY4IEMxMC42NzYyMTE3LDQuNjM1ODIyNDUgMTEuMjk1NTc2Nyw1IDEyLDUgQzEzLjEwNDU2OTUsNSAxNCw0LjEwNDU2OTUgMTQsMyBDMTQsMS44OTU0MzA1IDEzLjEwNDU2OTUsMSAxMiwxIEMxMC44OTU0MzA1LDEgMTAsMS44OTU0MzA1IDEwLDMgQzEwLDMuMzc4Njc1NzQgMTAuMTA1MjQsMy43MzI3NzEzNyAxMC4yODgwNjc2LDQuMDM0NjM0NTQgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); | ||||
|   --icon-Sort: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuOTk5MjUsMy43MDY1NTM3NyBMMy45OTkyNSwxMy40OTkyNSBDMy45OTkyNSwxMy43NzUyNSAzLjc3NTI1LDEzLjk5OTI1IDMuNDk5MjUsMTMuOTk5MjUgQzMuMjIzMjUsMTMuOTk5MjUgMi45OTkyNSwxMy43NzUyNSAyLjk5OTI1LDEzLjQ5OTI1IEwyLjk5OTI1LDMuNzA2NTUzNzcgTDEuODUzMjUsNC44NTMyNSBDMS42NTgyNSw1LjA0ODI1IDEuMzQxMjUsNS4wNDgyNSAxLjE0NjI1LDQuODUzMjUgQzAuOTUxMjUsNC42NTgyNSAwLjk1MTI1LDQuMzQxMjUgMS4xNDYyNSw0LjE0NjI1IEwzLjE0NjI1LDIuMTQ2MjUgQzMuMzQxMjUsMS45NTEyNSAzLjY1ODI1LDEuOTUxMjUgMy44NTMyNSwyLjE0NjI1IEw1Ljg1MzI1LDQuMTQ2MjUgQzYuMDQ4MjUsNC4zNDEyNSA2LjA0ODI1LDQuNjU4MjUgNS44NTMyNSw0Ljg1MzI1IEM1Ljc1NTI1LDQuOTUwMjUgNS42MjcyNSw0Ljk5OTI1IDUuNDk5MjUsNC45OTkyNSBDNS4zNzEyNSw0Ljk5OTI1IDUuMjQzMjUsNC45NTAyNSA1LjE0NTI1LDQuODUzMjUgTDMuOTk5MjUsMy43MDY1NTM3NyBaIE04LjQ5OTI1LDMuOTk5MjUgTDcuNDk5MjUsMy45OTkyNSBDNy4yMjMyNSwzLjk5OTI1IDYuOTk5MjUsMy43NzUyNSA2Ljk5OTI1LDMuNDk5MjUgQzYuOTk5MjUsMy4yMjMyNSA3LjIyMzI1LDIuOTk5MjUgNy40OTkyNSwyLjk5OTI1IEw4LjQ5OTI1LDIuOTk5MjUgQzguNzc1MjUsMi45OTkyNSA4Ljk5OTI1LDMuMjIzMjUgOC45OTkyNSwzLjQ5OTI1IEM4Ljk5OTI1LDMuNzc1MjUgOC43NzUyNSwzLjk5OTI1IDguNDk5MjUsMy45OTkyNSBaIE0xMC40OTkyNSw2Ljk5OTI1IEw3LjQ5OTI1LDYuOTk5MjUgQzcuMjIzMjUsNi45OTkyNSA2Ljk5OTI1LDYuNzc1MjUgNi45OTkyNSw2LjQ5OTI1IEM2Ljk5OTI1LDYuMjIzMjUgNy4yMjMyNSw1Ljk5OTI1IDcuNDk5MjUsNS45OTkyNSBMMTAuNDk5MjUsNS45OTkyNSBDMTAuNzc1MjUsNS45OTkyNSAxMC45OTkyNSw2LjIyMzI1IDEwLjk5OTI1LDYuNDk5MjUgQzEwLjk5OTI1LDYuNzc1MjUgMTAuNzc1MjUsNi45OTkyNSAxMC40OTkyNSw2Ljk5OTI1IFogTTEyLjQ5OTI1LDkuOTk5MjUgTDcuNDk5MjUsOS45OTkyNSBDNy4yMjMyNSw5Ljk5OTI1IDYuOTk5MjUsOS43NzUyNSA2Ljk5OTI1LDkuNDk5MjUgQzYuOTk5MjUsOS4yMjMyNSA3LjIyMzI1LDguOTk5MjUgNy40OTkyNSw4Ljk5OTI1IEwxMi40OTkyNSw4Ljk5OTI1IEMxMi43NzUyNSw4Ljk5OTI1IDEyLjk5OTI1LDkuMjIzMjUgMTIuOTk5MjUsOS40OTkyNSBDMTIuOTk5MjUsOS43NzUyNSAxMi43NzUyNSw5Ljk5OTI1IDEyLjQ5OTI1LDkuOTk5MjUgWiBNMTQuNDk5MjUsMTIuOTk5MjUgTDcuNDk5MjUsMTIuOTk5MjUgQzcuMjIzMjUsMTIuOTk5MjUgNi45OTkyNSwxMi43NzUyNSA2Ljk5OTI1LDEyLjQ5OTI1IEM2Ljk5OTI1LDEyLjIyMzI1IDcuMjIzMjUsMTEuOTk5MjUgNy40OTkyNSwxMS45OTkyNSBMMTQuNDk5MjUsMTEuOTk5MjUgQzE0Ljc3NTI1LDExLjk5OTI1IDE0Ljk5OTI1LDEyLjIyMzI1IDE0Ljk5OTI1LDEyLjQ5OTI1IEMxNC45OTkyNSwxMi43NzUyNSAxNC43NzUyNSwxMi45OTkyNSAxNC40OTkyNSwxMi45OTkyNSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iIHRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIDAgMTYpIi8+PC9zdmc+'); | ||||
|  | ||||
							
								
								
									
										10
									
								
								static/ui-icons/UI/Columns.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_7579)"> | ||||
| <path d="M0 2V14.2353H16V2H0ZM9.41176 3.88235V12.3529H6.58824V3.88235H9.41176ZM1.88235 3.88235H4.70588V12.3529H1.88235V3.88235ZM14.1176 12.3529H11.2941V3.88235H14.1176V12.3529Z" fill="black"/> | ||||
| </g> | ||||
| <defs> | ||||
| <clipPath id="clip0_1061_7579"> | ||||
| <rect width="16" height="16" fill="white"/> | ||||
| </clipPath> | ||||
| </defs> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 442 B | 
							
								
								
									
										10
									
								
								static/ui-icons/UI/Desktop.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_7601)"> | ||||
| <path d="M14.5455 1H1.45455C0.654545 1 0 1.65455 0 2.45455V11.1818C0 11.9818 0.654545 12.6364 1.45455 12.6364H6.54545L5.09091 14.8182V15.5455H10.9091V14.8182L9.45455 12.6364H14.5455C15.3455 12.6364 16 11.9818 16 11.1818V2.45455C16 1.65455 15.3455 1 14.5455 1ZM14.5455 9.72727H1.45455V2.45455H14.5455V9.72727Z" fill="black"/> | ||||
| </g> | ||||
| <defs> | ||||
| <clipPath id="clip0_1061_7601"> | ||||
| <rect width="16" height="16" fill="white"/> | ||||
| </clipPath> | ||||
| </defs> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 574 B | 
							
								
								
									
										10
									
								
								static/ui-icons/UI/Mobile.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_7598)"> | ||||
| <path d="M12 0H4C3.46957 0 2.96086 0.210714 2.58579 0.585786C2.21071 0.960859 2 1.46957 2 2V14C2 14.5304 2.21071 15.0391 2.58579 15.4142C2.96086 15.7893 3.46957 16 4 16H12C12.5304 16 13.0391 15.7893 13.4142 15.4142C13.7893 15.0391 14 14.5304 14 14V2C14 1.46957 13.7893 0.960859 13.4142 0.585786C13.0391 0.210714 12.5304 0 12 0ZM4 13V3H12V13H4Z" fill="black"/> | ||||
| </g> | ||||
| <defs> | ||||
| <clipPath id="clip0_1061_7598"> | ||||
| <rect width="16" height="16" fill="white"/> | ||||
| </clipPath> | ||||
| </defs> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 609 B | 
							
								
								
									
										10
									
								
								static/ui-icons/UI/Paragraph.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_7582)"> | ||||
| <path d="M5.92308 2.46154V7.38462C4.56923 7.38462 3.46154 6.27692 3.46154 4.92308C3.46154 3.56923 4.56923 2.46154 5.92308 2.46154ZM14 0H5.92308C3.20308 0 1 2.20308 1 4.92308C1 7.64308 3.20308 9.84615 5.92308 9.84615V16H7.5V2.46154H9.59615V16H11.25L11.25 2.46154H14V0Z" fill="black"/> | ||||
| </g> | ||||
| <defs> | ||||
| <clipPath id="clip0_1061_7582"> | ||||
| <rect width="16" height="16" fill="white"/> | ||||
| </clipPath> | ||||
| </defs> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 533 B | 
							
								
								
									
										12
									
								
								static/ui-icons/UI/Section.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,12 @@ | ||||
| <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_7593)"> | ||||
| <path fill-rule="evenodd" clip-rule="evenodd" d="M2.10526 4.21094H13.8947C15.0574 4.21094 16 5.1535 16 6.3162V9.68462C16 10.8473 15.0574 11.7899 13.8947 11.7899H2.10526C0.942558 11.7899 0 10.8473 0 9.68462V6.3162C0 5.1535 0.942558 4.21094 2.10526 4.21094ZM2.10526 5.4741C1.64018 5.4741 1.26316 5.85112 1.26316 6.3162V9.68462C1.26316 10.1497 1.64018 10.5267 2.10526 10.5267H13.8947C14.3598 10.5267 14.7368 10.1497 14.7368 9.68462V6.3162C14.7368 5.85112 14.3598 5.4741 13.8947 5.4741H2.10526Z" fill="black"/> | ||||
| <path fill-rule="evenodd" clip-rule="evenodd" d="M1.26316 16.0001H0V15.158L4.30659e-05 15.1444C0.00736708 13.9879 0.9471 13.0527 2.10526 13.0527H3.57895V14.3159H2.10526C1.64018 14.3159 1.26316 14.6929 1.26316 15.158V16.0001ZM9.47368 14.3159H6.52632V13.0527H9.47368V14.3159ZM14.7368 16.0001V15.158C14.7368 14.6929 14.3598 14.3159 13.8947 14.3159H12.4211V13.0527H13.8947C15.0574 13.0527 16 13.9953 16 15.158V16.0001H14.7368Z" fill="black"/> | ||||
| <path fill-rule="evenodd" clip-rule="evenodd" d="M14.7368 -0.000102818H16V0.842002L16 0.855618C15.9926 2.01206 15.0529 2.94727 13.8947 2.94727H12.4211V1.68411L13.8947 1.68411C14.3598 1.68411 14.7368 1.30708 14.7368 0.842002V-0.000102818ZM6.52632 1.68411L9.47368 1.68411V2.94727L6.52632 2.94727V1.68411ZM1.26316 -0.000102818V0.842002C1.26316 1.30708 1.64018 1.68411 2.10526 1.68411L3.57895 1.68411V2.94727L2.10526 2.94727C0.942558 2.94727 -1.19209e-07 2.00471 -1.19209e-07 0.842002V-0.000102818H1.26316Z" fill="black"/> | ||||
| </g> | ||||
| <defs> | ||||
| <clipPath id="clip0_1061_7593"> | ||||
| <rect width="16" height="16" fill="white"/> | ||||
| </clipPath> | ||||
| </defs> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 1.7 KiB | 
							
								
								
									
										12
									
								
								static/ui-icons/UI/Separator.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,12 @@ | ||||
| <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_7588)"> | ||||
| <rect x="1.33337" y="7" width="13.3333" height="1.33333" fill="black"/> | ||||
| <rect y="5" width="1.33333" height="5.33333" fill="black"/> | ||||
| <rect x="14.6667" y="5" width="1.33333" height="5.33333" fill="black"/> | ||||
| </g> | ||||
| <defs> | ||||
| <clipPath id="clip0_1061_7588"> | ||||
| <rect width="16" height="16" fill="white"/> | ||||
| </clipPath> | ||||
| </defs> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 453 B | 
| @ -5,6 +5,7 @@ import {setupTestSuite} from 'test/nbrowser/testUtils'; | ||||
| 
 | ||||
| describe('FormView', function() { | ||||
|   this.timeout('90s'); | ||||
|   gu.bigScreen(); | ||||
| 
 | ||||
|   let api: UserAPI; | ||||
|   let docId: string; | ||||
| @ -62,7 +63,7 @@ describe('FormView', function() { | ||||
|     assert.isUndefined(await api.getTable(docId, 'Table1').then(t => t.D)); | ||||
| 
 | ||||
|     // Add a text question
 | ||||
|     await drop().click(); | ||||
|     await plusButton().click(); | ||||
|     if (more) { | ||||
|       await clickMenu('More'); | ||||
|     } | ||||
| @ -87,6 +88,9 @@ describe('FormView', function() { | ||||
|       await cb.paste(); | ||||
|     }); | ||||
| 
 | ||||
|     // Select it
 | ||||
|     await question('D').click(); | ||||
| 
 | ||||
|     return await driver.find('#clipboardText').value(); | ||||
|   } | ||||
| 
 | ||||
| @ -181,9 +185,6 @@ describe('FormView', function() { | ||||
|     await gu.waitToPass(async () => { | ||||
|       assert.isTrue(await driver.find('.test-forms-preview').isDisplayed()); | ||||
|     }); | ||||
|     await driver.find('.test-forms-preview').click(); | ||||
|     await gu.waitForServer(); | ||||
| 
 | ||||
|     // We are in a new window.
 | ||||
|     await gu.onNewTab(async () => { | ||||
|       await driver.get(formUrl); | ||||
| @ -218,7 +219,7 @@ describe('FormView', function() { | ||||
|     // We are in a new window.
 | ||||
|     await gu.onNewTab(async () => { | ||||
|       await driver.get(formUrl); | ||||
|       await driver.findWait('input[name="D"]', 1000).click(); | ||||
|       await driver.findWait('input[name="D"]', 1000).findClosest("label").click(); | ||||
|       await driver.find('input[type="submit"]').click(); | ||||
|       await waitForConfirm(); | ||||
|     }); | ||||
| @ -299,11 +300,7 @@ describe('FormView', function() { | ||||
|     assert.isTrue(await driver.find('.test-forms-editor').isDisplayed()); | ||||
| 
 | ||||
|     // With 3 questions A, B, C.
 | ||||
|     for (const label of ['A', 'B', 'C']) { | ||||
|       assert.isTrue( | ||||
|         await driver.findContent('.test-forms-question .test-forms-label', gu.exactMatch(label)).isDisplayed() | ||||
|       ); | ||||
|     } | ||||
|     assert.deepEqual(await readLabels(), ['A', 'B', 'C']); | ||||
| 
 | ||||
|     // And a submit button.
 | ||||
|     assert.isTrue(await driver.findContent('.test-forms-submit', gu.exactMatch('Submit')).isDisplayed()); | ||||
| @ -386,7 +383,7 @@ describe('FormView', function() { | ||||
|     await driver.withActions(a => | ||||
|       a.move({origin: questionDrag('A')}) | ||||
|         .press() | ||||
|         .move({origin: drop().drag()}) | ||||
|         .move({origin: plusButton().drag()}) | ||||
|         .release() | ||||
|     ); | ||||
| 
 | ||||
| @ -396,7 +393,7 @@ describe('FormView', function() { | ||||
|     assert.deepEqual(await readLabels(), ['A', 'B', 'C']); | ||||
| 
 | ||||
|     // Now add a new question.
 | ||||
|     await drop().click(); | ||||
|     await plusButton().click(); | ||||
| 
 | ||||
|     await clickMenu('Text'); | ||||
|     await gu.waitForServer(); | ||||
| @ -452,15 +449,14 @@ describe('FormView', function() { | ||||
|     // Now B is selected.
 | ||||
|     assert.equal(await selectedLabel(), 'B'); | ||||
| 
 | ||||
|     // Click on the dropzone.
 | ||||
|     await drop().click(); | ||||
|     await gu.sendKeys(Key.ESCAPE); | ||||
|     // Click on the edit button.
 | ||||
|     await driver.find('.test-forms-submit').click(); | ||||
| 
 | ||||
|     // Now nothing is selected.
 | ||||
|     assert.isFalse(await isSelected()); | ||||
|     assert.isFalse(await isSelected(), 'Something is selected'); | ||||
| 
 | ||||
|     // When we add new question, it is automatically selected.
 | ||||
|     await drop().click(); | ||||
|     await plusButton().click(); | ||||
|     await clickMenu('Text'); | ||||
|     await gu.waitForServer(); | ||||
|     // Now D is selected.
 | ||||
| @ -478,6 +474,12 @@ describe('FormView', function() { | ||||
|     // We have only one hidden column.
 | ||||
|     assert.deepEqual(await hiddenColumns(), ['Choice']); | ||||
| 
 | ||||
|     // Make sure we see it in the menu.
 | ||||
|     await plusButton().click(); | ||||
| 
 | ||||
|     // We have 1 unmapped menu item.
 | ||||
|     assert.equal(await elementCount('menu-unmapped'), 1); | ||||
| 
 | ||||
|     // Now move it to the form on B
 | ||||
|     await driver.withActions(a => | ||||
|       a.move({origin: hiddenColumn('Choice')}) | ||||
| @ -504,6 +506,7 @@ describe('FormView', function() { | ||||
|     assert.deepEqual(await readLabels(), ['A', 'Choice', 'B', 'C']); | ||||
|     assert.deepEqual(await hiddenColumns(), []); | ||||
| 
 | ||||
| 
 | ||||
|     // Now hide it using menu.
 | ||||
|     await question('Choice').rightClick(); | ||||
|     await clickMenu('Hide'); | ||||
| @ -518,7 +521,21 @@ describe('FormView', function() { | ||||
|     assert.deepEqual(await readLabels(), ['A', 'Choice', 'B', 'C']); | ||||
|     assert.deepEqual(await hiddenColumns(), []); | ||||
| 
 | ||||
|     // And redo.
 | ||||
|     await gu.redo(); | ||||
|     assert.deepEqual(await readLabels(), ['A', 'B', 'C']); | ||||
|     assert.deepEqual(await hiddenColumns(), ['Choice']); | ||||
| 
 | ||||
|     // Now unhide it using menu.
 | ||||
|     await plusButton().click(); | ||||
|     await element('menu-unmapped').click(); | ||||
|     await gu.waitForServer(); | ||||
| 
 | ||||
|     assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'Choice']); | ||||
|     assert.deepEqual(await hiddenColumns(), []); | ||||
| 
 | ||||
|     // Now hide it using Delete key.
 | ||||
|     await driver.find('.test-forms-submit').click(); | ||||
|     await question('Choice').click(); | ||||
|     await gu.sendKeys(Key.DELETE); | ||||
|     await gu.waitForServer(); | ||||
| @ -527,6 +544,7 @@ describe('FormView', function() { | ||||
|     assert.deepEqual(await hiddenColumns(), ['Choice']); | ||||
|     assert.deepEqual(await readLabels(), ['A', 'B', 'C']); | ||||
| 
 | ||||
| 
 | ||||
|     await gu.toggleSidePanel('right', 'close'); | ||||
|   }); | ||||
| 
 | ||||
| @ -535,30 +553,27 @@ describe('FormView', function() { | ||||
|     assert.equal(await selectedLabel(), 'A'); | ||||
| 
 | ||||
|     // Move down.
 | ||||
|     await gu.sendKeys(Key.ARROW_DOWN); | ||||
|     await arrow(Key.ARROW_DOWN); | ||||
|     assert.equal(await selectedLabel(), 'B'); | ||||
| 
 | ||||
|     // Move up.
 | ||||
|     await gu.sendKeys(Key.ARROW_UP); | ||||
|     await arrow(Key.ARROW_UP); | ||||
|     assert.equal(await selectedLabel(), 'A'); | ||||
| 
 | ||||
|     // Move down to C.
 | ||||
|     await gu.sendKeys(Key.ARROW_DOWN); | ||||
|     await gu.sendKeys(Key.ARROW_DOWN); | ||||
|     await arrow(Key.ARROW_DOWN, 2); | ||||
|     assert.equal(await selectedLabel(), 'C'); | ||||
| 
 | ||||
|     // Move down we should be at A (past the submit button).
 | ||||
|     await gu.sendKeys(Key.ARROW_DOWN); | ||||
|     await gu.sendKeys(Key.ARROW_DOWN); | ||||
|     // Move down we should be at A (past the submit button, and titles and sections).
 | ||||
|     await arrow(Key.ARROW_DOWN, 7); | ||||
|     assert.equal(await selectedLabel(), 'A'); | ||||
| 
 | ||||
|     // Do the same with Left and Right.
 | ||||
|     await gu.sendKeys(Key.ARROW_RIGHT); | ||||
|     await arrow(Key.ARROW_RIGHT); | ||||
|     assert.equal(await selectedLabel(), 'B'); | ||||
|     await gu.sendKeys(Key.ARROW_LEFT); | ||||
|     await arrow(Key.ARROW_LEFT); | ||||
|     assert.equal(await selectedLabel(), 'A'); | ||||
|     await gu.sendKeys(Key.ARROW_RIGHT); | ||||
|     await gu.sendKeys(Key.ARROW_RIGHT); | ||||
|     await arrow(Key.ARROW_RIGHT, 2); | ||||
|     assert.equal(await selectedLabel(), 'C'); | ||||
|   }); | ||||
| 
 | ||||
| @ -578,14 +593,13 @@ describe('FormView', function() { | ||||
|     assert.deepEqual(await readLabels(), ['A', 'B', 'C']); | ||||
| 
 | ||||
|     // To the same for paragraph.
 | ||||
|     await drop().click(); | ||||
|     await plusButton().click(); | ||||
|     await clickMenu('Paragraph'); | ||||
|     await gu.waitForServer(); | ||||
|     await element('Paragraph').click(); | ||||
|     await element('Paragraph', 5).click(); | ||||
|     await clipboard.lockAndPerform(async (cb) => { | ||||
|       await cb.cut(); | ||||
|       // Go over A and paste there.
 | ||||
|       await gu.sendKeys(Key.ARROW_UP); // Focus on button
 | ||||
|       await gu.sendKeys(Key.ARROW_UP); // Focus on C.
 | ||||
|       await gu.sendKeys(Key.ARROW_UP); // Focus on B.
 | ||||
|       await gu.sendKeys(Key.ARROW_UP); // Focus on A.
 | ||||
| @ -597,13 +611,18 @@ describe('FormView', function() { | ||||
|     assert.deepEqual(await readLabels(), ['A', 'B', 'C']); | ||||
|     let elements = await driver.findAll('.test-forms-element'); | ||||
|     assert.isTrue(await elements[0].matches('.test-forms-Paragraph')); | ||||
|     assert.isTrue(await elements[1].matches('.test-forms-Paragraph')); | ||||
|     assert.isTrue(await elements[2].matches('.test-forms-Section')); | ||||
|     assert.isTrue(await elements[3].matches('.test-forms-Paragraph')); | ||||
|     assert.isTrue(await elements[4].matches('.test-forms-Paragraph')); | ||||
|     assert.isTrue(await elements[5].matches('.test-forms-Paragraph')); | ||||
| 
 | ||||
|     // Put it back using undo.
 | ||||
|     await gu.undo(); | ||||
|     elements = await driver.findAll('.test-forms-element'); | ||||
|     assert.isTrue(await elements[0].matches('.test-forms-question')); | ||||
|     assert.isTrue(await elements[5].matches('.test-forms-Field')); | ||||
|     // 0 - A, 1 - B, 2 - C, 3 - submit button.
 | ||||
|     assert.isTrue(await elements[4].matches('.test-forms-Paragraph')); | ||||
|     assert.isTrue(await elements[8].matches('.test-forms-Paragraph')); | ||||
| 
 | ||||
|     await revert(); | ||||
|   }); | ||||
| @ -617,7 +636,7 @@ describe('FormView', function() { | ||||
|   }; | ||||
|   const checkFieldsAtFirstLevel = (menuText: string) => { | ||||
|     it(`can add ${menuText} elements from the menu`, async function() { | ||||
|       await drop().click(); | ||||
|       await plusButton().click(); | ||||
|       await clickMenu(menuText); | ||||
|       await gu.waitForServer(); | ||||
|       await checkNewCol(); | ||||
| @ -631,7 +650,7 @@ describe('FormView', function() { | ||||
| 
 | ||||
|   const checkFieldInMore = (menuText: string) => { | ||||
|     it(`can add ${menuText} elements from the menu`, async function() { | ||||
|       await drop().click(); | ||||
|       await plusButton().click(); | ||||
|       await clickMenu('More'); | ||||
|       await clickMenu(menuText); | ||||
|       await gu.waitForServer(); | ||||
| @ -645,44 +664,43 @@ describe('FormView', function() { | ||||
|   checkFieldInMore('Choice List'); | ||||
|   checkFieldInMore('Reference'); | ||||
|   checkFieldInMore('Reference List'); | ||||
|   checkFieldInMore('Attachment'); | ||||
| 
 | ||||
|   const testStruct = (type: string) => { | ||||
|   const testStruct = (type: string, existing = 0) => { | ||||
|     it(`can add structure ${type} element`, async function() { | ||||
|       assert.equal(await elementCount(type), 0); | ||||
|       await drop().click(); | ||||
|       assert.equal(await elementCount(type), existing); | ||||
|       await plusButton().click(); | ||||
|       await clickMenu(type); | ||||
|       await gu.waitForServer(); | ||||
|       assert.equal(await elementCount(type), 1); | ||||
|       assert.equal(await elementCount(type), existing + 1); | ||||
|       await gu.undo(); | ||||
|       assert.equal(await elementCount(type), 0); | ||||
|       assert.equal(await elementCount(type), existing); | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   testStruct('Section'); | ||||
|   // testStruct('Section'); // There is already a section
 | ||||
|   testStruct('Columns'); | ||||
|   testStruct('Paragraph'); | ||||
|   testStruct('Paragraph', 4); | ||||
| 
 | ||||
|   it('basic section', async function() { | ||||
|     const revert = await gu.begin(); | ||||
| 
 | ||||
|     // Add structure.
 | ||||
|     await drop().click(); | ||||
|     await clickMenu('Section'); | ||||
|     await gu.waitForServer(); | ||||
|     // Adding section is disabled for now, so this test is altered to use the existing section.
 | ||||
|     // await drop().click();
 | ||||
|     // await clickMenu('Section');
 | ||||
|     // await gu.waitForServer();
 | ||||
|     assert.equal(await elementCount('Section'), 1); | ||||
| 
 | ||||
|     assert.deepEqual(await readLabels(), ['A', 'B', 'C']); | ||||
| 
 | ||||
|     // There is a drop in that section, click it to add a new question.
 | ||||
|     await element('Section').element('dropzone').click(); | ||||
|     await element('Section', 1).element('plus').click(); | ||||
|     await clickMenu('Text'); | ||||
|     await gu.waitForServer(); | ||||
| 
 | ||||
|     assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']); | ||||
| 
 | ||||
|     // And the question is inside a section.
 | ||||
|     assert.equal(await element('Section').element('label').getText(), 'D'); | ||||
|     assert.equal(await element('Section', 1).element('label', 4).value(), 'D'); | ||||
| 
 | ||||
|     // Make sure we can move that question around.
 | ||||
|     await driver.withActions(a => | ||||
| @ -696,11 +714,11 @@ describe('FormView', function() { | ||||
|     assert.deepEqual(await readLabels(), ['A', 'D', 'B', 'C']); | ||||
| 
 | ||||
|     // Make sure that it is not inside the section anymore.
 | ||||
|     assert.equal(await element('Section').element('label').isPresent(), false); | ||||
|     // assert.equal(await element('Section', 1).element('label').isPresent(), false);
 | ||||
| 
 | ||||
|     await gu.undo(); | ||||
|     assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']); | ||||
|     assert.equal(await element('Section').element('label').getText(), 'D'); | ||||
|     assert.equal(await element('Section', 1).element('label', 4).value(), 'D'); | ||||
| 
 | ||||
|     await revert(); | ||||
|     assert.deepEqual(await readLabels(), ['A', 'B', 'C']); | ||||
| @ -708,7 +726,7 @@ describe('FormView', function() { | ||||
| 
 | ||||
|   it('basic columns work', async function() { | ||||
|     const revert = await gu.begin(); | ||||
|     await drop().click(); | ||||
|     await plusButton().click(); | ||||
|     await clickMenu('Columns'); | ||||
|     await gu.waitForServer(); | ||||
| 
 | ||||
| @ -723,7 +741,7 @@ describe('FormView', function() { | ||||
|     assert.equal(await elementCount('Placeholder', element('Columns')), 3); | ||||
| 
 | ||||
|     // We can click the middle one, and add a question.
 | ||||
|     await element('Columns').find(`.test-forms-editor:nth-child(2) .test-forms-Placeholder`).click(); | ||||
|     await element('Columns').element(`Placeholder`, 2).click(); | ||||
|     await clickMenu('Text'); | ||||
|     await gu.waitForServer(); | ||||
| 
 | ||||
| @ -733,7 +751,7 @@ describe('FormView', function() { | ||||
|     assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']); | ||||
| 
 | ||||
|     // The question D is in the columns.
 | ||||
|     assert.equal(await element('Columns').element('label').getText(), 'D'); | ||||
|     assert.equal(await element('Columns').element('label').value(), 'D'); | ||||
| 
 | ||||
|     // We can move it around.
 | ||||
|     await driver.withActions(a => | ||||
| @ -749,7 +767,7 @@ describe('FormView', function() { | ||||
|     await driver.withActions(a => | ||||
|       a.move({origin: questionDrag('D')}) | ||||
|         .press() | ||||
|         .move({origin: element('Columns').find(`.test-forms-editor:nth-child(2) .test-forms-drag`)}) | ||||
|         .move({origin: element('Columns').element(`Placeholder`, 2).find(`.test-forms-drag`)}) | ||||
|         .release() | ||||
|     ); | ||||
|     await gu.waitForServer(); | ||||
| @ -760,7 +778,7 @@ describe('FormView', function() { | ||||
|     assert.lengthOf(allColumns, 3); | ||||
|     assert.isTrue(await allColumns[0].matches('.test-forms-Placeholder')); | ||||
|     assert.isTrue(await allColumns[1].matches('.test-forms-question')); | ||||
|     assert.equal(await allColumns[1].find('.test-forms-label').getText(), 'D'); | ||||
|     assert.equal(await allColumns[1].find('.test-forms-label').value(), 'D'); | ||||
|     assert.isTrue(await allColumns[2].matches('.test-forms-Placeholder')); | ||||
| 
 | ||||
|     // Check that we can remove the question.
 | ||||
| @ -780,7 +798,7 @@ describe('FormView', function() { | ||||
|     assert.lengthOf(allColumns, 3); | ||||
|     assert.isTrue(await allColumns[0].matches('.test-forms-Placeholder')); | ||||
|     assert.isTrue(await allColumns[1].matches('.test-forms-question')); | ||||
|     assert.equal(await allColumns[1].find('.test-forms-label').getText(), 'D'); | ||||
|     assert.equal(await allColumns[1].find('.test-forms-label').value(), 'D'); | ||||
|     assert.isTrue(await allColumns[2].matches('.test-forms-Placeholder')); | ||||
| 
 | ||||
|     await revert(); | ||||
| @ -790,7 +808,7 @@ describe('FormView', function() { | ||||
| 
 | ||||
|   it('changes type of a question', async function() { | ||||
|     // Add text question as D column.
 | ||||
|     await drop().click(); | ||||
|     await plusButton().click(); | ||||
|     await clickMenu('Text'); | ||||
|     await gu.waitForServer(); | ||||
|     assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']); | ||||
| @ -826,8 +844,21 @@ describe('FormView', function() { | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| function element(type: string, parent?: WebElement) { | ||||
|   return extra((parent ?? driver).find(`.test-forms-${type}`)); | ||||
| function element(type: string, parent?: WebElement): ExtraElement; | ||||
| function element(type: string, index: number, parent?: WebElement): ExtraElement; | ||||
| function element(type: string, arg1?: number | WebElement, arg2?: WebElement): ExtraElement { | ||||
|   if (typeof arg1 === 'number') { | ||||
|     if (arg1 === 1) { | ||||
|       return extra((arg2 ?? driver).find(`.test-forms-${type}`)); | ||||
|     } | ||||
|     const nth = ((arg2 ?? driver).findAll(`.test-forms-${type}`).then(els => els[arg1 - 1])).then(el => { | ||||
|       if (!el) { throw new Error(`No element of type ${type} at index ${arg1}`); } | ||||
|       return el; | ||||
|     }); | ||||
|     return extra(new WebElementPromise(driver, nth)); | ||||
|   } else { | ||||
|     return extra((arg1 ?? driver).find(`.test-forms-${type}`)); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| async function elementCount(type: string, parent?: WebElement) { | ||||
| @ -835,12 +866,11 @@ async function elementCount(type: string, parent?: WebElement) { | ||||
| } | ||||
| 
 | ||||
| async function readLabels() { | ||||
|   return await driver.findAll('.test-forms-question .test-forms-label', el => el.getText()); | ||||
|   return await driver.findAll('.test-forms-question .test-forms-label', el => el.value()); | ||||
| } | ||||
| 
 | ||||
| function question(label: string) { | ||||
|   return extra(driver.findContent('.test-forms-question .test-forms-label', gu.exactMatch(label)) | ||||
|     .findClosest('.test-forms-editor')); | ||||
|   return extra(gu.findValue(`.test-forms-label`, label).findClosest('.test-forms-editor')); | ||||
| } | ||||
| 
 | ||||
| function questionDrag(label: string) { | ||||
| @ -851,12 +881,12 @@ function questionType(label: string) { | ||||
|   return question(label).find('.test-forms-type').value(); | ||||
| } | ||||
| 
 | ||||
| function drop() { | ||||
|   return element('dropzone'); | ||||
| function plusButton() { | ||||
|   return element('plus'); | ||||
| } | ||||
| 
 | ||||
| function drops() { | ||||
|   return driver.findAll('.test-forms-dropzone'); | ||||
|   return driver.findAll('.test-forms-plus'); | ||||
| } | ||||
| 
 | ||||
| async function clickMenu(label: string) { | ||||
| @ -876,7 +906,7 @@ function selected() { | ||||
| } | ||||
| 
 | ||||
| function selectedLabel() { | ||||
|   return selected().find('.test-forms-label').getText(); | ||||
|   return selected().find('.test-forms-label').value(); | ||||
| } | ||||
| 
 | ||||
| function hiddenColumns() { | ||||
| @ -889,7 +919,7 @@ function hiddenColumn(label: string) { | ||||
| 
 | ||||
| type ExtraElement = WebElementPromise & { | ||||
|   rightClick: () => Promise<void>, | ||||
|   element: (type: string) => ExtraElement, | ||||
|   element: (type: string, index?: number) => ExtraElement, | ||||
|   /** | ||||
|    * A draggable element inside. This is 2x2px div to help with drag and drop. | ||||
|    */ | ||||
| @ -903,8 +933,8 @@ function extra(el: WebElementPromise): ExtraElement { | ||||
|     await driver.withActions(a => a.contextClick(webElement)); | ||||
|   }; | ||||
| 
 | ||||
|   webElement.element = function(type: string) { | ||||
|     return element(type, webElement); | ||||
|   webElement.element = function(type: string, index?: number) { | ||||
|     return element(type, index ?? 1, webElement); | ||||
|   }; | ||||
| 
 | ||||
|   webElement.drag = function() { | ||||
| @ -913,3 +943,9 @@ function extra(el: WebElementPromise): ExtraElement { | ||||
| 
 | ||||
|   return webElement; | ||||
| } | ||||
| 
 | ||||
| async function arrow(key: string, times: number = 1) { | ||||
|   for (let i = 0; i < times; i++) { | ||||
|     await gu.sendKeys(key); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -3562,7 +3562,7 @@ export const choicesEditor = { | ||||
|     return (await driver.find(".test-choice-list-entry-edit").getText()) === "Reset"; | ||||
|   }, | ||||
|   async reset() { | ||||
|     await driver.find(".test-choice-list-entry-edit").click(); | ||||
|     await driver.findWait(".test-choice-list-entry-edit", 100).click(); | ||||
|   }, | ||||
|   async label() { | ||||
|     return await driver.find(".test-choice-list-entry-row").getText(); | ||||
| @ -3599,6 +3599,20 @@ export const choicesEditor = { | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| export function findValue(selector: string, value: string|RegExp) { | ||||
|   const inner = async () => { | ||||
|     const all = await driver.findAll(selector); | ||||
|     const tested: string[] = []; | ||||
|     for(const el of all) { | ||||
|       const elValue = await el.value(); | ||||
|       tested.push(elValue); | ||||
|       const found = typeof value === 'string' ? elValue === value : value.test(elValue); | ||||
|       if (found) { return el; } | ||||
|     } | ||||
|     throw new Error(`No element found matching ${selector}, tested ${tested.join(', ')}`); | ||||
|   }; | ||||
|   return new WebElementPromise(driver, inner()); | ||||
| } | ||||
| 
 | ||||
| } // end of namespace gristUtils
 | ||||
| 
 | ||||
|  | ||||