From 418681915eac89dd90568e629db0ba0c814395b1 Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Wed, 20 Mar 2024 10:51:59 -0400 Subject: [PATCH] (core) Forms Improvements Summary: - Forms now have a reset button. - Choice and Reference fields in forms now have an improved select menu. - Formula and attachments column types are no longer mappable or visible in forms. - Fields in a form widget are now removed if their column is deleted. - The preview button in a published form widget has been replaced with a view button. It now opens the published form in a new tab. - A new share menu for published form widgets, with options to copy a link or embed code. - Forms can now have multiple sections. - Form widgets now indicate when publishing is unavailable (e.g. in forks or unsaved documents). - General improvements to form styling. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D4203 --- app/client/components/FormRenderer.ts | 506 +++++++++++++----- app/client/components/FormRendererCss.ts | 109 +++- app/client/components/Forms/Columns.ts | 29 +- app/client/components/Forms/Editor.ts | 24 +- app/client/components/Forms/Field.ts | 73 +-- app/client/components/Forms/FormView.ts | 375 +++++++++---- ...dFieldsConfig.ts => MappedFieldsConfig.ts} | 50 +- app/client/components/Forms/Menu.ts | 7 +- app/client/components/Forms/Model.ts | 72 +-- app/client/components/Forms/Paragraph.ts | 10 +- app/client/components/Forms/Section.ts | 59 +- app/client/components/Forms/elements.ts | 8 +- app/client/components/Forms/styles.ts | 168 ++++-- app/client/components/duplicatePage.ts | 3 +- app/client/components/modals.ts | 2 + app/client/lib/ACIndex.ts | 27 +- app/client/lib/simpleList.ts | 11 +- app/client/models/FormModel.ts | 10 +- app/client/models/entities/ColumnRec.ts | 6 + app/client/ui/DocumentSettings.ts | 2 +- app/client/ui/FormAPI.ts | 4 +- app/client/ui/PageWidgetPicker.ts | 2 +- app/client/ui/RightPanel.ts | 110 ++-- app/client/ui/searchDropdown.ts | 75 ++- app/client/ui2018/IconList.ts | 2 + app/client/ui2018/cssVars.ts | 16 + app/client/ui2018/modals.ts | 5 +- app/client/widgets/CurrencyPicker.ts | 2 +- app/client/widgets/FieldBuilder.ts | 7 + app/client/widgets/TZAutocomplete.ts | 5 +- app/common/Telemetry.ts | 33 ++ app/common/gristTypes.ts | 22 + app/server/lib/ActiveDocImport.ts | 11 +- app/server/lib/DocApi.ts | 10 +- static/icons/icons.css | 1 + static/ui-icons/UI/FormConfig.svg | 32 ++ test/client/lib/ACIndex.ts | 16 +- test/nbrowser/FormView.ts | 267 ++++++--- test/nbrowser/GridViewNewColumnMenu.ts | 3 +- test/nbrowser/gristUtils.ts | 46 ++ 40 files changed, 1623 insertions(+), 597 deletions(-) rename app/client/components/Forms/{UnmappedFieldsConfig.ts => MappedFieldsConfig.ts} (97%) create mode 100644 static/ui-icons/UI/FormConfig.svg diff --git a/app/client/components/FormRenderer.ts b/app/client/components/FormRenderer.ts index cd8bad90..857067a5 100644 --- a/app/client/components/FormRenderer.ts +++ b/app/client/components/FormRenderer.ts @@ -1,20 +1,24 @@ import * as css from 'app/client/components/FormRendererCss'; import {FormField} from 'app/client/ui/FormAPI'; import {sanitizeHTML} from 'app/client/ui/sanitizeHTML'; +import {dropdownWithSearch} from 'app/client/ui/searchDropdown'; +import {isXSmallScreenObs} from 'app/client/ui2018/cssVars'; +import {confirmModal} from 'app/client/ui2018/modals'; import {CellValue} from 'app/plugin/GristData'; -import {Disposable, dom, DomContents, Observable} from 'grainjs'; +import {Disposable, dom, DomContents, makeTestId, MutableObsArray, obsArray, Observable} from 'grainjs'; import {marked} from 'marked'; +import {IPopupOptions, PopupControl} from 'popweasel'; -export const CHOOSE_TEXT = '— Choose —'; +const testId = makeTestId('test-form-'); /** * A node in a recursive, tree-like hierarchy comprising the layout of a form. */ export interface FormLayoutNode { + /** Unique ID of the node. Used by FormView. */ + id: string; type: FormLayoutNodeType; children?: Array; - // Unique ID of the field. Used only in the Form widget. - id?: string; // Used by Layout. submitText?: string; successURL?: string; @@ -55,6 +59,24 @@ export interface FormRendererContext { error: Observable; } +/** + * Returns a copy of `layoutSpec` with any leaf nodes that don't exist + * in `fieldIds` removed. + */ +export function patchLayoutSpec( + layoutSpec: FormLayoutNode, + fieldIds: Set +): FormLayoutNode | null { + if (layoutSpec.leaf && !fieldIds.has(layoutSpec.leaf)) { return null; } + + return { + ...layoutSpec, + children: layoutSpec.children + ?.map(child => patchLayoutSpec(child, fieldIds)) + .filter((child): child is FormLayoutNode => child !== null), + }; +} + /** * A renderer for a form layout. * @@ -68,20 +90,35 @@ export interface FormRendererContext { * TODO: merge the two implementations or factor out what's common. */ export abstract class FormRenderer extends Disposable { - public static new(layoutNode: FormLayoutNode, context: FormRendererContext): FormRenderer { + public static new( + layoutNode: FormLayoutNode, + context: FormRendererContext, + parent?: FormRenderer + ): FormRenderer { const Renderer = FormRenderers[layoutNode.type] ?? ParagraphRenderer; - return new Renderer(layoutNode, context); + return new Renderer(layoutNode, context, parent); } protected children: FormRenderer[]; - constructor(protected layoutNode: FormLayoutNode, protected context: FormRendererContext) { + constructor( + protected layoutNode: FormLayoutNode, + protected context: FormRendererContext, + protected parent?: FormRenderer + ) { super(); this.children = (this.layoutNode.children ?? []).map((child) => - this.autoDispose(FormRenderer.new(child, this.context))); + this.autoDispose(FormRenderer.new(child, this.context, this))); } public abstract render(): DomContents; + + /** + * Reset the state of this layout node and all of its children. + */ + public reset() { + this.children.forEach((child) => child.reset()); + } } class LabelRenderer extends FormRenderer { @@ -122,30 +159,45 @@ class SubmitRenderer extends FormRenderer { public render() { return [ css.error(dom.text(use => use(this.context.error) ?? '')), - css.submit( - dom('input', + css.submitButtons( + css.resetButton( + 'Reset', dom.boolAttr('disabled', this.context.disabled), - { - type: 'submit', - value: this.context.rootLayoutNode.submitText || 'Submit' - }, + {type: 'button'}, dom.on('click', () => { - // Make sure that all choice or reference lists that are required have at least one option selected. - const lists = document.querySelectorAll('.grist-checkbox-list.required:not(:has(input:checked))'); - Array.from(lists).forEach(function(list) { - // If the form has at least one checkbox, make it required. - const firstCheckbox = list.querySelector('input[type="checkbox"]'); - firstCheckbox?.setAttribute('required', 'required'); - }); - - // All other required choice or reference lists with at least one option selected are no longer required. - const checkedLists = document.querySelectorAll('.grist-checkbox-list.required:has(input:checked)'); - Array.from(checkedLists).forEach(function(list) { - const firstCheckbox = list.querySelector('input[type="checkbox"]'); - firstCheckbox?.removeAttribute('required'); - }); + return confirmModal( + 'Are you sure you want to reset your form?', + 'Reset', + () => this.parent?.reset() + ); }), - ) + testId('reset'), + ), + css.submitButton( + dom('input', + dom.boolAttr('disabled', this.context.disabled), + { + type: 'submit', + value: this.context.rootLayoutNode.submitText || 'Submit', + }, + dom.on('click', () => { + // Make sure that all choice or reference lists that are required have at least one option selected. + const lists = document.querySelectorAll('.grist-checkbox-list.required:not(:has(input:checked))'); + Array.from(lists).forEach(function(list) { + // If the form has at least one checkbox, make it required. + const firstCheckbox = list.querySelector('input[type="checkbox"]'); + firstCheckbox?.setAttribute('required', 'required'); + }); + + // All other required choice or reference lists with at least one option selected are no longer required. + const checkedLists = document.querySelectorAll('.grist-checkbox-list.required:has(input:checked)'); + Array.from(checkedLists).forEach(function(list) { + const firstCheckbox = list.querySelector('input[type="checkbox"]'); + firstCheckbox?.removeAttribute('required'); + }); + }), + ) + ), ), ]; } @@ -164,174 +216,380 @@ class LayoutRenderer extends FormRenderer { } class FieldRenderer extends FormRenderer { - public build(field: FormField) { + public renderer: BaseFieldRenderer; + + public constructor(layoutNode: FormLayoutNode, context: FormRendererContext) { + super(layoutNode, context); + const field = this.layoutNode.leaf ? this.context.fields[this.layoutNode.leaf] : null; + if (!field) { throw new Error(); } + const Renderer = FieldRenderers[field.type as keyof typeof FieldRenderers] ?? TextRenderer; - return new Renderer(); + this.renderer = this.autoDispose(new Renderer(field, context)); } public render() { - const field = this.layoutNode.leaf ? this.context.fields[this.layoutNode.leaf] : null; - if (!field) { return null; } + return css.field(this.renderer.render()); + } - const renderer = this.build(field); - return css.field(renderer.render(field, this.context)); + public reset() { + this.renderer.resetInput(); } } -abstract class BaseFieldRenderer { - public render(field: FormField, context: FormRendererContext) { +abstract class BaseFieldRenderer extends Disposable { + public constructor(protected field: FormField, protected context: FormRendererContext) { + super(); + } + + public render() { return css.field( - this.label(field), - dom('div', this.input(field, context)), + this.label(), + dom('div', this.input()), ); } - public name(field: FormField) { - return field.colId; + public name() { + return this.field.colId; } - public label(field: FormField) { + public label() { return dom('label', css.label.cls(''), - css.label.cls('-required', Boolean(field.options.formRequired)), - {for: this.name(field)}, - field.question, + css.label.cls('-required', Boolean(this.field.options.formRequired)), + {for: this.name()}, + this.field.question, ); } - public abstract input(field: FormField, context: FormRendererContext): DomContents; + public abstract input(): DomContents; + + public abstract resetInput(): void; } class TextRenderer extends BaseFieldRenderer { - public input(field: FormField) { - return dom('input', { - type: 'text', - name: this.name(field), - required: field.options.formRequired, - }); + protected type = 'text'; + private _value = Observable.create(this, ''); + + public input() { + return dom('input', + { + type: this.type, + name: this.name(), + required: this.field.options.formRequired, + }, + dom.prop('value', this._value), + dom.on('input', (_e, elem) => this._value.set(elem.value)), + ); } -} -class DateRenderer extends BaseFieldRenderer { - public input(field: FormField) { - return dom('input', { - type: 'date', - name: this.name(field), - required: field.options.formRequired, - }); + public resetInput(): void { + this._value.set(''); } } -class DateTimeRenderer extends BaseFieldRenderer { - public input(field: FormField) { - return dom('input', { - type: 'datetime-local', - name: this.name(field), - required: field.options.formRequired, - }); - } +class DateRenderer extends TextRenderer { + protected type = 'date'; +} + +class DateTimeRenderer extends TextRenderer { + protected type = 'datetime-local'; } +export const SELECT_PLACEHOLDER = 'Select...'; + class ChoiceRenderer extends BaseFieldRenderer { - public input(field: FormField) { - const choices: Array = field.options.choices || []; - // Insert empty option. - choices.unshift(null); - return css.select( - {name: this.name(field), required: field.options.formRequired}, - choices.map((choice) => dom('option', {value: choice ?? ''}, choice ?? CHOOSE_TEXT)) + protected value = Observable.create(this, ''); + private _choices: string[]; + private _selectElement: HTMLElement; + private _ctl?: PopupControl; + + public constructor(field: FormField, context: FormRendererContext) { + super(field, context); + + const choices = this.field.options.choices; + if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) { + this._choices = []; + } else { + // Support for 1000 choices. TODO: make limit dynamic. + this._choices = choices.slice(0, 1000); + } + } + + public input() { + return css.hybridSelect( + this._selectElement = css.select( + {name: this.name(), required: this.field.options.formRequired}, + dom.prop('value', this.value), + dom.on('input', (_e, elem) => this.value.set(elem.value)), + dom('option', {value: ''}, SELECT_PLACEHOLDER), + this._choices.map((choice) => dom('option', {value: choice}, choice)), + dom.onKeyDown({ + ' $': (ev) => this._maybeOpenSearchSelect(ev), + ArrowUp$: (ev) => this._maybeOpenSearchSelect(ev), + ArrowDown$: (ev) => this._maybeOpenSearchSelect(ev), + }), + ), + dom.maybe(use => !use(isXSmallScreenObs()), () => + css.searchSelect( + dom('div', dom.text(use => use(this.value) || SELECT_PLACEHOLDER)), + dropdownWithSearch({ + action: (value) => this.value.set(value), + options: () => [ + {label: SELECT_PLACEHOLDER, value: '', placeholder: true}, + ...this._choices.map((choice) => ({ + label: choice, + value: choice, + }), + )], + onClose: () => { setTimeout(() => this._selectElement.focus()); }, + placeholder: 'Search', + acOptions: {maxResults: 1000, keepOrder: true, showEmptyItems: true}, + popupOptions: { + trigger: [ + 'click', + (_el, ctl) => { this._ctl = ctl; }, + ], + }, + matchTriggerElemWidth: true, + }), + css.searchSelectIcon('Collapse'), + testId('search-select'), + ), + ), ); } + + public resetInput(): void { + this.value.set(''); + } + + private _maybeOpenSearchSelect(ev: KeyboardEvent) { + if (isXSmallScreenObs().get()) { + return; + } + + ev.preventDefault(); + ev.stopPropagation(); + this._ctl?.open(); + } } class BoolRenderer extends BaseFieldRenderer { - public render(field: FormField) { + protected checked = Observable.create(this, false); + + public render() { return css.field( - dom('div', this.input(field)), + dom('div', this.input()), ); } - public input(field: FormField) { + public input() { return css.toggle( - css.label.cls('-required', Boolean(field.options.formRequired)), - dom('input', { - type: 'checkbox', - name: this.name(field), - value: '1', - required: field.options.formRequired, - }), + dom('input', + dom.prop('checked', this.checked), + dom.on('change', (_e, elem) => this.checked.set(elem.checked)), + { + type: 'checkbox', + name: this.name(), + value: '1', + required: this.field.options.formRequired, + }, + ), css.gristSwitch( css.gristSwitchSlider(), css.gristSwitchCircle(), ), - dom('span', field.question || field.colId) + css.toggleLabel( + css.label.cls('-required', Boolean(this.field.options.formRequired)), + this.field.question, + ), ); } + + public resetInput(): void { + this.checked.set(false); + } } class ChoiceListRenderer extends BaseFieldRenderer { - public input(field: FormField) { - const choices: string[] = field.options.choices ?? []; - const required = field.options.formRequired; + protected checkboxes: MutableObsArray<{ + label: string; + checked: Observable + }> = this.autoDispose(obsArray()); + + public constructor(field: FormField, context: FormRendererContext) { + super(field, context); + + let choices = this.field.options.choices; + if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) { + choices = []; + } else { + // Support for 30 choices. TODO: make limit dynamic. + choices = choices.slice(0, 30); + } + + this.checkboxes.set(choices.map(choice => ({ + label: choice, + checked: Observable.create(this, null), + }))); + } + + public input() { + const required = this.field.options.formRequired; return css.checkboxList( dom.cls('grist-checkbox-list'), dom.cls('required', Boolean(required)), - {name: this.name(field), required}, - choices.map(choice => css.checkbox( - dom('input', { - type: 'checkbox', - name: `${this.name(field)}[]`, - value: choice, - }), - dom('span', choice), - )), + {name: this.name(), required}, + dom.forEach(this.checkboxes, (checkbox) => + css.checkbox( + dom('input', + dom.prop('checked', checkbox.checked), + dom.on('change', (_e, elem) => checkbox.checked.set(elem.value)), + { + type: 'checkbox', + name: `${this.name()}[]`, + value: checkbox.label, + } + ), + dom('span', checkbox.label), + ) + ), ); } + + public resetInput(): void { + this.checkboxes.get().forEach(checkbox => { + checkbox.checked.set(null); + }); + } } class RefListRenderer extends BaseFieldRenderer { - public input(field: FormField) { - const choices: [number, CellValue][] = field.refValues ?? []; + protected checkboxes: MutableObsArray<{ + label: string; + value: string; + checked: Observable + }> = this.autoDispose(obsArray()); + + public constructor(field: FormField, context: FormRendererContext) { + super(field, context); + + const references = this.field.refValues ?? []; // Sort by the second value, which is the display value. - choices.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); + references.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); // Support for 30 choices. TODO: make limit dynamic. - choices.splice(30); - const required = field.options.formRequired; + references.splice(30); + this.checkboxes.set(references.map(reference => ({ + label: String(reference[1]), + value: String(reference[0]), + checked: Observable.create(this, null), + }))); + } + public input() { + const required = this.field.options.formRequired; return css.checkboxList( dom.cls('grist-checkbox-list'), dom.cls('required', Boolean(required)), - {name: this.name(field), required}, - choices.map(choice => css.checkbox( - dom('input', { - type: 'checkbox', - 'data-grist-type': field.type, - name: `${this.name(field)}[]`, - value: String(choice[0]), - }), - dom('span', String(choice[1] ?? '')), - )), + {name: this.name(), required}, + dom.forEach(this.checkboxes, (checkbox) => + css.checkbox( + dom('input', + dom.prop('checked', checkbox.checked), + dom.on('change', (_e, elem) => checkbox.checked.set(elem.value)), + { + type: 'checkbox', + 'data-grist-type': this.field.type, + name: `${this.name()}[]`, + value: checkbox.value, + } + ), + dom('span', checkbox.label), + ) + ), ); } + + public resetInput(): void { + this.checkboxes.get().forEach(checkbox => { + checkbox.checked.set(null); + }); + } } class RefRenderer extends BaseFieldRenderer { - public input(field: FormField) { - const choices: [number|string, CellValue][] = field.refValues ?? []; + protected value = Observable.create(this, ''); + private _selectElement: HTMLElement; + private _ctl?: PopupControl; + + public input() { + const choices: [number|string, CellValue][] = this.field.refValues ?? []; // Sort by the second value, which is the display value. choices.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); // Support for 1000 choices. TODO: make limit dynamic. choices.splice(1000); - // Insert empty option. - choices.unshift(['', CHOOSE_TEXT]); - return css.select( - { - name: this.name(field), - 'data-grist-type': field.type, - required: field.options.formRequired, - }, - choices.map((choice) => dom('option', {value: String(choice[0])}, String(choice[1] ?? ''))), + return css.hybridSelect( + this._selectElement = css.select( + { + name: this.name(), + 'data-grist-type': this.field.type, + required: this.field.options.formRequired, + }, + dom.prop('value', this.value), + dom.on('input', (_e, elem) => this.value.set(elem.value)), + dom('option', {value: ''}, SELECT_PLACEHOLDER), + choices.map((choice) => dom('option', {value: String(choice[0])}, String(choice[1]))), + dom.onKeyDown({ + ' $': (ev) => this._maybeOpenSearchSelect(ev), + ArrowUp$: (ev) => this._maybeOpenSearchSelect(ev), + ArrowDown$: (ev) => this._maybeOpenSearchSelect(ev), + }), + ), + dom.maybe(use => !use(isXSmallScreenObs()), () => + css.searchSelect( + dom('div', dom.text(use => { + const choice = choices.find((c) => String(c[0]) === use(this.value)); + return String(choice?.[1] || SELECT_PLACEHOLDER); + })), + dropdownWithSearch({ + action: (value) => this.value.set(value), + options: () => [ + {label: SELECT_PLACEHOLDER, value: '', placeholder: true}, + ...choices.map((choice) => ({ + label: String(choice[1]), + value: String(choice[0]), + }), + )], + onClose: () => { setTimeout(() => this._selectElement.focus()); }, + acOptions: {maxResults: 1000, keepOrder: true, showEmptyItems: true}, + placeholder: 'Search', + popupOptions: { + trigger: [ + 'click', + (_el, ctl) => { this._ctl = ctl; }, + ], + }, + matchTriggerElemWidth: true, + }), + css.searchSelectIcon('Collapse'), + testId('search-select'), + ), + ) ); } + + public resetInput(): void { + this.value.set(''); + } + + private _maybeOpenSearchSelect(ev: KeyboardEvent) { + if (isXSmallScreenObs().get()) { + return; + } + + ev.preventDefault(); + ev.stopPropagation(); + this._ctl?.open(); + } } const FieldRenderers = { diff --git a/app/client/components/FormRendererCss.ts b/app/client/components/FormRendererCss.ts index 2745baa4..dc776819 100644 --- a/app/client/components/FormRendererCss.ts +++ b/app/client/components/FormRendererCss.ts @@ -1,4 +1,5 @@ -import {colors, vars} from 'app/client/ui2018/cssVars'; +import {colors, mediaXSmall, vars} from 'app/client/ui2018/cssVars'; +import {icon} from 'app/client/ui2018/icons'; import {styled} from 'grainjs'; export const label = styled('div', ` @@ -38,7 +39,36 @@ export const columns = styled('div', ` gap: 4px; `); -export const submit = styled('div', ` +export const submitButtons = styled('div', ` + display: flex; + justify-content: center; + column-gap: 8px; +`); + +export const resetButton = styled('button', ` + line-height: inherit; + font-size: ${vars.mediumFontSize}; + padding: 10px 24px; + cursor: pointer; + background-color: transparent; + color: ${vars.primaryBg}; + border: 1px solid ${vars.primaryBg}; + border-radius: 4px; + outline-color: ${vars.primaryBgHover}; + + &:hover { + color: ${vars.primaryBgHover}; + border-color: ${vars.primaryBgHover}; + } + &:disabled { + cursor: not-allowed; + color: ${colors.light}; + background-color: ${colors.slate}; + border-color: ${colors.slate}; + } +`); + +export const submitButton = styled('div', ` display: flex; justify-content: center; align-items: center; @@ -52,11 +82,18 @@ export const submit = styled('div', ` font-size: 13px; cursor: pointer; line-height: inherit; + outline-color: ${vars.primaryBgHover}; } & input[type="submit"]:hover { border-color: ${vars.primaryBgHover}; background-color: ${vars.primaryBgHover}; } + & input[type="submit"]:disabled { + cursor: not-allowed; + color: ${colors.light}; + background-color: ${colors.slate}; + border-color: ${colors.slate}; + } `); // TODO: break up into multiple variables, one for each field type. @@ -72,12 +109,10 @@ export const field = styled('div', ` padding: 4px 8px; border: 1px solid ${colors.darkGrey}; border-radius: 3px; - outline: none; + outline-color: ${vars.primaryBgHover}; } & input[type="text"] { font-size: 13px; - outline-color: ${vars.primaryBg}; - outline-width: 1px; line-height: inherit; width: 100%; color: ${colors.dark}; @@ -101,6 +136,9 @@ export const field = styled('div', ` margin-right: 8px; vertical-align: baseline; } + & input[type="checkbox"]:focus { + outline-color: ${vars.primaryBgHover}; + } & input[type="checkbox"]:checked:enabled, & input[type="checkbox"]:indeterminate:enabled { --color: ${vars.primaryBg}; @@ -171,11 +209,19 @@ export const toggle = styled('label', ` & input[type='checkbox'] { position: absolute; } + & input[type='checkbox']:focus { + outline: none; + } & > span { margin-left: 8px; } `); +export const toggleLabel = styled('span', ` + font-size: 13px; + font-weight: 700; +`); + export const gristSwitchSlider = styled('div', ` position: absolute; cursor: pointer; @@ -185,8 +231,8 @@ export const gristSwitchSlider = styled('div', ` bottom: 0; background-color: #ccc; border-radius: 17px; - -webkit-transition: .4s; - transition: .4s; + -webkit-transition: background-color .4s; + transition: background-color .4s; &:hover { box-shadow: 0 0 1px #2196F3; @@ -203,8 +249,8 @@ export const gristSwitchCircle = styled('div', ` bottom: 2px; background-color: white; border-radius: 17px; - -webkit-transition: .4s; - transition: .4s; + -webkit-transition: transform .4s; + transition: transform .4s; `); export const gristSwitch = styled('div', ` @@ -214,6 +260,11 @@ export const gristSwitch = styled('div', ` display: inline-block; flex: none; + input:focus + & > .${gristSwitchSlider.className} { + outline: 2px solid ${vars.primaryBgHover}; + outline-offset: 1px; + } + input:checked + & > .${gristSwitchSlider.className} { background-color: ${vars.primaryBg}; } @@ -239,16 +290,52 @@ export const checkbox = styled('label', ` } `); +export const hybridSelect = styled('div', ` + position: relative; +`); + export const select = styled('select', ` + position: absolute; + padding: 4px 8px; + border-radius: 3px; + border: 1px solid ${colors.darkGrey}; + font-size: 13px; + outline: none; + background: white; + line-height: inherit; + height: 27px; + flex: auto; + width: 100%; + + @media ${mediaXSmall} { + & { + outline: revert; + outline-color: ${vars.primaryBgHover}; + position: relative; + } + } +`); + +export const searchSelect = styled('div', ` + display: flex; + justify-content: space-between; + align-items: center; + position: relative; padding: 4px 8px; border-radius: 3px; border: 1px solid ${colors.darkGrey}; font-size: 13px; - outline-color: ${vars.primaryBg}; - outline-width: 1px; background: white; line-height: inherit; height: 27px; flex: auto; width: 100%; + + select:focus + & { + outline: 2px solid ${vars.primaryBgHover}; + } +`); + +export const searchSelectIcon = styled(icon, ` + flex-shrink: 0; `); diff --git a/app/client/components/Forms/Columns.ts b/app/client/components/Forms/Columns.ts index 6319a802..eda80299 100644 --- a/app/client/components/Forms/Columns.ts +++ b/app/client/components/Forms/Columns.ts @@ -9,6 +9,7 @@ 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, IDomArgs, MultiHolder, Observable, styled} from 'grainjs'; +import {v4 as uuidv4} from 'uuid'; const testId = makeTestId('test-forms-'); @@ -93,17 +94,23 @@ export class ColumnsModel extends BoxModel { const fieldsToRemove = (Array.from(this.filter(b => b instanceof FieldModel)) as FieldModel[]); const fieldIdsToRemove = fieldsToRemove.map(f => f.leaf.get()); - // Remove each child of this column from the layout. - this.children.get().forEach(child => { child.removeSelf(); }); - - // Remove this column from the layout. - this.removeSelf(); - - // Finally, remove the fields and save the changes to the layout. await this.parent?.save(async () => { + // FormView is particularly sensitive to the order that view fields and + // the form layout are modified. Specifically, if the layout is + // modified before view fields are removed, deleting a column with + // mapped fields inside seems to break. The same issue affects sections + // containing mapped fields. Reversing the order causes no such issues. + // + // TODO: narrow down why this happens and see if it's worth fixing. if (fieldIdsToRemove.length > 0) { await this.view.viewSection.removeField(fieldIdsToRemove); } + + // Remove each child of this column from the layout. + this.children.get().forEach(child => { child.removeSelf(); }); + + // Remove this column from the layout. + this.removeSelf(); }); } } @@ -218,16 +225,12 @@ export class PlaceholderModel extends BoxModel { } } -export function Paragraph(text: string, alignment?: 'left'|'right'|'center'): FormLayoutNode { - return {type: 'Paragraph', text, alignment}; -} - export function Placeholder(): FormLayoutNode { - return {type: 'Placeholder'}; + return {id: uuidv4(), type: 'Placeholder'}; } export function Columns(): FormLayoutNode { - return {type: 'Columns', children: [Placeholder(), Placeholder()]}; + return {id: uuidv4(), type: 'Columns', children: [Placeholder(), Placeholder()]}; } const cssPlaceholder = styled('div', ` diff --git a/app/client/components/Forms/Editor.ts b/app/client/components/Forms/Editor.ts index 46bb6814..9170995f 100644 --- a/app/client/components/Forms/Editor.ts +++ b/app/client/components/Forms/Editor.ts @@ -7,7 +7,7 @@ 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'; +import {BindableValue, dom, DomContents, IDomArgs, MultiHolder, Observable} from 'grainjs'; const testId = makeTestId('test-forms-'); const t = makeT('FormView.Editor'); @@ -27,9 +27,13 @@ interface Props { */ click?: (ev: MouseEvent, box: BoxModel) => void, /** - * Custom remove icon. If null, then no drop icon is shown. + * Whether to show the remove button. Defaults to true. */ - removeIcon?: IconName|null, + showRemoveButton?: BindableValue, + /** + * Custom remove icon. + */ + removeIcon?: IconName, /** * Custom remove button rendered atop overlay. */ @@ -212,7 +216,12 @@ export function buildEditor(props: Props, ...args: IDomArgs) { } await box.save(async () => { - await box.accept(dropped, wasBelow ? 'below' : 'above')?.afterDrop(); + // When a field is dragged from the creator panel, it has a colId instead of a fieldRef (as there is no + // field yet). In this case, we need to create a field first. + if (dropped.type === 'Field' && typeof dropped.leaf === 'string') { + dropped.leaf = await view.showColumn(dropped.leaf); + } + box.accept(dropped, wasBelow ? 'below' : 'above'); }); }), @@ -225,10 +234,9 @@ export function buildEditor(props: Props, ...args: IDomArgs) { testId('element'), dom.attr('data-box-model', String(box.type)), 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, + dom.maybe(props.showRemoveButton ?? true, () => [ + props.removeButton ?? dom.maybe(use => !props.editMode || !use(props.editMode), defaultRemoveButton), + ]), ...args, ); } diff --git a/app/client/components/Forms/Field.ts b/app/client/components/Forms/Field.ts index 5ad55052..d1eb0bcf 100644 --- a/app/client/components/Forms/Field.ts +++ b/app/client/components/Forms/Field.ts @@ -1,4 +1,4 @@ -import {CHOOSE_TEXT, FormLayoutNode} from 'app/client/components/FormRenderer'; +import {FormLayoutNode, SELECT_PLACEHOLDER} from 'app/client/components/FormRenderer'; import {buildEditor} from 'app/client/components/Forms/Editor'; import {FormView} from 'app/client/components/Forms/FormView'; import {BoxModel, ignoreClick} from 'app/client/components/Forms/Model'; @@ -8,6 +8,7 @@ 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 {isBlankValue} from 'app/common/gristTypes'; import {Constructor, not} from 'app/common/gutil'; import { BindableValue, @@ -102,18 +103,6 @@ export class FieldModel extends BoxModel { ); } - 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 override render(...args: IDomArgs): 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) => { @@ -287,20 +276,14 @@ class TextModel extends Question { class ChoiceModel extends Question { protected choices: Computed = Computed.create(this, use => { // Read choices from field. - const list = use(use(use(this.model.field).origCol).widgetOptionsJson.prop('choices')) || []; + const choices = use(use(this.model.field).widgetOptionsJson.prop('choices')); - // Make sure it is array of strings. - if (!Array.isArray(list) || list.some((v) => typeof v !== 'string')) { + // Make sure it is an array of strings. + if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) { return []; + } else { + return choices; } - return list; - }); - - protected choicesWithEmpty = Computed.create(this, use => { - const list: Array = Array.from(use(this.choices)); - // Add empty choice if not present. - list.unshift(null); - return list; }); public renderInput(): HTMLElement { @@ -309,21 +292,27 @@ class ChoiceModel extends Question { {tabIndex: "-1"}, ignoreClick, dom.prop('name', use => use(use(field).colId)), - dom.forEach(this.choicesWithEmpty, (choice) => dom('option', choice ?? CHOOSE_TEXT, {value: choice ?? ''})), + dom('option', SELECT_PLACEHOLDER, {value: ''}), + dom.forEach(this.choices, (choice) => dom('option', choice, {value: choice})), ); } } class ChoiceListModel extends ChoiceModel { + private _choices = Computed.create(this, use => { + // Support for 30 choices. TODO: make limit dynamic. + return use(this.choices).slice(0, 30); + }); + public renderInput() { const field = this.model.field; return dom('div', dom.prop('name', use => use(use(field).colId)), - dom.forEach(this.choices, (choice) => css.cssCheckboxLabel( + dom.forEach(this._choices, (choice) => css.cssCheckboxLabel( squareCheckbox(observable(false)), choice )), - dom.maybe(use => use(this.choices).length === 0, () => [ + dom.maybe(use => use(this._choices).length === 0, () => [ dom('div', 'No choices defined'), ]), ); @@ -382,22 +371,22 @@ class DateTimeModel extends Question { } class RefListModel extends Question { - protected choices = this._subscribeForChoices(); + protected options = this._getOptions(); public renderInput() { return dom('div', dom.prop('name', this.model.colId), - dom.forEach(this.choices, (choice) => css.cssCheckboxLabel( + dom.forEach(this.options, (option) => css.cssCheckboxLabel( squareCheckbox(observable(false)), - String(choice[1] ?? '') + option.label, )), - dom.maybe(use => use(this.choices).length === 0, () => [ - dom('div', 'No choices defined'), + dom.maybe(use => use(this.options).length === 0, () => [ + dom('div', 'No values in show column of referenced table'), ]), ) as HTMLElement; } - private _subscribeForChoices() { + private _getOptions() { const tableId = Computed.create(this, use => { const refTable = use(use(this.model.column).refTable); return refTable ? use(refTable.tableId) : ''; @@ -411,27 +400,23 @@ class RefListModel extends Question { 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 + return use(observer) + .filter(([_id, value]) => !isBlankValue(value)) + .map(([id, value]) => ({label: String(value), value: String(id)})) + .sort((a, b) => a.label.localeCompare(b.label)) + .slice(0, 30); // TODO: make limit dynamic. }); } } class RefModel extends RefListModel { - protected withEmpty = Computed.create(this, use => { - const list = Array.from(use(this.choices)); - // Add empty choice if not present. - list.unshift(['', CHOOSE_TEXT]); - return list; - }); - public renderInput() { return css.cssSelect( {tabIndex: "-1"}, ignoreClick, dom.prop('name', this.model.colId), - dom.forEach(this.withEmpty, (choice) => dom('option', String(choice[1] ?? ''), {value: String(choice[0])})), + dom('option', SELECT_PLACEHOLDER, {value: ''}), + dom.forEach(this.options, ({label, value}) => dom('option', label, {value})), ); } } diff --git a/app/client/components/Forms/FormView.ts b/app/client/components/Forms/FormView.ts index a76f1ec6..47931d2c 100644 --- a/app/client/components/Forms/FormView.ts +++ b/app/client/components/Forms/FormView.ts @@ -1,7 +1,7 @@ import BaseView from 'app/client/components/BaseView'; import * as commands from 'app/client/components/commands'; import {Cursor} from 'app/client/components/Cursor'; -import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer'; +import {FormLayoutNode, FormLayoutNodeType, patchLayoutSpec} from 'app/client/components/FormRenderer'; import * as components from 'app/client/components/Forms/elements'; import {NewBox} from 'app/client/components/Forms/Menu'; import {BoxModel, LayoutModel, parseBox, Place} from 'app/client/components/Forms/Model'; @@ -15,13 +15,16 @@ import {localStorageBoolObs} from 'app/client/lib/localStorageObs'; import {logTelemetryEvent} from 'app/client/lib/telemetry'; import DataTableModel from 'app/client/models/DataTableModel'; import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel'; +import {reportError} from 'app/client/models/errors'; import {ShareRec} from 'app/client/models/entities/ShareRec'; import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec'; import {docUrl, urlState} from 'app/client/models/gristUrlState'; import {SortedRowSet} from 'app/client/models/rowset'; -import {showTransientTooltip} from 'app/client/ui/tooltips'; +import {hoverTooltip, showTransientTooltip} from 'app/client/ui/tooltips'; import {cssButton} from 'app/client/ui2018/buttons'; import {icon} from 'app/client/ui2018/icons'; +import {loadingSpinner} from 'app/client/ui2018/loaders'; +import {menuCssClass} from 'app/client/ui2018/menus'; import {confirmModal} from 'app/client/ui2018/modals'; import {INITIAL_FIELDS_COUNT} from 'app/common/Forms'; import {isOwner} from 'app/common/roles'; @@ -31,6 +34,7 @@ import defaults from 'lodash/defaults'; import isEqual from 'lodash/isEqual'; import {v4 as uuidv4} from 'uuid'; import * as ko from 'knockout'; +import {defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel'; const t = makeT('FormView'); @@ -42,6 +46,7 @@ export class FormView extends Disposable { public viewSection: ViewSectionRec; public selectedBox: Computed; public selectedColumns: ko.Computed|null; + public disableDeleteSection: Computed; protected sortedRows: SortedRowSet; protected tableModel: DataTableModel; @@ -49,17 +54,20 @@ export class FormView extends Disposable { protected menuHolder: Holder; protected bundle: (clb: () => Promise) => Promise; + private _formFields: Computed; private _autoLayout: Computed; private _root: BoxModel; private _savedLayout: any; private _saving: boolean = false; - private _url: Computed; - private _copyingLink: Observable; + private _previewUrl: Computed; private _pageShare: Computed; private _remoteShare: AsyncComputed<{key: string}|null>; + private _isFork: Computed; private _published: Computed; private _showPublishedMessage: Observable; private _isOwner: boolean; + private _openingForm: Observable; + private _formElement: HTMLElement; public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) { BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false}); @@ -124,15 +132,22 @@ export class FormView extends Disposable { })); this.viewSection.selectedFields(this.selectedColumns.peek()); + this._formFields = Computed.create(this, use => { + const fields = use(use(this.viewSection.viewFields).getObservable()); + return fields.filter(f => use(use(f.column).isFormCol)); + }); 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) { - const fields = use(use(this.viewSection.viewFields).getObservable()); + const fields = use(this._formFields); + const layout = use(this.viewSection.layoutSpecObj); + if (!layout || !layout.id) { return this._formTemplate(fields); + } else { + const patchedLayout = patchLayoutSpec(layout, new Set(fields.map(f => f.id()))); + if (!patchedLayout) { throw new Error('Invalid form layout spec'); } + + return patchedLayout; } - return existing; }); this._root = this.autoDispose(new LayoutModel(this._autoLayout.get(), null, async (clb?: () => Promise) => { @@ -166,12 +181,7 @@ export class FormView extends Disposable { copy: () => { const selected = this.selectedBox.get(); if (!selected) { return; } - // Add this box as a json to clipboard. - const json = selected.toJSON(); - navigator.clipboard.writeText(JSON.stringify({ - ...json, - id: uuidv4(), - })).catch(reportError); + selected.copySelf().catch(reportError); }, cut: () => { const selected = this.selectedBox.get(); @@ -179,7 +189,7 @@ export class FormView extends Disposable { selected.cutSelf().catch(reportError); }, paste: () => { - const doPast = async () => { + const doPaste = async () => { const boxInClipboard = parseBox(await navigator.clipboard.readText()); if (!boxInClipboard) { return; } if (!this.selectedBox.get()) { @@ -187,13 +197,14 @@ export class FormView extends Disposable { } else { this.selectedBox.set(this.selectedBox.get()!.insertBefore(boxInClipboard)); } - // Remove the original box from the clipboard. - const cut = this._root.find(boxInClipboard.id); - cut?.removeSelf(); + const maybeCutBox = this._root.find(boxInClipboard.id); + if (maybeCutBox?.cut.get()) { + maybeCutBox.removeSelf(); + } await this._root.save(); await navigator.clipboard.writeText(''); }; - doPast().catch(reportError); + doPaste().catch(reportError); }, nextField: () => { const current = this.selectedBox.get(); @@ -242,7 +253,7 @@ export class FormView extends Disposable { }, clearValues: () => { const selected = this.selectedBox.get(); - if (!selected) { return; } + if (!selected || selected.canRemove?.() === false) { return; } keyboardActions.nextField(); this.bundle(async () => { await selected.deleteSelf(); @@ -267,6 +278,7 @@ export class FormView extends Disposable { this.addNewQuestion(selected.placeBeforeMe(), what).catch(reportError); } else { selected.insertBefore(components.defaultElement(what.structure)); + this.save().catch(reportError); } }, insertField: (what: NewBox) => { @@ -287,6 +299,7 @@ export class FormView extends Disposable { this.addNewQuestion(selected.placeAfterMe(), what).catch(reportError); } else { selected.insertAfter(components.defaultElement(what.structure)); + this.save().catch(reportError); } }, showColumns: (colIds: string[]) => { @@ -299,6 +312,7 @@ export class FormView extends Disposable { const field = this.viewSection.viewFields().all().find(f => f.getRowId() === fieldRef); if (!field) { continue; } const box = { + id: uuidv4(), leaf: fieldRef, type: 'Field' as FormLayoutNodeType, }; @@ -332,7 +346,7 @@ export class FormView extends Disposable { hideFields: keyboardActions.hideFields, }, this, this.viewSection.hasFocus)); - this._url = Computed.create(this, use => { + this._previewUrl = Computed.create(this, use => { const doc = use(this.gristDoc.docPageModel.currentDoc); if (!doc) { return ''; } const url = urlState().makeUrl({ @@ -344,8 +358,6 @@ export class FormView extends Disposable { return url; }); - this._copyingLink = Observable.create(this, false); - this._pageShare = Computed.create(this, use => { const page = use(use(this.viewSection.view).page); if (!page) { return null; } @@ -366,7 +378,15 @@ export class FormView extends Disposable { } }); + this._isFork = Computed.create(this, use => { + const {docPageModel} = this.gristDoc; + return use(docPageModel.isFork) || use(docPageModel.isPrefork); + }); + this._published = Computed.create(this, use => { + const isFork = use(this._isFork); + if (isFork) { return false; } + const pageShare = use(this._pageShare); const remoteShare = use(this._remoteShare) || use(this._remoteShare.dirty); const validShare = pageShare && remoteShare; @@ -384,6 +404,8 @@ export class FormView extends Disposable { this._isOwner = isOwner(this.gristDoc.docPageModel.currentDoc.get()); + this._openingForm = Observable.create(this, false); + // Last line, build the dom. this.viewPane = this.autoDispose(this.buildDom()); } @@ -401,7 +423,7 @@ export class FormView extends Disposable { testId('editor'), style.cssFormEditBody( style.cssFormContainer( - dom.forEach(this._root.children, (child) => { + this._formElement = dom('div', dom.forEach(this._root.children, (child) => { if (!child) { return dom('div', 'Empty node'); } @@ -410,11 +432,12 @@ export class FormView extends Disposable { throw new Error('Element is not an HTMLElement'); } return element; - }), + })), this._buildPublisher(), ), ), - dom.on('click', () => this.selectedBox.set(null)) + dom.on('click', () => this.selectedBox.set(null)), + dom.maybe(this.gristDoc.docPageModel.isReadonly, () => style.cssFormDisabledOverlay()), ); } @@ -443,6 +466,7 @@ export class FormView extends Disposable { } // And add it into the layout. this.selectedBox.set(insert({ + id: uuidv4(), leaf: fieldRef, type: 'Field' })); @@ -612,67 +636,90 @@ export class FormView extends Disposable { private _buildPublisher() { return style.cssSwitcher( - this._buildSwitcherMessage(), + this._buildNotifications(), style.cssButtonGroup( - style.cssSmallIconButton( - style.cssIconButton.cls('-frameless'), + style.cssSmallButton( + style.cssSmallButton.cls('-frameless'), icon('Revert'), testId('reset'), - dom('div', 'Reset form'), + dom('div', t('Reset form')), + dom.style('visibility', use => use(this._published) ? 'hidden' : 'visible'), dom.style('margin-right', 'auto'), // move it to the left dom.on('click', () => { - this._resetForm().catch(reportError); - }) - ), - style.cssIconLink( - testId('preview'), - 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()); - } + return confirmModal(t('Are you sure you want to reset your form?'), + t('Reset'), + () => this._resetForm(), + ); }) ), - style.cssIconButton( - icon('FieldAttachment'), - testId('link'), - dom('div', 'Copy Link'), - dom.prop('disabled', this._copyingLink), + dom.domComputed(this._published, published => { + if (published) { + return style.cssSmallButton( + testId('view'), + icon('EyeShow'), + t('View'), + dom.boolAttr('disabled', this._openingForm), + dom.on('click', async (ev) => { + // If this form is not yet saved, we will save it first. + if (!this._savedLayout) { + await this.save(); + } + + try { + this._openingForm.set(true); + window.open(await this._getFormUrl()); + } finally { + this._openingForm.set(false); + } + }) + ); + } else { + return style.cssSmallLinkButton( + testId('preview'), + icon('EyeShow'), + t('Preview'), + dom.attr('href', this._previewUrl), + 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._previewUrl.get()); + } + }) + ); + } + }), + style.cssSmallButton( + icon('Share'), + testId('share'), + dom('div', t('Share')), dom.show(use => this._isOwner && use(this._published)), - dom.on('click', async (_event, element) => { - try { - this._copyingLink.set(true); - const data = typeof ClipboardItem !== 'function' ? await this._getFormLink() : new ClipboardItem({ - "text/plain": this._getFormLink().then(text => new Blob([text], {type: 'text/plain'})), - }); - await copyToClipboard(data); - showTransientTooltip(element, 'Link copied to clipboard', {key: 'copy-form-link'}); - } catch (ex) { - if (ex.code === 'AUTH_NO_OWNER') { - throw new Error('Sharing a form is only available to owners'); - } - } finally { - this._copyingLink.set(false); - } - }), + elem => { + setPopupToCreateDom(elem, ctl => this._buildShareMenu(ctl), { + ...defaultMenuOptions, + placement: 'top-end', + }); + }, ), - dom.domComputed(this._published, published => { + dom.domComputed(use => { + const isFork = use(this._isFork); + const published = use(this._published); return published - ? style.cssIconButton( - dom('div', 'Unpublish'), + ? style.cssSmallButton( + dom('div', t('Unpublish')), dom.show(this._isOwner), - style.cssIconButton.cls('-warning'), + style.cssSmallButton.cls('-warning'), dom.on('click', () => this._handleClickUnpublish()), testId('unpublish'), ) - : style.cssIconButton( - dom('div', 'Publish'), + : style.cssSmallButton( + dom('div', t('Publish')), + dom.boolAttr('disabled', isFork), + !isFork ? null : hoverTooltip(t('Save your document to publish this form.'), { + placement: 'top', + }), dom.show(this._isOwner), cssButton.cls('-primary'), dom.on('click', () => this._handleClickPublish()), @@ -683,7 +730,7 @@ export class FormView extends Disposable { ); } - private async _getFormLink() { + private async _getFormUrl() { const share = this._pageShare.get(); if (!share) { throw new Error('Unable to get form link: form is not published'); @@ -703,7 +750,139 @@ export class FormView extends Disposable { }); } - private _buildSwitcherMessage() { + private _buildShareMenu(ctl: IOpenController) { + const formUrl = Observable.create(ctl, null); + const showEmbedCode = Observable.create(this, false); + const embedCode = Computed.create(ctl, formUrl, (_use, url) => { + if (!url) { return null; } + + return '`; + }); + + // Reposition the popup when its height changes. + ctl.autoDispose(formUrl.addListener(() => ctl.update())); + ctl.autoDispose(showEmbedCode.addListener(() => ctl.update())); + + this._getFormUrl() + .then((url) => { + if (ctl.isDisposed()) { return; } + + formUrl.set(url); + }) + .catch((e) => { + ctl.close(); + reportError(e); + }); + + return style.cssShareMenu( + dom.cls(menuCssClass), + style.cssShareMenuHeader( + style.cssShareMenuCloseButton( + icon('CrossBig'), + dom.on('click', () => ctl.close()), + ), + ), + style.cssShareMenuBody( + dom.domComputed(use => { + const url = use(formUrl); + const code = use(embedCode); + if (!url || !code) { + return style.cssShareMenuSpinner(loadingSpinner()); + } + + return [ + dom('div', + style.cssShareMenuSectionHeading( + t('Share this form'), + ), + dom('div', + style.cssShareMenuHintText( + t('Anyone with the link below can see the empty form and submit a response.'), + ), + style.cssShareMenuUrlBlock( + style.cssShareMenuUrl( + {readonly: true, value: url}, + dom.on('click', (_ev, el) => { setTimeout(() => el.select(), 0); }), + ), + style.cssShareMenuCopyButton( + testId('link'), + t('Copy link'), + dom.on('click', async (_ev, el) => { + await copyToClipboard(url); + showTransientTooltip( + el, + t('Link copied to clipboard'), + {key: 'share-form-menu'} + ); + }) + ), + ), + ), + ), + dom.domComputed(showEmbedCode, (showCode) => { + if (!showCode) { + return dom('div', + style.cssShareMenuEmbedFormButton( + t('Embed this form'), + dom.on('click', () => showEmbedCode.set(true)), + ) + ); + } else { + return dom('div', + style.cssShareMenuSectionHeading(t('Embed this form')), + dom.maybe(showEmbedCode, () => style.cssShareMenuCodeBlock( + style.cssShareMenuCode( + code, + {readonly: true, rows: '3'}, + dom.on('click', (_ev, el) => { setTimeout(() => el.select(), 0); }), + ), + style.cssShareMenuCodeBlockButtons( + style.cssShareMenuCopyButton( + testId('code'), + t('Copy code'), + dom.on('click', async (_ev, el) => { + await copyToClipboard(code); + showTransientTooltip( + el, + t('Code copied to clipboard'), + {key: 'share-form-menu'} + ); + }), + ), + ), + )), + ); + } + }), + ]; + }), + ), + ); + } + + private _getEstimatedFormHeightPx() { + return ( + // Form content height. + this._formElement.scrollHeight + + // Plus top/bottom page padding. + (2 * 52) + + // Plus top/bottom form padding. + (2 * 20) + + // Plus minimum form error height. + 38 + + // Plus form footer height. + 64 + ); + } + + private _buildNotifications() { + return [ + this._buildFormPublishedNotification(), + ]; + } + + private _buildFormPublishedNotification() { return dom.maybe(use => use(this._published) && use(this._showPublishedMessage), () => { return style.cssSwitcherMessage( style.cssSwitcherMessageBody( @@ -726,29 +905,24 @@ export class FormView extends Disposable { /** * Generates a form template based on the fields in the view section. */ - private _formTemplate(fields: ViewFieldRec[]) { + private _formTemplate(fields: ViewFieldRec[]): FormLayoutNode { const boxes: FormLayoutNode[] = fields.map(f => { return { + id: uuidv4(), type: 'Field', - leaf: f.id() - } as FormLayoutNode; + leaf: f.id(), + }; }); - const section = { - type: 'Section', - children: [ - {type: 'Paragraph', text: SECTION_TITLE}, - {type: 'Paragraph', text: SECTION_DESC}, - ...boxes, - ], - }; + const section = components.Section(...boxes); return { + id: uuidv4(), type: 'Layout', children: [ - {type: 'Paragraph', text: FORM_TITLE, alignment: 'center', }, - {type: 'Paragraph', text: FORM_DESC, alignment: 'center', }, + {id: uuidv4(), type: 'Paragraph', text: FORM_TITLE, alignment: 'center', }, + {id: uuidv4(), type: 'Paragraph', text: FORM_DESC, alignment: 'center', }, section, - {type: 'Submit'} - ] + {id: uuidv4(), type: 'Submit'}, + ], }; } @@ -758,19 +932,9 @@ export class FormView extends Disposable { // 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; } - - // Attachments are currently unsupported in forms. - if (c.pureType() === 'Attachments') { return false; } - - return true; - }); - toAdd.sort((a, b) => a.parentPos() - b.parentPos()); + const toAdd = this.viewSection.table().columns().peek() + .filter(c => c.isFormCol()) + .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()); @@ -799,6 +963,3 @@ Object.assign(FormView.prototype, BackboneEvents); // Default values when form is reset. const FORM_TITLE = "## **Form Title**"; const FORM_DESC = "Your form description goes here."; - -const SECTION_TITLE = '### **Header**'; -const SECTION_DESC = 'Description'; diff --git a/app/client/components/Forms/UnmappedFieldsConfig.ts b/app/client/components/Forms/MappedFieldsConfig.ts similarity index 97% rename from app/client/components/Forms/UnmappedFieldsConfig.ts rename to app/client/components/Forms/MappedFieldsConfig.ts index ec61feb5..651345c8 100644 --- a/app/client/components/Forms/UnmappedFieldsConfig.ts +++ b/app/client/components/Forms/MappedFieldsConfig.ts @@ -16,7 +16,7 @@ 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 { +export class MappedFieldsConfig extends Disposable { constructor(private _section: ViewSectionRec) { super(); @@ -28,7 +28,8 @@ export class UnmappedFieldsConfig extends Disposable { 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())); + const cols = this._section.table().visibleColumns() + .filter(c => c.isFormCol() && !fields.has(c.colId())); return cols.map(col => ({ col, selected: Observable.create(null, false), @@ -38,11 +39,12 @@ export class UnmappedFieldsConfig extends Disposable { if (this._section.isDisposed()) { return []; } - const cols = this._section.viewFields().map(f => f.column()); + const cols = this._section.viewFields().map(f => f.column()).all() + .filter(c => c.isFormCol()); return cols.map(col => ({ col, selected: Observable.create(null, false), - })).all(); + })); }))); const anyUnmappedSelected = Computed.create(this, use => { @@ -65,60 +67,60 @@ export class UnmappedFieldsConfig extends Disposable { return [ cssHeader( - cssFieldListHeader(t("Unmapped")), + cssFieldListHeader(dom.text(t("Mapped"))), selectAllLabel( dom.on('click', () => { - unmappedColumns.get().forEach((col) => col.selected.set(true)); + mappedColumns.get().forEach((col) => col.selected.set(true)); }), - dom.show(/* any unmapped columns */ use => use(unmappedColumns).length > 0), + dom.show(/* any mapped columns */ use => use(mappedColumns).length > 0), ), ), dom('div', - testId('hidden-fields'), - dom.forEach(unmappedColumns, (field) => { - return this._buildUnmappedField(field); + testId('visible-fields'), + dom.forEach(mappedColumns, (field) => { + return this._buildMappedField(field); }) ), - dom.maybe(anyUnmappedSelected, () => + dom.maybe(anyMappedSelected, () => cssRow( primaryButton( - dom.text(t("Map fields")), - dom.on('click', mapSelected), + dom.text(t("Unmap fields")), + dom.on('click', unMapSelected), testId('visible-hide') ), basicButton( t("Clear"), - dom.on('click', () => unmappedColumns.get().forEach((col) => col.selected.set(false))), + dom.on('click', () => mappedColumns.get().forEach((col) => col.selected.set(false))), testId('visible-clear') ), testId('visible-batch-buttons') ), ), cssHeader( - cssFieldListHeader(dom.text(t("Mapped"))), + cssFieldListHeader(t("Unmapped")), selectAllLabel( dom.on('click', () => { - mappedColumns.get().forEach((col) => col.selected.set(true)); + unmappedColumns.get().forEach((col) => col.selected.set(true)); }), - dom.show(/* any mapped columns */ use => use(mappedColumns).length > 0), + dom.show(/* any unmapped columns */ use => use(unmappedColumns).length > 0), ), ), dom('div', - testId('visible-fields'), - dom.forEach(mappedColumns, (field) => { - return this._buildMappedField(field); + testId('hidden-fields'), + dom.forEach(unmappedColumns, (field) => { + return this._buildUnmappedField(field); }) ), - dom.maybe(anyMappedSelected, () => + dom.maybe(anyUnmappedSelected, () => cssRow( primaryButton( - dom.text(t("Unmap fields")), - dom.on('click', unMapSelected), + dom.text(t("Map fields")), + dom.on('click', mapSelected), testId('visible-hide') ), basicButton( t("Clear"), - dom.on('click', () => mappedColumns.get().forEach((col) => col.selected.set(false))), + dom.on('click', () => unmappedColumns.get().forEach((col) => col.selected.set(false))), testId('visible-clear') ), testId('visible-batch-buttons') diff --git a/app/client/components/Forms/Menu.ts b/app/client/components/Forms/Menu.ts index 55aa4772..739e8c4c 100644 --- a/app/client/components/Forms/Menu.ts +++ b/app/client/components/Forms/Menu.ts @@ -49,12 +49,7 @@ export function buildMenu(props: Props, ...args: IDomArgs): IDomArg 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 normalCols = use(viewSection.hiddenColumns).filter(col => use(col.isFormCol)); const list = normalCols.map(col => { return { label: use(col.label), diff --git a/app/client/components/Forms/Model.ts b/app/client/components/Forms/Model.ts index 2b891144..2f24d456 100644 --- a/app/client/components/Forms/Model.ts +++ b/app/client/components/Forms/Model.ts @@ -2,7 +2,6 @@ import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRend import * as elements from 'app/client/components/Forms/elements'; import {FormView} from 'app/client/components/Forms/FormView'; import {bundleChanges, Computed, Disposable, dom, IDomArgs, MutableObsArray, obsArray, Observable} from 'grainjs'; -import {v4 as uuidv4} from 'uuid'; type Callback = () => Promise; @@ -33,9 +32,7 @@ export abstract class BoxModel extends Disposable { } /** - * The id of the created box. The value here is not important. It is only used as a plain old pointer to this - * element. Every new box will get a new id in constructor. Even if this is the same box as before. We just need - * it as box are serialized to JSON and put into clipboard, and we need to be able to find them back. + * The unique id of the box. */ public id: string; /** @@ -77,8 +74,7 @@ export abstract class BoxModel extends Disposable { parent.children.autoDispose(this); } - // Store "pointer" to this element. - this.id = uuidv4(); + this.id = box.id; // Create observables for all properties. this.type = box.type; @@ -93,15 +89,6 @@ export abstract class BoxModel extends Disposable { this.onCreate(); } - /** - * Public method that should be called when this box is dropped somewhere. In derived classes - * 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 afterDrop() { - - } - /** * The only method that derived classes need to implement. It should return a DOM element that * represents this box. @@ -134,12 +121,19 @@ export abstract class BoxModel extends Disposable { } /** - * Cuts self and puts it into clipboard. + * Copies self and puts it into clipboard. */ - public async cutSelf() { + public async copySelf() { [...this.root().traverse()].forEach(box => box?.cut.set(false)); // Add this box as a json to clipboard. await navigator.clipboard.writeText(JSON.stringify(this.toJSON())); + } + + /** + * Cuts self and puts it into clipboard. + */ + public async cutSelf() { + await this.copySelf(); this.cut.set(true); } @@ -339,27 +333,28 @@ export abstract class BoxModel extends Disposable { this.prop(key).set(boxDef[key]); } - // Add or delete any children that were removed or added. - const myLength = this.children.get().length; - const newLength = boxDef.children ? boxDef.children.length : 0; - if (myLength > newLength) { - this.children.splice(newLength, myLength - newLength); - } else if (myLength < newLength) { - for (let i = myLength; i < newLength; i++) { - const toPush = boxDef.children![i]; - this.children.push(toPush && BoxModel.new(toPush, this)); + // First remove any children from the model that aren't in `boxDef`. + const boxDefChildren = boxDef.children ?? []; + const boxDefChildrenIds = new Set(boxDefChildren.map(c => c.id)); + for (const child of this.children.get()) { + if (!boxDefChildrenIds.has(child.id)) { + child.removeSelf(); } } - 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]; - atIndex.update(atIndexDef); + // Then add or update the children from `boxDef` to the model. + const newChildren: BoxModel[] = []; + const modelChildrenById = new Map(this.children.get().map(c => [c.id, c])); + for (const boxDefChild of boxDefChildren) { + if (!boxDefChild.id || !modelChildrenById.has(boxDefChild.id)) { + newChildren.push(BoxModel.new(boxDefChild, this)); + } else { + const existingChild = modelChildrenById.get(boxDefChild.id)!; + existingChild.update(boxDefChild); + newChildren.push(existingChild); + } } + this.children.set(newChildren); } /** @@ -381,12 +376,18 @@ export abstract class BoxModel extends Disposable { } } + public canRemove() { + return true; + } + protected onCreate() { } } export class LayoutModel extends BoxModel { + public disableDeleteSection: Computed; + constructor( box: FormLayoutNode, public parent: BoxModel | null, @@ -394,6 +395,9 @@ export class LayoutModel extends BoxModel { public view: FormView ) { super(box, parent, view); + this.disableDeleteSection = Computed.create(this, use => { + return use(this.children).filter(c => c.type === 'Section').length === 1; + }); } public async save(clb?: Callback) { diff --git a/app/client/components/Forms/Paragraph.ts b/app/client/components/Forms/Paragraph.ts index 44fa8c1c..f62eb65d 100644 --- a/app/client/components/Forms/Paragraph.ts +++ b/app/client/components/Forms/Paragraph.ts @@ -1,10 +1,12 @@ -import * as css from './styles'; +import {FormLayoutNode} from 'app/client/components/FormRenderer'; +import {buildEditor} from 'app/client/components/Forms/Editor'; import {BoxModel} from 'app/client/components/Forms/Model'; +import * as css from 'app/client/components/Forms/styles'; 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'; +import {v4 as uuidv4} from 'uuid'; export class ParagraphModel extends BoxModel { public edit = Observable.create(this, false); @@ -60,6 +62,10 @@ export class ParagraphModel extends BoxModel { } } +export function Paragraph(text: string, alignment?: 'left'|'right'|'center'): FormLayoutNode { + return {id: uuidv4(), type: 'Paragraph', text, alignment}; +} + const cssTextArea = styled(textarea, ` color: ${theme.inputFg}; background-color: ${theme.mainPanelBg}; diff --git a/app/client/components/Forms/Section.ts b/app/client/components/Forms/Section.ts index 6e9f64bb..9d0f0106 100644 --- a/app/client/components/Forms/Section.ts +++ b/app/client/components/Forms/Section.ts @@ -1,11 +1,19 @@ +import {allCommands} from 'app/client/components/commands'; import {FormLayoutNode} from 'app/client/components/FormRenderer'; import {buildEditor} from 'app/client/components/Forms/Editor'; import {FieldModel} from 'app/client/components/Forms/Field'; +import {FormView} from 'app/client/components/Forms/FormView'; import {buildMenu} from 'app/client/components/Forms/Menu'; -import {BoxModel} from 'app/client/components/Forms/Model'; +import {BoxModel, LayoutModel} from 'app/client/components/Forms/Model'; +import {Paragraph} from 'app/client/components/Forms/Paragraph'; import * as style from 'app/client/components/Forms/styles'; import {makeTestId} from 'app/client/lib/domUtils'; +import {makeT} from 'app/client/lib/localization'; +import * as menus from 'app/client/ui2018/menus'; import {dom, styled} from 'grainjs'; +import {v4 as uuidv4} from 'uuid'; + +const t = makeT('FormView'); const testId = makeTestId('test-forms-'); @@ -13,14 +21,17 @@ const testId = makeTestId('test-forms-'); * Component that renders a section of the form. */ export class SectionModel extends BoxModel { + constructor(box: FormLayoutNode, parent: BoxModel | null, view: FormView) { + super(box, parent, view); + } + public override render(): HTMLElement { const children = this.children; 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, + showRemoveButton: use => !use((this.root() as LayoutModel).disableDeleteSection), // Content is just a list of children. content: style.cssSection( // Wrap them in a div that mutes hover events. @@ -35,6 +46,18 @@ export class SectionModel extends BoxModel { style.cssPlusIcon('Plus'), buildMenu({ box: this, + customItems: [ + menus.menuItem( + () => allCommands.insertFieldBefore.run({structure: 'Section'}), + menus.menuIcon('Section'), + t('Insert section above'), + ), + menus.menuItem( + () => allCommands.insertFieldAfter.run({structure: 'Section'}), + menus.menuIcon('Section'), + t('Insert section below'), + ), + ], }) ), ) @@ -79,19 +102,35 @@ export class SectionModel extends BoxModel { const fieldsToRemove = Array.from(this.filter(b => b instanceof FieldModel)) as FieldModel[]; const fieldIdsToRemove = fieldsToRemove.map(f => f.leaf.get()); - // Remove each child of this section from the layout. - this.children.get().forEach(child => { child.removeSelf(); }); - - // Remove this section from the layout. - this.removeSelf(); - - // Finally, remove the fields and save the changes to the layout. await this.parent?.save(async () => { + // Remove the fields. if (fieldIdsToRemove.length > 0) { await this.view.viewSection.removeField(fieldIdsToRemove); } + + // Remove each child of this section from the layout. + this.children.get().forEach(child => { child.removeSelf(); }); + + // Remove this section from the layout. + this.removeSelf(); }); } + + public canRemove() { + return !((this.parent as LayoutModel).disableDeleteSection.get()); + } +} + +export function Section(...children: FormLayoutNode[]): FormLayoutNode { + return { + id: uuidv4(), + type: 'Section', + children: [ + Paragraph('### **Header**'), + Paragraph('Description'), + ...children, + ], + }; } const cssSectionItems = styled('div.hover_border', ` diff --git a/app/client/components/Forms/elements.ts b/app/client/components/Forms/elements.ts index 1ff486de..c979e084 100644 --- a/app/client/components/Forms/elements.ts +++ b/app/client/components/Forms/elements.ts @@ -1,5 +1,8 @@ import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer'; -import {Columns, Paragraph, Placeholder} from 'app/client/components/Forms/Columns'; +import {Columns, Placeholder} from 'app/client/components/Forms/Columns'; +import {Paragraph} from 'app/client/components/Forms/Paragraph'; +import {Section} from 'app/client/components/Forms/Section'; +import {v4 as uuidv4} from 'uuid'; /** * Add any other element you whish to use in the form here. * FormView will look for any exported BoxModel derived class in format `type` + `Model`, and use It @@ -18,6 +21,7 @@ export function defaultElement(type: FormLayoutNodeType): FormLayoutNode { case 'Placeholder': return Placeholder(); case 'Separator': return Paragraph('---'); case 'Header': return Paragraph('## **Header**', 'center'); - default: return {type}; + case 'Section': return Section(); + default: return {id: uuidv4(), type}; } } diff --git a/app/client/components/Forms/styles.ts b/app/client/components/Forms/styles.ts index c126b4de..f6b921f9 100644 --- a/app/client/components/Forms/styles.ts +++ b/app/client/components/Forms/styles.ts @@ -1,6 +1,6 @@ import {textarea} from 'app/client/ui/inputs'; import {sanitizeHTML} from 'app/client/ui/sanitizeHTML'; -import {basicButton, bigBasicButton, bigBasicButtonLink} from 'app/client/ui2018/buttons'; +import {basicButton, basicButtonLink, textButton} from 'app/client/ui2018/buttons'; import {colors, theme} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {BindableValue, dom, DomElementArg, IDomArgs, Observable, styled, subscribeBindable} from 'grainjs'; @@ -239,14 +239,6 @@ export const cssSelect = styled('select', ` border-radius: 3px; outline: none; pointer-events: none; - - &-invalid { - color: ${theme.inputInvalid}; - } - &:has(option[value='']:checked) { - font-style: italic; - color: ${colors.slate}; - } `); export const cssFieldEditorContent = styled('div', ` @@ -373,49 +365,23 @@ export const cssButtonGroup = styled('div', ` `); -export const cssIconLink = styled(bigBasicButtonLink, ` +export const cssSmallLinkButton = styled(basicButtonLink, ` display: flex; align-items: center; gap: 4px; - - &-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; - } + min-height: 26px; `); -export const cssSmallIconButton = styled(basicButton, ` +export const cssSmallButton = styled(basicButton, ` display: flex; align-items: center; gap: 4px; + min-height: 26px; &-frameless { background-color: transparent; border: none; } -`); - -export const cssIconButton = styled(bigBasicButton, ` - display: flex; - align-items: center; - gap: 4px; - - &-standard { - background-color: ${theme.leftPanelBg}; - } &-warning { color: ${theme.controlPrimaryFg}; background-color: ${theme.toastWarningBg}; @@ -426,10 +392,6 @@ export const cssIconButton = styled(bigBasicButton, ` background-color: #B8791B; border: none; } - &-frameless { - background-color: transparent; - border: none; - } `); export const cssMarkdownRendered = styled('div', ` @@ -615,7 +577,7 @@ export const cssRemoveButton = styled('div', ` cursor: pointer; } .${cssFieldEditor.className}-selected > &, - .${cssFieldEditor.className}:hover > & { + .${cssFieldEditor.className}:hover:not(:has(.hover_border:hover)) > & { display: flex; } &-right { @@ -623,6 +585,124 @@ export const cssRemoveButton = styled('div', ` } `); +export const cssShareMenu = styled('div', ` + color: ${theme.text}; + background-color: ${theme.popupBg}; + width: min(calc(100% - 16px), 400px); + border-radius: 3px; + padding: 8px; +`); + +export const cssShareMenuHeader = styled('div', ` + display: flex; + justify-content: flex-end; +`); + +export const cssShareMenuBody = styled('div', ` + box-sizing: content-box; + display: flex; + flex-direction: column; + row-gap: 32px; + padding: 0px 16px 24px 16px; + min-height: 160px; +`); + +export const cssShareMenuCloseButton = styled('div', ` + flex-shrink: 0; + border-radius: 4px; + cursor: pointer; + padding: 4px; + --icon-color: ${theme.popupCloseButtonFg}; + + &:hover { + background-color: ${theme.hover}; + } +`); + +export const cssShareMenuSectionHeading = styled('div', ` + display: flex; + align-items: center; + justify-content: space-between; + font-weight: 600; + margin-bottom: 16px; +`); + +export const cssShareMenuHintText = styled('div', ` + color: ${theme.lightText}; +`); + +export const cssShareMenuSpinner = styled('div', ` + display: flex; + justify-content: center; + align-items: center; + min-height: inherit; +`); + +export const cssShareMenuSectionButtons = styled('div', ` + display: flex; + justify-content: flex-end; + margin-top: 16px; +`); + +export const cssShareMenuUrlBlock = styled('div', ` + display: flex; + background-color: ${theme.inputReadonlyBg}; + padding: 8px; + border-radius: 3px; + width: 100%; + margin-top: 16px; +`); + +export const cssShareMenuUrl = styled('input', ` + background: transparent; + flex-grow: 1; + overflow: hidden; + text-overflow: ellipsis; + border: none; + outline: none; +`); + +export const cssShareMenuCopyButton = styled(textButton, ` + margin-left: 4px; + font-weight: 500; +`); + +export const cssShareMenuEmbedFormButton = styled(textButton, ` + font-weight: 500; +`); + +export const cssShareMenuCodeBlock = styled('div', ` + border-radius: 3px; + background-color: ${theme.inputReadonlyBg}; + padding: 8px; +`); + +export const cssShareMenuCodeBlockButtons = styled('div', ` + display: flex; + justify-content: flex-end; +`); + +export const cssShareMenuCode = styled('textarea', ` + background-color: transparent; + border: none; + border-radius: 3px; + word-break: break-all; + width: 100%; + outline: none; + resize: none; +`); + +export const cssFormDisabledOverlay = styled('div', ` + background-color: ${theme.widgetBg}; + opacity: 0.8; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 100; +`); + export function saveControls(editMode: Observable, save: (ok: boolean) => void) { return [ dom.onKeyDown({ diff --git a/app/client/components/duplicatePage.ts b/app/client/components/duplicatePage.ts index 8bca51eb..a4e01775 100644 --- a/app/client/components/duplicatePage.ts +++ b/app/client/components/duplicatePage.ts @@ -129,6 +129,7 @@ async function updateViewSections(gristDoc: GristDoc, destViewSections: ViewSect ...record, layoutSpec: JSON.stringify(viewSectionLayoutSpec), linkSrcSectionRef: viewSectionMap[srcViewSection.linkSrcSectionRef.peek()], + shareOptions: '', }); } @@ -201,7 +202,7 @@ function newViewSectionAction(widget: IPageWidget, viewId: number) { */ export function patchLayoutSpec(layoutSpec: any, mapIds: {[id: number]: number}) { return cloneDeepWith(layoutSpec, (val) => { - if (typeof val === 'object') { + if (typeof val === 'object' && val !== null) { if (mapIds[val.leaf]) { return {...val, leaf: mapIds[val.leaf]}; } diff --git a/app/client/components/modals.ts b/app/client/components/modals.ts index 25c2042f..34a61442 100644 --- a/app/client/components/modals.ts +++ b/app/client/components/modals.ts @@ -321,10 +321,12 @@ const cssArrowContainer = styled('div', ` ${sideSelectorChunk('top')} > & { bottom: -17px; + margin: 0px 16px; } ${sideSelectorChunk('bottom')} > & { top: -14px; + margin: 0px 16px; } ${sideSelectorChunk('right')} > & { diff --git a/app/client/lib/ACIndex.ts b/app/client/lib/ACIndex.ts index d5e2293d..96adb4c5 100644 --- a/app/client/lib/ACIndex.ts +++ b/app/client/lib/ACIndex.ts @@ -15,7 +15,6 @@ import split = require("lodash/split"); export interface ACItem { // This should be a trimmed lowercase version of the item's text. It may be an accessor. - // Note that items with empty cleanText are never suggested. cleanText: string; } @@ -65,6 +64,19 @@ interface Word { pos: number; // Position of the word within the item where it occurred. } +export interface ACIndexOptions { + /** The max number of items to suggest. Defaults to 50. */ + maxResults?: number; + /** + * Suggested matches in the same relative order as items, rather than by score. + * + * Defaults to false. + */ + keepOrder?: boolean; + /** Show items with an empty `cleanText`. Defaults to false. */ + showEmptyItems?: boolean; +} + /** * Implements a search index. It doesn't currently support updates; when any values change, the * index needs to be rebuilt from scratch. @@ -75,11 +87,12 @@ export class ACIndexImpl implements ACIndex { // All words from _allItems, sorted. private _words: Word[]; + private _maxResults = this._options.maxResults ?? 50; + private _keepOrder = this._options.keepOrder ?? false; + private _showEmptyItems = this._options.showEmptyItems ?? false; + // Creates an index for the given list of items. - // The max number of items to suggest may be set using _maxResults (default is 50). - // If _keepOrder is true, best matches will be suggested in the order they occur in items, - // rather than order by best score. - constructor(items: Item[], private _maxResults: number = 50, private _keepOrder = false) { + constructor(items: Item[], private _options: ACIndexOptions = {}) { this._allItems = items.slice(0); // Collects [word, occurrence, position] tuples for all words in _allItems. @@ -132,7 +145,9 @@ export class ACIndexImpl implements ACIndex { // Append enough non-matching indices to reach maxResults. for (let i = 0; i < this._allItems.length && itemIndices.length < this._maxResults; i++) { - if (this._allItems[i].cleanText && !myMatches.has(i)) { + if (myMatches.has(i)) { continue; } + + if (this._allItems[i].cleanText || this._showEmptyItems) { itemIndices.push(i); } } diff --git a/app/client/lib/simpleList.ts b/app/client/lib/simpleList.ts index 901e3ec6..5a71a084 100644 --- a/app/client/lib/simpleList.ts +++ b/app/client/lib/simpleList.ts @@ -22,6 +22,7 @@ export type { IOption, IOptionFull } from 'popweasel'; export { getOptionFull } from 'popweasel'; export interface ISimpleListOpt = IOption> { + matchTriggerElemWidth?: boolean; headerDom?(): DomArg; renderItem?(item: U): DomArg; } @@ -42,6 +43,14 @@ export class SimpleList = IOption> extends Disposable const renderItem = opt.renderItem || ((item: U) => getOptionFull(item).label); this.content = cssMenuWrap( dom('div', + elem => { + if (opt.matchTriggerElemWidth) { + const style = elem.style; + style.minWidth = _ctl.getTriggerElem().getBoundingClientRect().width + 'px'; + style.marginLeft = '0px'; + style.marginRight = '0px'; + } + }, {class: menuCssClass + ' grist-floating-menu'}, cssMenu.cls(''), cssMenuExt.cls(''), @@ -113,7 +122,7 @@ export class SimpleList = IOption> extends Disposable private _doAction(value: T | null) { // If value is null, simply close the menu. This happens when pressing enter with no element // selected. - if (value) { this._action(value); } + if (value !== null) { this._action(value); } this._ctl.close(); } diff --git a/app/client/models/FormModel.ts b/app/client/models/FormModel.ts index ef347c33..a6bb4abe 100644 --- a/app/client/models/FormModel.ts +++ b/app/client/models/FormModel.ts @@ -1,4 +1,4 @@ -import {FormLayoutNode} from 'app/client/components/FormRenderer'; +import {FormLayoutNode, patchLayoutSpec} from 'app/client/components/FormRenderer'; import {TypedFormData, typedFormDataToJson} from 'app/client/lib/formUtils'; import {makeT} from 'app/client/lib/localization'; import {getHomeUrl} from 'app/client/models/AppModel'; @@ -25,7 +25,13 @@ export class FormModelImpl extends Disposable implements FormModel { public readonly formLayout = Computed.create(this, this.form, (_use, form) => { if (!form) { return null; } - return safeJsonParse(form.formLayoutSpec, null) as FormLayoutNode; + const layout = safeJsonParse(form.formLayoutSpec, null) as FormLayoutNode | null; + if (!layout) { throw new Error('invalid formLayoutSpec'); } + + const patchedLayout = patchLayoutSpec(layout, new Set(Object.keys(form.formFieldsById).map(Number))); + if (!patchedLayout) { throw new Error('invalid formLayoutSpec'); } + + return patchedLayout; }); public readonly submitting = Observable.create(this, false); public readonly submitted = Observable.create(this, false); diff --git a/app/client/models/entities/ColumnRec.ts b/app/client/models/entities/ColumnRec.ts index e3cc1a2f..62c73834 100644 --- a/app/client/models/entities/ColumnRec.ts +++ b/app/client/models/entities/ColumnRec.ts @@ -68,6 +68,7 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> { disableEditData: ko.Computed; // True to disable editing of the data in this column. isHiddenCol: ko.Computed; + isFormCol: ko.Computed; // Returns the rowModel for the referenced table, or null, if is not a reference column. refTable: ko.Computed; @@ -144,6 +145,11 @@ export function createColumnRec(this: ColumnRec, docModel: DocModel): void { this.disableEditData = ko.pureComputed(() => Boolean(this.summarySourceCol())); this.isHiddenCol = ko.pureComputed(() => gristTypes.isHiddenCol(this.colId())); + this.isFormCol = ko.pureComputed(() => ( + !this.isHiddenCol() && + this.pureType() !== 'Attachments' && + !this.isRealFormula() + )); // Returns the rowModel for the referenced table, or null, if this is not a reference column. this.refTable = ko.pureComputed(() => { diff --git a/app/client/ui/DocumentSettings.ts b/app/client/ui/DocumentSettings.ts index b6cb96e3..6e20b94d 100644 --- a/app/client/ui/DocumentSettings.ts +++ b/app/client/ui/DocumentSettings.ts @@ -130,7 +130,7 @@ function buildLocaleSelect( locale: l.code, cleanText: l.name.trim().toLowerCase(), })).sort(propertyCompare("label")); - const acIndex = new ACIndexImpl(localeList, 200, true); + const acIndex = new ACIndexImpl(localeList, {maxResults: 200, keepOrder: true}); // AC select will show the value (in this case locale) not a label when something is selected. // To show the label - create another observable that will be in sync with the value, but // will contain text. diff --git a/app/client/ui/FormAPI.ts b/app/client/ui/FormAPI.ts index 415d3fc0..51bd2c15 100644 --- a/app/client/ui/FormAPI.ts +++ b/app/client/ui/FormAPI.ts @@ -106,7 +106,9 @@ export class FormAPIImpl extends BaseAPI implements FormAPI { }); } else { const {shareKey, tableId, colValues} = options; - return this.requestJson(`${this._url}/api/s/${shareKey}/tables/${tableId}/records`, { + const url = new URL(`${this._url}/api/s/${shareKey}/tables/${tableId}/records`); + url.searchParams.set('utm_source', 'grist-forms'); + return this.requestJson(url.href, { method: 'POST', body: JSON.stringify({records: [{fields: colValues}]}), }); diff --git a/app/client/ui/PageWidgetPicker.ts b/app/client/ui/PageWidgetPicker.ts index 9a6678e8..fed425f4 100644 --- a/app/client/ui/PageWidgetPicker.ts +++ b/app/client/ui/PageWidgetPicker.ts @@ -398,7 +398,7 @@ export class PageWidgetSelect extends Disposable { this._behavioralPromptsManager.attachPopup('pageWidgetPickerSelectBy', { popupOptions: { attach: null, - placement: 'bottom', + placement: 'bottom-start', } }), ]}, diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index 7f41d7f0..4ff41a60 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -16,7 +16,7 @@ import * as commands from 'app/client/components/commands'; 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 {MappedFieldsConfig} from 'app/client/components/Forms/MappedFieldsConfig'; import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc'; import {EmptyFilterState} from "app/client/components/LinkingState"; import {RefSelect} from 'app/client/components/RefSelect'; @@ -559,7 +559,7 @@ export class RightPanel extends Disposable { dom.maybe(this._isForm, () => [ cssSeparator(), - dom.create(UnmappedFieldsConfig, activeSection), + dom.create(MappedFieldsConfig, activeSection), ]), ]); } @@ -996,19 +996,11 @@ export class RightPanel extends Disposable { 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 selectedBoxWithOptions = 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; - } + if (!box || !['Paragraph', 'Label'].includes(box.type)) { return null; } + + return box; }); return domAsync(imports.loadViewPane().then(() => buildConfigContainer(cssSection( @@ -1036,24 +1028,12 @@ export class RightPanel extends Disposable { testId('field-label'), ), ), - // 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, builder => [ cssSeparator(), cssLabel(t("COLUMN TYPE")), cssSection( builder.buildSelectTypeDom(), ), - // V2 thing - // cssSection( - // builder.buildSelectWidgetDom(), - // ), cssSection( builder.buildFormConfigDom(), ), @@ -1062,36 +1042,44 @@ export class RightPanel extends Disposable { }), // Box config - dom.maybe(use => use(selectedColumn) ? null : use(selectedBox), (box) => [ + dom.maybe(selectedBoxWithOptions, (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( + 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))), - ) - ]), + ), + 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')), + dom.maybe(u => !u(selectedField) && !u(selectedBoxWithOptions), () => [ + buildFormConfigPlaceholder(), ]) )))); } } +function buildFormConfigPlaceholder() { + return cssFormConfigPlaceholder( + cssFormConfigImg(), + cssFormConfigMessage( + cssFormConfigMessageTitle(t('No field selected')), + dom('div', t('Select a field in the form widget to configure.')), + ) + ); +} + function disabledSection() { return cssOverlay( testId('panel-disabled-section'), @@ -1429,3 +1417,33 @@ const cssLinkInfoPre = styled("pre", ` font-size: ${vars.smallFontSize}; line-height: 1.2; `); + +const cssFormConfigPlaceholder = styled('div', ` + display: flex; + flex-direction: column; + row-gap: 16px; + margin-top: 32px; + padding: 8px; +`); + +const cssFormConfigImg = styled('div', ` + height: 140px; + width: 100%; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + background-image: var(--icon-FormConfig); +`); + +const cssFormConfigMessage = styled('div', ` + display: flex; + flex-direction: column; + row-gap: 8px; + color: ${theme.text}; + text-align: center; +`); + +const cssFormConfigMessageTitle = styled('div', ` + font-size: ${vars.largeFontSize}; + font-weight: 600; +`); diff --git a/app/client/ui/searchDropdown.ts b/app/client/ui/searchDropdown.ts index 4884ec7b..b618f7bd 100644 --- a/app/client/ui/searchDropdown.ts +++ b/app/client/ui/searchDropdown.ts @@ -4,7 +4,8 @@ import { Disposable, dom, DomElementMethod, IOptionFull, makeTestId, Observable, styled } from "grainjs"; import { theme, vars } from 'app/client/ui2018/cssVars'; -import { ACIndexImpl, ACItem, buildHighlightedDom, HighlightFunc, normalizeText } from "app/client/lib/ACIndex"; +import { ACIndexImpl, ACIndexOptions, ACItem, buildHighlightedDom, HighlightFunc, + normalizeText } from "app/client/lib/ACIndex"; import { menuDivider } from "app/client/ui2018/menus"; import { icon } from "app/client/ui2018/icons"; import { cssMenuItem, defaultMenuOptions, IOpenController, IPopupOptions, setPopupToFunc } from "popweasel"; @@ -28,20 +29,54 @@ export interface IDropdownWithSearchOptions { // list of options options: () => Array>, + /** Called when the dropdown menu is disposed. */ + onClose?: () => void; + // place holder for the search input. Default to 'Search' placeholder?: string; // popup options popupOptions?: IPopupOptions; + + /** ACIndexOptions to use for indexing and searching items. */ + acOptions?: ACIndexOptions; + + /** + * If set, the width of the dropdown menu will be equal to that of + * the trigger element. + */ + matchTriggerElemWidth?: boolean; +} + +export interface OptionItemParams { + /** Item label. Normalized and used by ACIndex for indexing and searching. */ + label: string; + /** Item value. */ + value: T; + /** Defaults to false. */ + disabled?: boolean; + /** + * If true, marks this item as the "placeholder" item. + * + * The placeholder item is excluded from indexing, so it's label doesn't + * match search inputs. However, it's still shown when the search input is + * empty. + * + * Defaults to false. + */ + placeholder?: boolean; } export class OptionItem implements ACItem, IOptionFull { - public cleanText: string = normalizeText(this.label); - constructor( - public label: string, - public value: T, - public disabled?: boolean - ) {} + public label = this._params.label; + public value = this._params.value; + public disabled = this._params.disabled; + public placeholder = this._params.placeholder; + public cleanText = this.placeholder ? '' : normalizeText(this.label); + + constructor(private _params: OptionItemParams) { + + } } export function dropdownWithSearch(options: IDropdownWithSearchOptions): DomElementMethod { @@ -52,7 +87,7 @@ export function dropdownWithSearch(options: IDropdownWithSearchOptions): D ); setPopupToFunc( elem, - (ctl) => DropdownWithSearch.create(null, ctl, options), + (ctl) => (DropdownWithSearch).create(null, ctl, options), popupOptions ); }; @@ -68,8 +103,8 @@ class DropdownWithSearch extends Disposable { constructor(private _ctl: IOpenController, private _options: IDropdownWithSearchOptions) { super(); - const acItems = _options.options().map(getOptionFull).map(o => new OptionItem(o.label, o.value, o.disabled)); - this._acIndex = new ACIndexImpl>(acItems); + const acItems = _options.options().map(getOptionFull).map((params) => new OptionItem(params)); + this._acIndex = new ACIndexImpl>(acItems, this._options.acOptions); this._items = Observable.create[]>(this, acItems); this._highlightFunc = () => []; this._simpleList = this._buildSimpleList(); @@ -77,6 +112,7 @@ class DropdownWithSearch extends Disposable { this._update(); // auto-focus the search input setTimeout(() => this._inputElem.focus(), 1); + this._ctl.onDispose(() => _options.onClose?.()); } public get content(): HTMLElement { @@ -87,7 +123,11 @@ class DropdownWithSearch extends Disposable { const action = this._action.bind(this); const headerDom = this._buildHeader.bind(this); const renderItem = this._buildItem.bind(this); - return SimpleList.create(this, this._ctl, this._items, action, {headerDom, renderItem}); + return (SimpleList).create(this, this._ctl, this._items, action, { + matchTriggerElemWidth: this._options.matchTriggerElemWidth, + headerDom, + renderItem, + }); } private _buildHeader() { @@ -110,7 +150,9 @@ class DropdownWithSearch extends Disposable { private _buildItem(item: OptionItem) { return [ - buildHighlightedDom(item.label, this._highlightFunc, cssMatchText), + item.placeholder + ? cssPlaceholderItem(item.label) + : buildHighlightedDom(item.label, this._highlightFunc, cssMatchText), testId('searchable-list-item'), ]; } @@ -125,7 +167,7 @@ class DropdownWithSearch extends Disposable { private _action(value: T | null) { // If value is null, simply close the menu. This happens when pressing enter with no element // selected. - if (value) { + if (value !== null) { this._options.action(value); } this._ctl.close(); @@ -171,3 +213,10 @@ const cssMenuDivider = styled(menuDivider, ` flex-shrink: 0; margin: 0; `); +const cssPlaceholderItem = styled('div', ` + color: ${theme.inputPlaceholderFg}; + + .${cssMenuItem.className}-sel > & { + color: ${theme.menuItemSelectedFg}; + } +`); diff --git a/app/client/ui2018/IconList.ts b/app/client/ui2018/IconList.ts index a07a0c36..c0fcffbf 100644 --- a/app/client/ui2018/IconList.ts +++ b/app/client/ui2018/IconList.ts @@ -76,6 +76,7 @@ export type IconName = "ChartArea" | "FontItalic" | "FontStrikethrough" | "FontUnderline" | + "FormConfig" | "FunctionResult" | "GreenArrow" | "Grow" | @@ -232,6 +233,7 @@ export const IconList: IconName[] = ["ChartArea", "FontItalic", "FontStrikethrough", "FontUnderline", + "FormConfig", "FunctionResult", "GreenArrow", "Grow", diff --git a/app/client/ui2018/cssVars.ts b/app/client/ui2018/cssVars.ts index 5e862432..deab8233 100644 --- a/app/client/ui2018/cssVars.ts +++ b/app/client/ui2018/cssVars.ts @@ -983,6 +983,22 @@ export function isNarrowScreenObs(): Observable { return _isNarrowScreenObs; } +export function isXSmallScreen() { + return window.innerWidth < smallScreenWidth; +} + +let _isXSmallScreenObs: Observable|undefined; + +// Returns a singleton observable for whether the screen is an extra small one. +export function isXSmallScreenObs(): Observable { + if (!_isXSmallScreenObs) { + const obs = Observable.create(null, isXSmallScreen()); + window.addEventListener('resize', () => obs.set(isXSmallScreen())); + _isXSmallScreenObs = obs; + } + return _isXSmallScreenObs; +} + export const cssHideForNarrowScreen = styled('div', ` @media ${mediaSmall} { & { diff --git a/app/client/ui2018/modals.ts b/app/client/ui2018/modals.ts index 2aaa3f87..4ed74ab6 100644 --- a/app/client/ui2018/modals.ts +++ b/app/client/ui2018/modals.ts @@ -9,6 +9,7 @@ import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars'; import {loadingSpinner} from 'app/client/ui2018/loaders'; import {cssMenuElem} from 'app/client/ui2018/menus'; import {waitGrainObs} from 'app/common/gutil'; +import {MaybePromise} from 'app/plugin/gutil'; import {Computed, Disposable, dom, DomContents, DomElementArg, input, keyframes, MultiHolder, Observable, styled} from 'grainjs'; import {IOpenController, IPopupOptions, PopupControl, popupOpen} from 'popweasel'; @@ -356,7 +357,7 @@ export interface ConfirmModalOptions { export function confirmModal( title: DomElementArg, btnText: DomElementArg, - onConfirm: (dontShowAgain?: boolean) => Promise, + onConfirm: (dontShowAgain?: boolean) => MaybePromise, options: ConfirmModalOptions = {}, ): void { const { @@ -383,7 +384,7 @@ export function confirmModal( ), ], saveLabel: btnText, - saveFunc: () => onConfirm(hideDontShowAgain ? undefined : dontShowAgain.get()), + saveFunc: async () => onConfirm(hideDontShowAgain ? undefined : dontShowAgain.get()), hideCancel, width: width ?? 'normal', extraButtons, diff --git a/app/client/widgets/CurrencyPicker.ts b/app/client/widgets/CurrencyPicker.ts index 3551d082..266e2387 100644 --- a/app/client/widgets/CurrencyPicker.ts +++ b/app/client/widgets/CurrencyPicker.ts @@ -35,7 +35,7 @@ export function buildCurrencyPicker( // Create a computed that will display 'Local currency' as a value and label // when `currency` is undefined. const valueObs = Computed.create(owner, (use) => use(currency) || defaultCurrencyLabel); - const acIndex = new ACIndexImpl(currencyItems, 200, true); + const acIndex = new ACIndexImpl(currencyItems, {maxResults: 200, keepOrder: true}); return buildACSelect(owner, { acIndex, valueObs, diff --git a/app/client/widgets/FieldBuilder.ts b/app/client/widgets/FieldBuilder.ts index 1ca94636..ef41d1a8 100644 --- a/app/client/widgets/FieldBuilder.ts +++ b/app/client/widgets/FieldBuilder.ts @@ -34,6 +34,7 @@ import * as UserType from 'app/client/widgets/UserType'; import * as UserTypeImpl from 'app/client/widgets/UserTypeImpl'; import * as gristTypes from 'app/common/gristTypes'; import { getReferencedTableId, isFullReferencingType } from 'app/common/gristTypes'; +import { WidgetType } from 'app/common/widgetTypes'; import { CellValue } from 'app/plugin/GristData'; import { bundleChanges, Computed, Disposable, fromKo, dom as grainjsDom, makeTestId, MultiHolder, Observable, styled, toKo } from 'grainjs'; @@ -129,9 +130,15 @@ export class FieldBuilder extends Disposable { // Observable with a list of available types. this._availableTypes = Computed.create(this, (use) => { + const isForm = use(use(this.field.viewSection).widgetType) === WidgetType.Form; const isFormula = use(this.origColumn.isFormula); const types: Array> = []; _.each(UserType.typeDefs, (def: any, key: string|number) => { + if (isForm && key === 'Attachments') { + // Attachments in forms are currently unsupported. + return; + } + const o: IOptionFull = { value: key as string, label: def.label, diff --git a/app/client/widgets/TZAutocomplete.ts b/app/client/widgets/TZAutocomplete.ts index 8dc108f9..1e40d490 100644 --- a/app/client/widgets/TZAutocomplete.ts +++ b/app/client/widgets/TZAutocomplete.ts @@ -52,7 +52,10 @@ export function buildTZAutocomplete( ) { // Set a large maxResults, since it's sometimes nice to see all supported timezones (there are // fewer than 1000 in practice). - const acIndex = new ACIndexImpl(timezoneOptions(moment), 1000, true); + const acIndex = new ACIndexImpl(timezoneOptions(moment), { + maxResults: 1000, + keepOrder: true, + }); // Only save valid time zones. If there is no selected item, we'll auto-select and save only // when there is a good match. diff --git a/app/common/Telemetry.ts b/app/common/Telemetry.ts index 7acf07eb..f2be644e 100644 --- a/app/common/Telemetry.ts +++ b/app/common/Telemetry.ts @@ -1684,6 +1684,38 @@ export const TelemetryContracts: TelemetryContracts = { }, }, }, + submittedForm: { + category: 'WidgetUsage', + description: 'Triggered when a published form is submitted.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + docIdDigest: { + description: 'A hash of the doc id.', + dataType: 'string', + }, + siteId: { + description: 'The site id.', + dataType: 'number', + }, + siteType: { + description: 'The site type.', + dataType: 'string', + }, + altSessionId: { + description: 'A random, session-based identifier for the user that triggered this event.', + dataType: 'string', + }, + access: { + description: 'The document access level of the user that triggered this event.', + dataType: 'string', + }, + userId: { + description: 'The id of the user that triggered this event.', + dataType: 'number', + }, + }, + }, changedAccessRules: { category: 'AccessRules', description: 'Triggered when a change to access rules is saved.', @@ -1776,6 +1808,7 @@ export const TelemetryEvents = StringUnion( 'publishedForm', 'unpublishedForm', 'visitedForm', + 'submittedForm', 'changedAccessRules', ); export type TelemetryEvent = typeof TelemetryEvents.type; diff --git a/app/common/gristTypes.ts b/app/common/gristTypes.ts index 9f68807e..f0c98b9c 100644 --- a/app/common/gristTypes.ts +++ b/app/common/gristTypes.ts @@ -169,6 +169,13 @@ export function isEmptyList(value: CellValue): boolean { return Array.isArray(value) && value.length === 1 && value[0] === GristObjCode.List; } +/** + * Returns whether a value (as received in a DocAction) represents an empty reference list. + */ +export function isEmptyReferenceList(value: CellValue): boolean { + return Array.isArray(value) && value.length === 1 && value[0] === GristObjCode.ReferenceList; +} + function isNumber(v: CellValue) { return typeof v === 'number' || typeof v === 'boolean'; } function isNumberOrNull(v: CellValue) { return isNumber(v) || v === null; } function isBoolean(v: CellValue) { return typeof v === 'boolean' || v === 1 || v === 0; } @@ -344,6 +351,21 @@ export function isValidRuleValue(value: CellValue|undefined) { return value === null || typeof value === 'boolean'; } +/** + * Returns true if `value` is blank. + * + * Blank values include `null`, (trimmed) empty string, and 0-length lists and + * reference lists. + */ +export function isBlankValue(value: CellValue) { + return ( + value === null || + (typeof value === 'string' && value.trim().length === 0) || + isEmptyList(value) || + isEmptyReferenceList(value) + ); +} + export type RefListValue = [GristObjCode.List, ...number[]]|null; /** diff --git a/app/server/lib/ActiveDocImport.ts b/app/server/lib/ActiveDocImport.ts index 79f69dd9..038b27fa 100644 --- a/app/server/lib/ActiveDocImport.ts +++ b/app/server/lib/ActiveDocImport.ts @@ -10,6 +10,7 @@ import {ApplyUAResult, DataSourceTransformed, ImportOptions, ImportResult, Impor TransformRuleMap} from 'app/common/ActiveDocAPI'; import {ApiError} from 'app/common/ApiError'; import {BulkColValues, CellValue, fromTableDataAction, UserAction} from 'app/common/DocActions'; +import {isBlankValue} from 'app/common/gristTypes'; import * as gutil from 'app/common/gutil'; import {localTimestampToUTC} from 'app/common/RelativeDates'; import {DocStateComparison} from 'app/common/UserAPI'; @@ -667,12 +668,6 @@ export class ActiveDocImport { } } -// Helper function that returns true if a given cell is blank (i.e. null or empty). -function isBlank(value: CellValue): boolean { - return value === null || (typeof value === 'string' && value.trim().length === 0); -} - - // Helper function that returns new `colIds` with import prefixes stripped. function stripPrefixes(colIds: string[]): string[] { return colIds.map(id => id.startsWith(IMPORT_TRANSFORM_COLUMN_PREFIX) ? @@ -691,13 +686,13 @@ type MergeFunction = (srcVal: CellValue, destVal: CellValue) => CellValue; function getMergeFunction({type}: MergeStrategy): MergeFunction { switch (type) { case 'replace-with-nonblank-source': { - return (srcVal, destVal) => isBlank(srcVal) ? destVal : srcVal; + return (srcVal, destVal) => isBlankValue(srcVal) ? destVal : srcVal; } case 'replace-all-fields': { return (srcVal, _destVal) => srcVal; } case 'replace-blank-fields-only': { - return (srcVal, destVal) => isBlank(destVal) ? srcVal : destVal; + return (srcVal, destVal) => isBlankValue(destVal) ? srcVal : destVal; } default: { // Normally, we should never arrive here. If we somehow do, throw an error. diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index f1ebe072..350fc8a4 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -12,7 +12,7 @@ import { UserAction } from 'app/common/DocActions'; import {DocData} from 'app/common/DocData'; -import {extractTypeFromColType, isFullReferencingType, isRaisedException} from "app/common/gristTypes"; +import {extractTypeFromColType, isBlankValue, isFullReferencingType, isRaisedException} from "app/common/gristTypes"; import {INITIAL_FIELDS_COUNT} from "app/common/Forms"; import {buildUrlId, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls"; import {isAffirmative, safeJsonParse, timeoutReached} from "app/common/gutil"; @@ -573,6 +573,9 @@ export class DocWorkerApi { validateCore(RecordsPost, req, body); const ops = await getTableOperations(req, activeDoc); const records = await ops.create(body.records); + if (req.query.utm_source === 'grist-forms') { + activeDoc.logTelemetryEvent(docSessionFromRequest(req), 'submittedForm'); + } res.json({records}); }) ); @@ -1422,7 +1425,7 @@ export class DocWorkerApi { .filter(f => { const col = Tables_column.getRecord(f.colRef); // Formulas and attachments are currently unsupported. - return col && !(col.isFormula && col.formula) && col.type !== 'Attachment'; + return col && !(col.isFormula && col.formula) && col.type !== 'Attachments'; }); let {layoutSpec: formLayoutSpec} = section; @@ -1474,7 +1477,8 @@ export class DocWorkerApi { if (!refTableId || !refColId) { return () => []; } if (typeof refTableId !== 'string' || typeof refColId !== 'string') { return []; } - return await getTableValues(refTableId, refColId); + const values = await getTableValues(refTableId, refColId); + return values.filter(([_id, value]) => !isBlankValue(value)); }; const formFields = await Promise.all(fields.map(async (field) => { diff --git a/static/icons/icons.css b/static/icons/icons.css index acf45565..1a87652f 100644 --- a/static/icons/icons.css +++ b/static/icons/icons.css @@ -77,6 +77,7 @@ --icon-FontItalic: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDEyVjEySDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PGcgdHJhbnNmb3JtPSJtYXRyaXgoMS4xMzU2IDAgMCAxLjEzNTYgLjk4MyAxLjM5KSIgY2xpcC1wYXRoPSJ1cmwoI2EpIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGZpbGw9Im5vbmUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0ibSA0Ljg3NSwwLjM3NSBoIDQuNSIgc3Ryb2tlPSIjMDAwIi8+PHBhdGggZD0ibSAyLjYyNSwxMS42MjUgaCA0LjUiIHN0cm9rZT0iIzA2MDAwMCIvPjxwYXRoIGQ9Im0gNy4xMjUsMC4zNzUgLTIuMjUsMTEuMjUiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLW9wYWNpdHk9Ii45OSIvPjwvZz48L3N2Zz4='); --icon-FontStrikethrough: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTSAxLjgwNTY1ODUsMy4zOTcxNzIgViAxLjczNTc0MzEgSCAxNC4xOTQzNDIgViAzLjM5NzE3MiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOTI5Mjk5IiBzdHJva2Utd2lkdGg9IjEuMTQzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNIDcuOTk5OTk5OSwxMC4wNDI4ODcgViAxNC4xOTY0NiIgc3Ryb2tlPSIjOTI5Mjk5IiBzdHJva2Utd2lkdGg9IjEuMTQzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNIDcuOTk5OTk5OSwxLjczNTc0MzEgViA3LjU1MDc0NCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOTI5Mjk5IiBzdHJva2Utd2lkdGg9IjEuMTQzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNNS4zNDUyODIyIDE0LjE5NjQ2SDEwLjY1NDcxOE0xLjgwNTY1ODUgNy41NTA3NDRIMTQuMTk0MzQyIiBzdHJva2U9IiM5MjkyOTkiIHN0cm9rZS13aWR0aD0iMS4xNDMiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg=='); --icon-FontUnderline: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDEyVjEySDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PGcgdHJhbnNmb3JtPSJtYXRyaXgoMS4xNDI5NSAwIDAgMS4xNDI5NSAxLjE0MiAuNzIxKSIgY2xpcC1wYXRoPSJ1cmwoI2EpIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNLjM3NSAxMS42MjVoMTEuMjVNOS4zNzUgMS4xMjVWNkM5LjM3NSA2Ljg5NTExIDkuMDE5NDIgNy43NTM1NSA4LjM4NjQ5IDguMzg2NDkgNy43NTM1NSA5LjAxOTQyIDYuODk1MTEgOS4zNzUgNiA5LjM3NXYwQzUuMTA0ODkgOS4zNzUgNC4yNDY0NSA5LjAxOTQyIDMuNjEzNTEgOC4zODY0OSAyLjk4MDU4IDcuNzUzNTUgMi42MjUgNi44OTUxMSAyLjYyNSA2VjEuMTI1TTEuMTI1IDEuMTI1aDNNNy44NzUgMS4xMjVoMyIvPjwvZz48L3N2Zz4='); + --icon-FormConfig: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTUwIiBoZWlnaHQ9IjE0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMTUuNTg4MyAxNDBMMTUgMTM5Ljk4OUMxNS4yMjc1IDEzMy41MDggMTUuOTI1NSAxMjcuMDUyIDE3LjA4ODUgMTIwLjY3IDE5LjYzMjYgMTA3LjAwNyAyMy44NjMgOTguMDM0IDI5LjY2MjIgOTRMMzAgOTQuNDc3QzE2LjQ1MyAxMDMuOSAxNS41OTUyIDEzOS42NCAxNS41ODgzIDE0MHpNMjIuNjA4OSAxMzlMMjIgMTM4Ljk4OUMyMi4wMTMxIDEzOC4zNDYgMjIuMzkzNyAxMjMuMTg5IDI4LjY1MDMgMTE5TDI5IDExOS40NzVDMjIuOTk1OCAxMjMuNDk1IDIyLjYxMTkgMTM4Ljg0NSAyMi42MDg5IDEzOXoiIGZpbGw9IiM0OTQ5NDkiLz48cGF0aCBkPSJNMzMgOTRDMzQuNjU2OSA5NCAzNiA5Mi42NTY4IDM2IDkxIDM2IDg5LjM0MzEgMzQuNjU2OSA4OCAzMyA4OCAzMS4zNDMxIDg4IDMwIDg5LjM0MzEgMzAgOTEgMzAgOTIuNjU2OCAzMS4zNDMxIDk0IDMzIDk0ek0zMSAxMTlDMzIuNjU2OSAxMTkgMzQgMTE3LjY1NyAzNCAxMTYgMzQgMTE0LjM0MyAzMi42NTY5IDExMyAzMSAxMTMgMjkuMzQzMSAxMTMgMjggMTE0LjM0MyAyOCAxMTYgMjggMTE3LjY1NyAyOS4zNDMxIDExOSAzMSAxMTl6IiBmaWxsPSIjMTZCMzc4Ii8+PHBhdGggZD0iTTIwLjg3NDYgOTYuMTk5M0MyMS40MjMzIDk5Ljc4OTIgMTkuOTkzNCAxMDMgMTkuOTkzNCAxMDMgMTkuOTkzNCAxMDMgMTcuNjc0IDEwMC4zOTEgMTcuMTI1NCA5Ni44MDA3IDE2LjU3NjcgOTMuMjEwOCAxOC4wMDY2IDkwIDE4LjAwNjYgOTAgMTguMDA2NiA5MCAyMC4zMjYgOTIuNjA5NSAyMC44NzQ2IDk2LjE5OTN6TTMwLjYwODEgMTA0LjM3NUMyNy4yOTQzIDEwNS41NzYgMjQgMTA0LjY3NiAyNCAxMDQuNjc2IDI0IDEwNC42NzYgMjYuMDc4MiAxMDEuODI3IDI5LjM5MTkgMTAwLjYyNSAzMi43MDU2IDk5LjQyMzUgMzYgMTAwLjMyNCAzNiAxMDAuMzI0IDM2IDEwMC4zMjQgMzMuOTIxOCAxMDMuMTczIDMwLjYwODEgMTA0LjM3NXpNMzAuNDA1NCAxMjYuNjI5QzI4Ljk3ODkgMTI3LjA1MyAyNy40NTk4IDEyNy4xMTUgMjYgMTI2LjgwOSAyNi45MjY5IDEyNS42OTUgMjguMTc1MiAxMjQuODU3IDI5LjU5NDYgMTI0LjM5OCAzMS4wMTQgMTIzLjkzOCAzMi41NDM5IDEyMy44NzUgMzQgMTI0LjIxOCAzMy4wNDYxIDEyNS4zMDUgMzEuODA2NSAxMjYuMTM3IDMwLjQwNTQgMTI2LjYyOXoiIGZpbGw9IiM0OTQ5NDkiLz48cGF0aCBkPSJNMTA3Ljk5OSAxMzZIMTAzLjkzNEwxMDIgMTIxTDEwOCAxMjFMMTA3Ljk5OSAxMzZaIiBmaWxsPSIjRkZGM0RFIi8+PHBhdGggZD0iTTEwOCAxNDBMOTYgMTQwVjEzOS44NDJDOTYuMDAwMSAxMzguNTU4IDk2LjQ5MjIgMTM3LjMyNiA5Ny4zNjgxIDEzNi40MThDOTguMjQ0IDEzNS41MSA5OS40MzIgMTM1IDEwMC42NzEgMTM1TDEwOCAxMzVMMTA4IDE0MFoiIGZpbGw9IiM0OTQ5NDkiLz48cGF0aCBkPSJNODQuOTk5NCAxMzZIODAuOTMzOUw3OSAxMjFMODUgMTIxTDg0Ljk5OTQgMTM2WiIgZmlsbD0iI0ZGRjNERSIvPjxwYXRoIGQ9Ik04NC45OTk4IDE0MEw3MyAxNDBWMTM5Ljg0MkM3My4wMDAxIDEzOC41NTggNzMuNDkyMiAxMzcuMzI2IDc0LjM2ODEgMTM2LjQxOEM3NS4yNDQgMTM1LjUxIDc2LjQzMiAxMzUgNzcuNjcwNyAxMzVINzcuNjcxTDg1IDEzNUw4NC45OTk4IDE0MFoiIGZpbGw9IiM0OTQ5NDkiLz48cGF0aCBkPSJNNzQgNTMuODQ0OEw2OC43NjA3IDUzIDY3LjA2OCA1NS44NDMxIDQ4Ljg1MjggNjAuNjg4MyA0OC45MDIxIDYwLjk1MDNDNDguMzg2OSA2MC4zODkgNDcuNzEwNiA2MC4wMTgyIDQ2Ljk3NTUgNTkuODkzOCA0Ni4yNDA0IDU5Ljc2OTQgNDUuNDg2NSA1OS44OTgzIDQ0LjgyNzggNjAuMjYxIDQ0LjE2OTEgNjAuNjIzNiA0My42NDE2IDYxLjIwMDMgNDMuMzI1IDYxLjkwMzcgNDMuMDA4NSA2Mi42MDcyIDQyLjkyMDEgNjMuMzk5IDQzLjA3MzMgNjQuMTU5NSA0My4yMjY1IDY0LjkyMDEgNDMuNjEzIDY1LjYwNzggNDQuMTc0MSA2Ni4xMTg3IDQ0LjczNTMgNjYuNjI5NSA0NS40NDA3IDY2LjkzNTcgNDYuMTgzNiA2Ni45OTEgNDYuOTI2NCA2Ny4wNDYyIDQ3LjY2NjMgNjYuODQ3NCA0OC4yOTEyIDY2LjQyNDcgNDguOTE2MSA2Ni4wMDE5IDQ5LjM5MjEgNjUuMzc4MyA0OS42NDcgNjQuNjQ4MUw3MS43MDYyIDU5Ljg0NzMgNzQgNTMuODQ0OHpNMTI3LjU2MSA1MC4wNjE3QzEyNy4xMjkgNTAuMDYyMSAxMjYuNyA1MC4xNDUzIDEyNi4yOTggNTAuMzA2OUwxMjYuNDAzIDUwLjEyMjIgMTAzLjE2OCAzOCAxMDAgNDMuMTY4OSAxMjQuMTU3IDUzLjk5MjRDMTI0LjI0NCA1NC42NTI5IDEyNC41MTkgNTUuMjc0IDEyNC45NDcgNTUuNzgxMiAxMjUuMzc2IDU2LjI4ODUgMTI1Ljk0IDU2LjY2MDQgMTI2LjU3MiA1Ni44NTI2IDEyNy4yMDQgNTcuMDQ0OCAxMjcuODc4IDU3LjA0OTEgMTI4LjUxMyA1Ni44NjUgMTI5LjE0NyA1Ni42ODA4IDEyOS43MTYgNTYuMzE2MSAxMzAuMTUgNTUuODE0MyAxMzAuNTg1IDU1LjMxMjYgMTMwLjg2NyA1NC42OTUxIDEzMC45NjMgNTQuMDM1NyAxMzEuMDYgNTMuMzc2MyAxMzAuOTY1IDUyLjcwMjkgMTMwLjY5MiA1Mi4wOTYgMTMwLjQxOSA1MS40ODkyIDEyOS45NzkgNTAuOTc0NiAxMjkuNDIzIDUwLjYxMzkgMTI4Ljg2OCA1MC4yNTMxIDEyOC4yMjIgNTAuMDYxNCAxMjcuNTYxIDUwLjA2MTd6IiBmaWxsPSIjRkZGM0RFIi8+PHBhdGggZD0iTTkxLjQwMzUgMTguMDA5NUM5Mi4zMjk0IDEyLjI4NDkgODguNDQwMSA2Ljg4ODY5IDgyLjcxNjUgNS45NTY3OEM3Ni45OTI5IDUuMDI0ODYgNzEuNjAyNCA4LjkxMDExIDcwLjY3NjUgMTQuNjM0N0M2OS43NTA2IDIwLjM1OTMgNzMuNjM5OSAyNS43NTU1IDc5LjM2MzUgMjYuNjg3NEM4NS4wODcyIDI3LjYxOTMgOTAuNDc3NiAyMy43MzQgOTEuNDAzNSAxOC4wMDk1WiIgZmlsbD0iIzQ5NDk0OSIvPjxwYXRoIGQ9Ik04MC41IDI3Qzg0LjA4OTkgMjcgODcgMjQuMDg5OSA4NyAyMC41Qzg3IDE2LjkxMDEgODQuMDg5OSAxNCA4MC41IDE0Qzc2LjkxMDIgMTQgNzQgMTYuOTEwMSA3NCAyMC41Qzc0IDI0LjA4OTkgNzYuOTEwMiAyNyA4MC41IDI3WiIgZmlsbD0iI0ZGRjNERSIvPjxwYXRoIGQ9Ik04MCAxOUM4My44NjYgMTkgODcgMTYuOTg1MyA4NyAxNC41Qzg3IDEyLjAxNDcgODMuODY2IDEwIDgwIDEwQzc2LjEzNCAxMCA3MyAxMi4wMTQ3IDczIDE0LjVDNzMgMTYuOTg1MyA3Ni4xMzQgMTkgODAgMTlaIiBmaWxsPSIjNDk0OTQ5Ii8+PHBhdGggZD0iTTgxLjUgMTFDODMuOTg1MyAxMSA4NiA4Ljk4NTI4IDg2IDYuNUM4NiA0LjAxNDcyIDgzLjk4NTMgMiA4MS41IDJDNzkuMDE0NyAyIDc3IDQuMDE0NzIgNzcgNi41Qzc3IDguOTg1MjggNzkuMDE0NyAxMSA4MS41IDExWiIgZmlsbD0iIzQ5NDk0OSIvPjxwYXRoIGQ9Ik03NS45MzkxIDQuNDk5OThDNzUuOTM5MiAzLjM4NzY3IDc2LjM1NDIgMi4zMTQ4IDc3LjEwNDEgMS40ODgzMSA3Ny44NTQuNjYxODMgNzguODg1Ni4xNDAzMDEgODAgLjAyNDMzMiA3OS44NDMxLjAwODE2MjMgNzkuNjg1NS4wMDAwNDAxMTU3IDc5LjUyNzggMCA3OC4zMjY1LjAwMTM5MDc4IDc3LjE3NDguNDc2MTA4IDc2LjMyNTkgMS4zMTk4NyA3NS40NzY5IDIuMTYzNjMgNzUgMy4zMDc0MyA3NSA0LjUgNzUgNS42OTI1NyA3NS40NzY5IDYuODM2MzYgNzYuMzI1OSA3LjY4MDEzIDc3LjE3NDggOC41MjM4OSA3OC4zMjY1IDguOTk4NjEgNzkuNTI3OCA5IDc5LjY4NTUgOC45OTk5NiA3OS44NDMxIDguOTkxODQgODAgOC45NzU2NyA3OC44ODU2IDguODU5NyA3Ny44NTQgOC4zMzgxNyA3Ny4xMDQxIDcuNTExNjggNzYuMzU0MiA2LjY4NTE5IDc1LjkzOTIgNS42MTIzIDc1LjkzOTEgNC40OTk5OHpNNzYuNjk0OCAzNi45MzI2TDc3LjIyMTEgMzEuODI4NUM3Ny4yMjExIDMxLjgyODUgODQuMzYxOCAyNy4wODEzIDg2Ljc4ODcgMjkuODg3TDEwMS4yOTQgNTQuOTIyNkMxMDEuMjk0IDU0LjkyMjYgMTEwLjMxIDU4LjE1NjQgMTA5Ljk5MiA3MC41MDQ2TDEwOS41NjEgMTMwLjE5MiA5OS4zMzY0IDEzMS4zMjMgOTMuMTI3NyA4NC43ODcxIDg3LjUxODEgMTMzIDc1LjYxNDggMTMyLjYyNCA3Ni42ODU3IDEwMC44NjIgODIuMzYxNiA3MC4wNzI0IDgyLjMwNzMgNTkuODU1NyA3OS44MDc1IDU1LjczMjVDNzkuODA3NSA1NS43MzI1IDc1LjIyODMgNTMuODc2OCA3NS4xMDAzIDQ4LjYzMjRMNzUgNDEuMjYwNiA3Ni42OTQ4IDM2LjkzMjZ6IiBmaWxsPSIjNDk0OTQ5Ii8+PHBhdGggZD0iTTg0IDMxLjM1MzZMODQuMTQgMjlDODQuMTQgMjkgMTA1LjYwNyAzNC42NDggMTAzLjkwNCAzOC42ODY0IDEwMi4yMDEgNDIuNzI0NyA5OS4wMDc3IDQ0IDk5LjAwNzcgNDRMODYuNjYwOSAzOS4xMTE1IDg0IDMxLjM1MzZ6TTc4LjEyOTQgMzYuNzc2MUw3Ni41NDM3IDM1Qzc2LjU0MzcgMzUgNjMuODk1OCA1My42NDEgNjcuNzIwNSA1NS43MjM4IDcxLjU0NTIgNTcuODA2NiA3NC43NTY5IDU2LjcxMzEgNzQuNzU2OSA1Ni43MTMxTDgxIDQ0LjY4OTcgNzguMTI5NCAzNi43NzYxeiIgZmlsbD0iIzQ5NDk0OSIvPjxwYXRoIGQ9Ik01Mi44NjQyIDk3TDEzNSA4MC41MzQxTDEyOC4xMzYgNDZMNDYgNjIuNDY1OUw1Mi44NjQyIDk3WiIgZmlsbD0iI2ZmZiIvPjxwYXRoIGQ9Ik0xMzUgODEuNDIxMUw1Mi4wNjA5IDk4TDQ1IDYyLjU3ODlMMTI3LjkzOSA0NkwxMzUgODEuNDIxMVpNNTIuOTU0NCA5Ni42NTg5TDEzMy42NjEgODAuNTI2M0wxMjcuMDQ2IDQ3LjM0MTFMNDYuMzM5MiA2My40NzM3TDUyLjk1NDQgOTYuNjU4OVoiIGZpbGw9IiNEOUQ5RDkiLz48cGF0aCBkPSJNMTIyLjY3NiA1Ny42NDZMNTYgNzEgNTYuMzkwMyA3Mi45NjE2IDEyMy4wNjYgNTkuNjA3NiAxMjIuNjc2IDU3LjY0NnpNMTIzLjY3NiA2My42NDZMNTcgNzcgNTcuMzkwMyA3OC45NjE2IDEyNC4wNjYgNjUuNjA3NiAxMjMuNjc2IDYzLjY0NnpNMTI1LjY3NiA3MC42NDZMNTkgODQgNTkuMzkwMyA4NS45NjE2IDEyNi4wNjYgNzIuNjA3NiAxMjUuNjc2IDcwLjY0NnoiIGZpbGw9IiNEOUQ5RDkiLz48cGF0aCBkPSJNOTAuNDQ0OSA2Ny41NDE1TDg3Ljk4NzMgNjcuOTg4Qzg3LjgwNzggNjguMDIwNCA4Ny42MjE1IDY3Ljk4NjcgODcuNDY5MiA2Ny44OTQyIDg3LjMxNjkgNjcuODAxOCA4Ny4yMTEyIDY3LjY1ODEgODcuMTc1MSA2Ny40OTQ4TDg2LjAxMzIgNjIuMTk3NkM4NS45Nzc2IDYyLjAzNDIgODYuMDE0NiA2MS44NjQ2IDg2LjExNjIgNjEuNzI2MSA4Ni4yMTc4IDYxLjU4NzUgODYuMzc1NiA2MS40OTEzIDg2LjU1NTEgNjEuNDU4NUw4OS4wMTI3IDYxLjAxMkM4OS4xOTIyIDYwLjk3OTYgODkuMzc4NiA2MS4wMTMzIDg5LjUzMDggNjEuMTA1OCA4OS42ODMxIDYxLjE5ODIgODkuNzg4OCA2MS4zNDE5IDg5LjgyNDkgNjEuNTA1Mkw5MC45ODY4IDY2LjgwMjRDOTEuMDIyNCA2Ni45NjU4IDkwLjk4NTQgNjcuMTM1NCA5MC44ODM4IDY3LjI3MzkgOTAuNzgyMiA2Ny40MTI1IDkwLjYyNDQgNjcuNTA4NyA5MC40NDQ5IDY3LjU0MTV6TTExNy40NDUgNjkuNTQxNUwxMTQuOTg3IDY5Ljk4OEMxMTQuODA4IDcwLjAyMDQgMTE0LjYyMSA2OS45ODY3IDExNC40NjkgNjkuODk0MiAxMTQuMzE3IDY5LjgwMTggMTE0LjIxMSA2OS42NTgxIDExNC4xNzUgNjkuNDk0OEwxMTMuMDEzIDY0LjE5NzZDMTEyLjk3OCA2NC4wMzQyIDExMy4wMTUgNjMuODY0NiAxMTMuMTE2IDYzLjcyNjEgMTEzLjIxOCA2My41ODc1IDExMy4zNzYgNjMuNDkxMyAxMTMuNTU1IDYzLjQ1ODVMMTE2LjAxMyA2My4wMTJDMTE2LjE5MiA2Mi45Nzk2IDExNi4zNzkgNjMuMDEzMyAxMTYuNTMxIDYzLjEwNTggMTE2LjY4MyA2My4xOTgyIDExNi43ODkgNjMuMzQxOSAxMTYuODI1IDYzLjUwNTJMMTE3Ljk4NyA2OC44MDI0QzExOC4wMjIgNjguOTY1OCAxMTcuOTg1IDY5LjEzNTQgMTE3Ljg4NCA2OS4yNzM5IDExNy43ODIgNjkuNDEyNSAxMTcuNjI0IDY5LjUwODcgMTE3LjQ0NSA2OS41NDE1ek0xMDIuNDQ1IDc5LjU0MTVMOTkuOTg3MyA3OS45ODhDOTkuODA3OCA4MC4wMjA0IDk5LjYyMTUgNzkuOTg2NyA5OS40NjkyIDc5Ljg5NDIgOTkuMzE2OSA3OS44MDE4IDk5LjIxMTIgNzkuNjU4MSA5OS4xNzUyIDc5LjQ5NDhMOTguMDEzMiA3NC4xOTc2Qzk3Ljk3NzYgNzQuMDM0MiA5OC4wMTQ2IDczLjg2NDYgOTguMTE2MiA3My43MjYxIDk4LjIxNzggNzMuNTg3NSA5OC4zNzU2IDczLjQ5MTMgOTguNTU1MSA3My40NTg1TDEwMS4wMTMgNzMuMDEyQzEwMS4xOTIgNzIuOTc5NiAxMDEuMzc5IDczLjAxMzMgMTAxLjUzMSA3My4xMDU4IDEwMS42ODMgNzMuMTk4MiAxMDEuNzg5IDczLjM0MTkgMTAxLjgyNSA3My41MDUyTDEwMi45ODcgNzguODAyNEMxMDMuMDIyIDc4Ljk2NTggMTAyLjk4NSA3OS4xMzU0IDEwMi44ODQgNzkuMjczOSAxMDIuNzgyIDc5LjQxMjUgMTAyLjYyNCA3OS41MDg3IDEwMi40NDUgNzkuNTQxNXoiIGZpbGw9IiMxNkIzNzgiLz48cGF0aCBkPSJNMTMwLjgyNiAxNDBIMC4xNzM5NzFDMC4xMjc4MzEgMTQwIDAuMDgzNTgwNiAxMzkuOTQ3IDAuMDUwOTU0NyAxMzkuODU0QzAuMDE4MzI4OSAxMzkuNzYgMCAxMzkuNjMzIDAgMTM5LjVDMCAxMzkuMzY3IDAuMDE4MzI4OSAxMzkuMjQgMC4wNTA5NTQ3IDEzOS4xNDZDMC4wODM1ODA2IDEzOS4wNTMgMC4xMjc4MzEgMTM5IDAuMTczOTcxIDEzOUgxMzAuODI2QzEzMC44NzIgMTM5IDEzMC45MTYgMTM5LjA1MyAxMzAuOTQ5IDEzOS4xNDZDMTMwLjk4MiAxMzkuMjQgMTMxIDEzOS4zNjcgMTMxIDEzOS41QzEzMSAxMzkuNjMzIDEzMC45ODIgMTM5Ljc2IDEzMC45NDkgMTM5Ljg1NEMxMzAuOTE2IDEzOS45NDcgMTMwLjg3MiAxNDAgMTMwLjgyNiAxNDBaIiBmaWxsPSIjQ0FDQUNBIi8+PC9zdmc+'); --icon-FunctionResult: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTQsMiBMMTIsMiBDMTMuMTA0NTY5NSwyIDE0LDIuODk1NDMwNSAxNCw0IEwxNCwxMiBDMTQsMTMuMTA0NTY5NSAxMy4xMDQ1Njk1LDE0IDEyLDE0IEw0LDE0IEMyLjg5NTQzMDUsMTQgMiwxMy4xMDQ1Njk1IDIsMTIgTDIsNCBDMiwyLjg5NTQzMDUgMi44OTU0MzA1LDIgNCwyIFogTTQuNSw2IEM0LjIyMzg1NzYzLDYgNCw2LjIyMzg1NzYzIDQsNi41IEM0LDYuNzc2MTQyMzcgNC4yMjM4NTc2Myw3IDQuNSw3IEwxMS41LDcgQzExLjc3NjE0MjQsNyAxMiw2Ljc3NjE0MjM3IDEyLDYuNSBDMTIsNi4yMjM4NTc2MyAxMS43NzYxNDI0LDYgMTEuNSw2IEw0LjUsNiBaIE00LjUsOSBDNC4yMjM4NTc2Myw5IDQsOS4yMjM4NTc2MyA0LDkuNSBDNCw5Ljc3NjE0MjM3IDQuMjIzODU3NjMsMTAgNC41LDEwIEwxMS41LDEwIEMxMS43NzYxNDI0LDEwIDEyLDkuNzc2MTQyMzcgMTIsOS41IEMxMiw5LjIyMzg1NzYzIDExLjc3NjE0MjQsOSAxMS41LDkgTDQuNSw5IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-GreenArrow: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOTQiIGhlaWdodD0iMTIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTk0IDZMODQgMC4yMjY0OTdWMTEuNzczNUw5NCA2Wk0wIDdINC43VjVIMFY3Wk0xNC4xIDdIMjMuNVY1SDE0LjFWN1pNMzIuOSA3SDQyLjNWNUgzMi45VjdaTTUxLjcgN0g2MS4xVjVINTEuN1Y3Wk03MC41IDdINzkuOVY1SDcwLjVWN1oiIGZpbGw9IiMxNkIzNzgiLz48L3N2Zz4='); --icon-Grow: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEuNSAxLjVMNi41IDYuNU05LjUgOS41TDE0LjUgMTQuNU03LjUgMS41SDEuNVY3LjVNMTQuNSA4LjVWMTQuNUg4LjUiIHN0cm9rZT0iIzkyOTI5OSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4='); diff --git a/static/ui-icons/UI/FormConfig.svg b/static/ui-icons/UI/FormConfig.svg new file mode 100644 index 00000000..2ce09841 --- /dev/null +++ b/static/ui-icons/UI/FormConfig.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/client/lib/ACIndex.ts b/test/client/lib/ACIndex.ts index 49a931ab..75eb7c20 100644 --- a/test/client/lib/ACIndex.ts +++ b/test/client/lib/ACIndex.ts @@ -37,7 +37,7 @@ describe('ACIndex', function() { it('should find items with matching words', function() { const items: ACItem[] = ["blue", "dark red", "reddish", "red", "orange", "yellow", "radical green"].map( c => ({cleanText: c})); - const acIndex = new ACIndexImpl(items, 5); + const acIndex = new ACIndexImpl(items, {maxResults: 5}); assert.deepEqual(acIndex.search("red").items.map((item) => item.cleanText), ["red", "reddish", "dark red", "radical green", "blue"]); }); @@ -48,7 +48,7 @@ describe('ACIndex', function() { assert.deepEqual(acResult.items, colors); assert.deepEqual(acResult.selectIndex, -1); - acResult = new ACIndexImpl(colors, 3).search(""); + acResult = new ACIndexImpl(colors, {maxResults: 3}).search(""); assert.deepEqual(acResult.items, colors.slice(0, 3)); assert.deepEqual(acResult.selectIndex, -1); @@ -161,7 +161,7 @@ describe('ACIndex', function() { }); it('should limit results to maxResults', function() { - const acIndex = new ACIndexImpl(colors, 3); + const acIndex = new ACIndexImpl(colors, {maxResults: 3}); let acResult: ACResults; acResult = acIndex.search("red"); @@ -247,7 +247,7 @@ describe('ACIndex', function() { }); it('should return a useful highlight function', function() { - const acIndex = new ACIndexImpl(colors, 3); + const acIndex = new ACIndexImpl(colors, {maxResults: 3}); let acResult: ACResults; // Here we split the items' (uncleaned) text with the returned highlightFunc. The values at @@ -267,7 +267,7 @@ describe('ACIndex', function() { [["Blue"], ["Dark Red"], ["Reddish"]]); // Try some messier cases. - const acIndex2 = new ACIndexImpl(messy, 6); + const acIndex2 = new ACIndexImpl(messy, {maxResults: 6}); acResult = acIndex2.search("#r"); assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)), [["#", "r", "ed"], [" ", "R", "ED "], ["", "r", "ed"], ["", "r", "ead "], @@ -280,7 +280,9 @@ describe('ACIndex', function() { }); it('should highlight multi-byte unicode', function() { - const acIndex = new ACIndexImpl(['Lorem ipsum 𝌆 dolor sit ameͨ͆t.', "mañana", "Москва"].map(makeItem), 3); + const acIndex = new ACIndexImpl(['Lorem ipsum 𝌆 dolor sit ameͨ͆t.', "mañana", "Москва"].map(makeItem), { + maxResults: 3, + }); let acResult: ACResults = acIndex.search("mañ моск am"); assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)), [["", "Моск", "ва"], ["", "mañ", "ana"], ["Lorem ipsum 𝌆 dolor sit ", "am", "eͨ͆t."]]); @@ -345,7 +347,7 @@ describe('ACIndex', function() { // tslint:disable:no-console it('main algorithm', function() { - const [buildTime, acIndex] = repeat(10, () => new ACIndexImpl(items, 100)); + const [buildTime, acIndex] = repeat(10, () => new ACIndexImpl(items, {maxResults: 100})); console.log(`Time to build index (${items.length} items): ${buildTime} ms`); const [searchTime, result] = repeat(10, () => acIndex.search("YORK")); diff --git a/test/nbrowser/FormView.ts b/test/nbrowser/FormView.ts index fd2fcff9..1aeb71dc 100644 --- a/test/nbrowser/FormView.ts +++ b/test/nbrowser/FormView.ts @@ -20,29 +20,6 @@ describe('FormView', function() { afterEach(() => gu.checkForErrors()); - /** - * Adds a temporary textarea to the document for pasting the contents of - * the clipboard. - * - * Used to test copying of form URLs to the clipboard. - */ - function createClipboardTextArea() { - const textArea = document.createElement('textarea'); - textArea.style.position = 'absolute'; - textArea.style.top = '0'; - textArea.style.height = '2rem'; - textArea.style.width = '16rem'; - textArea.id = 'clipboardText'; - window.document.body.appendChild(textArea); - } - - function removeClipboardTextArea() { - const textArea = document.getElementById('clipboardText'); - if (textArea) { - window.document.body.removeChild(textArea); - } - } - async function createFormWith(type: string, more = false) { await gu.addNewSection('Form', 'Table1'); @@ -69,8 +46,11 @@ describe('FormView', function() { // Now open the form in external window. await clipboard.lockAndPerform(async (cb) => { - await driver.find(`.test-forms-link`).click(); + const shareButton = await driver.find(`.test-forms-share`); + await gu.scrollIntoView(shareButton); + await shareButton.click(); await gu.waitForServer(); + await driver.findWait('.test-forms-link', 1000).click(); await gu.waitToPass(async () => assert.match( await driver.find('.test-tooltip').getText(), /Link copied to clipboard/), 1000); await driver.find('#clipboardText').click(); @@ -121,12 +101,9 @@ describe('FormView', function() { const session = await gu.session().login(); docId = await session.tempNewDoc(cleanup); api = session.createHomeApi(); - await driver.executeScript(createClipboardTextArea); }); - after(async function() { - await driver.executeScript(removeClipboardTextArea); - }); + gu.withClipboardTextArea(); it('updates creator panel when navigated away', async function() { // Add 2 new pages. @@ -186,6 +163,12 @@ describe('FormView', function() { await gu.onNewTab(async () => { await driver.get(formUrl); await driver.findWait('input[name="D"]', 2000).click(); + await gu.sendKeys('Hello'); + assert.equal(await driver.find('input[name="D"]').value(), 'Hello'); + await driver.find('.test-form-reset').click(); + await driver.find('.test-modal-confirm').click(); + assert.equal(await driver.find('input[name="D"]').value(), ''); + await driver.find('input[name="D"]').click(); await gu.sendKeys('Hello World'); await driver.find('input[type="submit"]').click(); await waitForConfirm(); @@ -201,6 +184,12 @@ describe('FormView', function() { await gu.onNewTab(async () => { await driver.get(formUrl); await driver.findWait('input[name="D"]', 2000).click(); + await gu.sendKeys('1983'); + assert.equal(await driver.find('input[name="D"]').value(), '1983'); + await driver.find('.test-form-reset').click(); + await driver.find('.test-modal-confirm').click(); + assert.equal(await driver.find('input[name="D"]').value(), ''); + await driver.find('input[name="D"]').click(); await gu.sendKeys('1984'); await driver.find('input[type="submit"]').click(); await waitForConfirm(); @@ -216,9 +205,13 @@ describe('FormView', function() { await gu.onNewTab(async () => { await driver.get(formUrl); await driver.findWait('input[name="D"]', 2000).click(); - await driver.executeScript( - () => (document.querySelector('input[name="D"]') as HTMLInputElement).value = '2000-01-01' - ); + await gu.sendKeys('01011999'); + assert.equal(await driver.find('input[name="D"]').getAttribute('value'), '1999-01-01'); + await driver.find('.test-form-reset').click(); + await driver.find('.test-modal-confirm').click(); + assert.equal(await driver.find('input[name="D"]').getAttribute('value'), ''); + await driver.find('input[name="D"]').click(); + await gu.sendKeys('01012000'); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -239,21 +232,30 @@ describe('FormView', function() { await gu.choicesEditor.save(); await gu.toggleSidePanel('right', 'close'); - // We need to press preview, as form is not saved yet. + // We need to press view, as form is not saved yet. await gu.scrollActiveViewTop(); await gu.waitToPass(async () => { - assert.isTrue(await driver.find('.test-forms-preview').isDisplayed()); + assert.isTrue(await driver.find('.test-forms-view').isDisplayed()); }); // We are in a new window. await gu.onNewTab(async () => { await driver.get(formUrl); - const select = await driver.findWait('select[name="D"]', 2000); + await driver.findWait('select[name="D"]', 2000); // Make sure options are there. assert.deepEqual( - await driver.findAll('select[name="D"] option', e => e.getText()), ['— Choose —', 'Foo', 'Bar', 'Baz'] + await driver.findAll('select[name="D"] option', e => e.getText()), ['Select...', 'Foo', 'Bar', 'Baz'] + ); + await driver.find('.test-form-search-select').click(); + assert.deepEqual( + await driver.findAll('.test-sd-searchable-list-item', e => e.getText()), ['Select...', 'Foo', 'Bar', 'Baz'] ); - await select.click(); - await driver.find("option[value='Bar']").click(); + await gu.sendKeys('Baz', Key.ENTER); + assert.equal(await driver.find('select[name="D"]').value(), 'Baz'); + await driver.find('.test-form-reset').click(); + await driver.find('.test-modal-confirm').click(); + assert.equal(await driver.find('select[name="D"]').value(), ''); + await driver.find('.test-form-search-select').click(); + await driver.findContent('.test-sd-searchable-list-item', 'Bar').click(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -267,6 +269,12 @@ describe('FormView', function() { await gu.onNewTab(async () => { await driver.get(formUrl); await driver.findWait('input[name="D"]', 2000).click(); + await gu.sendKeys('1983'); + assert.equal(await driver.find('input[name="D"]').value(), '1983'); + await driver.find('.test-form-reset').click(); + await driver.find('.test-modal-confirm').click(); + assert.equal(await driver.find('input[name="D"]').value(), ''); + await driver.find('input[name="D"]').click(); await gu.sendKeys('1984'); await driver.find('input[type="submit"]').click(); await waitForConfirm(); @@ -282,6 +290,11 @@ describe('FormView', function() { await gu.onNewTab(async () => { await driver.get(formUrl); await driver.findWait('input[name="D"]', 2000).findClosest("label").click(); + assert.equal(await driver.find('input[name="D"]').getAttribute('checked'), 'true'); + await driver.find('.test-form-reset').click(); + await driver.find('.test-modal-confirm').click(); + assert.equal(await driver.find('input[name="D"]').getAttribute('checked'), null); + await driver.find('input[name="D"]').findClosest("label").click(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -314,7 +327,12 @@ describe('FormView', function() { // We are in a new window. await gu.onNewTab(async () => { await driver.get(formUrl); - await driver.findWait('input[name="D[]"][value="Foo"]', 2000).click(); + await driver.findWait('input[name="D[]"][value="Bar"]', 2000).click(); + assert.equal(await driver.find('input[name="D[]"][value="Bar"]').getAttribute('checked'), 'true'); + await driver.find('.test-form-reset').click(); + await driver.find('.test-modal-confirm').click(); + assert.equal(await driver.find('input[name="D[]"][value="Bar"]').getAttribute('checked'), null); + await driver.find('input[name="D[]"][value="Foo"]').click(); await driver.find('input[name="D[]"][value="Baz"]').click(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); @@ -339,17 +357,26 @@ describe('FormView', function() { // We are in a new window. await gu.onNewTab(async () => { await driver.get(formUrl); - const select = await driver.findWait('select[name="D"]', 2000); + await driver.findWait('select[name="D"]', 2000); assert.deepEqual( await driver.findAll('select[name="D"] option', e => e.getText()), - ['— Choose —', ...['Bar', 'Baz', 'Foo']] + ['Select...', ...['Bar', 'Baz', 'Foo']] ); assert.deepEqual( await driver.findAll('select[name="D"] option', e => e.value()), ['', ...['2', '3', '1']] ); - await select.click(); - await driver.find('option[value="2"]').click(); + await driver.find('.test-form-search-select').click(); + assert.deepEqual( + await driver.findAll('.test-sd-searchable-list-item', e => e.getText()), ['Select...', 'Bar', 'Baz', 'Foo'] + ); + await gu.sendKeys('Baz', Key.ENTER); + assert.equal(await driver.find('select[name="D"]').value(), '3'); + await driver.find('.test-form-reset').click(); + await driver.find('.test-modal-confirm').click(); + assert.equal(await driver.find('select[name="D"]').value(), ''); + await driver.find('.test-form-search-select').click(); + await driver.findContent('.test-sd-searchable-list-item', 'Bar').click(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -379,11 +406,16 @@ describe('FormView', function() { // We are in a new window. await gu.onNewTab(async () => { await driver.get(formUrl); - await driver.findWait('input[name="D[]"][value="1"]', 2000).click(); - await driver.find('input[name="D[]"][value="2"]').click(); - assert.equal(await driver.find('label:has(input[name="D[]"][value="1"])').getText(), 'Foo'); + assert.equal(await driver.findWait('label:has(input[name="D[]"][value="1"])', 2000).getText(), 'Foo'); assert.equal(await driver.find('label:has(input[name="D[]"][value="2"])').getText(), 'Bar'); assert.equal(await driver.find('label:has(input[name="D[]"][value="3"])').getText(), 'Baz'); + await driver.find('input[name="D[]"][value="1"]').click(); + assert.equal(await driver.find('input[name="D[]"][value="1"]').getAttribute('checked'), 'true'); + await driver.find('.test-form-reset').click(); + await driver.find('.test-modal-confirm').click(); + assert.equal(await driver.find('input[name="D[]"][value="1"]').getAttribute('checked'), null); + await driver.find('input[name="D[]"][value="1"]').click(); + await driver.find('input[name="D[]"][value="2"]').click(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -402,12 +434,75 @@ describe('FormView', function() { // Temporarily make A a formula column. await gu.sendActions([ - ['AddRecord', 'Table1', null, {A: 'Foo'}], - ['UpdateRecord', '_grist_Tables_column', 2, {formula: '"hello"', isFormula: true}], + ['ModifyColumn', 'Table1', 'A', {formula: '"hello"', isFormula: true}], ]); + + // Check that A is hidden in the form editor. + await gu.waitToPass(async () => assert.deepEqual(await readLabels(), ['B', 'C', 'D'])); + await gu.openWidgetPanel('widget'); + assert.deepEqual( + await driver.findAll('.test-vfc-visible-field', (e) => e.getText()), + ['B', 'C', 'D'] + ); + assert.deepEqual( + await driver.findAll('.test-vfc-hidden-field', (e) => e.getText()), + [] + ); + + // Check that A is excluded from the published form. + await gu.onNewTab(async () => { + await driver.get(formUrl); + await driver.findWait('input[name="D"]', 2000).click(); + await gu.sendKeys('Hello World'); + assert.isFalse(await driver.find('input[name="A"]').isPresent()); + await driver.find('input[type="submit"]').click(); + await waitForConfirm(); + }); + + // Make sure we see the new record. + await expectInD(['Hello World']); + + // And check that A was not modified. assert.deepEqual(await api.getTable(docId, 'Table1').then(t => t.A), ['hello']); - // Check that A is excluded from the form, and we can still submit it. + // Revert A and check that it's visible again in the editor. + await gu.sendActions([ + ['ModifyColumn', 'Table1', 'A', {formula: '', isFormula: false}], + ]); + await gu.waitToPass(async () => assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D'])); + assert.deepEqual( + await driver.findAll('.test-vfc-visible-field', (e) => e.getText()), + ['A', 'B', 'C', 'D'] + ); + assert.deepEqual( + await driver.findAll('.test-vfc-hidden-field', (e) => e.getText()), + [] + ); + + await removeForm(); + }); + + it('excludes attachment fields from forms', async function() { + const formUrl = await createFormWith('Text'); + + // Temporarily make A an attachments column. + await gu.sendActions([ + ['ModifyColumn', 'Table1', 'A', {type: 'Attachments'}], + ]); + + // Check that A is hidden in the form editor. + await gu.waitToPass(async () => assert.deepEqual(await readLabels(), ['B', 'C', 'D'])); + await gu.openWidgetPanel('widget'); + assert.deepEqual( + await driver.findAll('.test-vfc-visible-field', (e) => e.getText()), + ['B', 'C', 'D'] + ); + assert.deepEqual( + await driver.findAll('.test-vfc-hidden-field', (e) => e.getText()), + [] + ); + + // Check that A is excluded from the published form. await gu.onNewTab(async () => { await driver.get(formUrl); await driver.findWait('input[name="D"]', 2000).click(); @@ -418,15 +513,25 @@ describe('FormView', function() { }); // Make sure we see the new record. - await expectInD(['', 'Hello World']); + await expectInD(['Hello World']); // And check that A was not modified. - assert.deepEqual(await api.getTable(docId, 'Table1').then(t => t.A), ['hello', 'hello']); + assert.deepEqual(await api.getTable(docId, 'Table1').then(t => t.A), [null]); + // Revert A and check that it's visible again in the editor. await gu.sendActions([ - ['RemoveRecord', 'Table1', 1], - ['UpdateRecord', '_grist_Tables_column', 2, {formula: '', isFormula: false}], + ['ModifyColumn', 'Table1', 'A', {type: 'Text'}], ]); + await gu.waitToPass(async () => assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D'])); + assert.deepEqual( + await driver.findAll('.test-vfc-visible-field', (e) => e.getText()), + ['A', 'B', 'C', 'D'] + ); + assert.deepEqual( + await driver.findAll('.test-vfc-hidden-field', (e) => e.getText()), + [] + ); + await removeForm(); }); @@ -851,28 +956,33 @@ describe('FormView', function() { checkFieldInMore('Reference List'); const testStruct = (type: string, existing = 0) => { - it(`can add structure ${type} element`, async function() { + async function doTestStruct(menuLabel?: string) { assert.equal(await elementCount(type), existing); await plusButton().click(); - await clickMenu(type); + await clickMenu(menuLabel ?? type); await gu.waitForServer(); assert.equal(await elementCount(type), existing + 1); await gu.undo(); assert.equal(await elementCount(type), existing); + } + + it(`can add structure ${type} element`, async function() { + if (type === 'Section') { + await doTestStruct('Insert section above'); + await doTestStruct('Insert section below'); + } else { + await doTestStruct(); + } }); }; - // testStruct('Section'); // There is already a section + testStruct('Section', 1); testStruct('Columns'); testStruct('Paragraph', 4); it('basic section', async function() { const revert = await gu.begin(); - // 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']); @@ -898,25 +1008,39 @@ describe('FormView', function() { await gu.waitForServer(); assert.deepEqual(await readLabels(), ['A', 'D', 'B', 'C']); - // Make sure that it is not inside the section anymore. - // 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', 1).element('label', 4).getText(), 'D'); - // Make sure that deleting the section also hides its fields and unmaps them. + // Check that we can't delete a section if it's the only one. await element('Section').element('Paragraph', 1).click(); await gu.sendKeys(Key.ESCAPE, Key.UP, Key.DELETE); await gu.waitForServer(); - assert.equal(await elementCount('Section'), 0); - assert.deepEqual(await readLabels(), []); + assert.equal(await elementCount('Section'), 1); + + // Add a new section below it. + await plusButton().click(); + await clickMenu('Insert section below'); + await gu.waitForServer(); + assert.equal(await elementCount('Section'), 2); + await plusButton(element('Section', 2)).click(); + await clickMenu('Text'); + await gu.waitForServer(); + + // Now check that we can delete the first section. + await element('Section', 1).element('Paragraph', 1).click(); + await gu.sendKeys(Key.ESCAPE, Key.UP, Key.DELETE); + await gu.waitForServer(); + assert.equal(await elementCount('Section'), 1); + + // Make sure that deleting the section also hides its fields and unmaps them. + assert.deepEqual(await readLabels(), ['E']); await gu.openWidgetPanel(); assert.deepEqual(await hiddenColumns(), ['A', 'B', 'C', 'Choice', 'D']); await gu.undo(); - assert.equal(await elementCount('Section'), 1); - assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']); + assert.equal(await elementCount('Section'), 2); + assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D', 'E']); assert.deepEqual(await hiddenColumns(), ['Choice']); await revert(); @@ -1243,12 +1367,9 @@ describe('FormView', function() { const session = await gu.session().teamSite.login(); docId = await session.tempNewDoc(cleanup); api = session.createHomeApi(); - await driver.executeScript(createClipboardTextArea); }); - after(async function() { - await driver.executeScript(removeClipboardTextArea); - }); + gu.withClipboardTextArea(); it('can submit a form', async function() { // A bug was preventing this by forcing a login redirect from the public form URL. @@ -1309,8 +1430,8 @@ function questionType(label: string) { return question(label).find('.test-forms-type').value(); } -function plusButton() { - return element('plus'); +function plusButton(parent?: WebElement) { + return element('plus', parent); } function drops() { diff --git a/test/nbrowser/GridViewNewColumnMenu.ts b/test/nbrowser/GridViewNewColumnMenu.ts index d080d494..0054662e 100644 --- a/test/nbrowser/GridViewNewColumnMenu.ts +++ b/test/nbrowser/GridViewNewColumnMenu.ts @@ -262,7 +262,8 @@ describe('GridViewNewColumnMenu', function () { // Wait for the side panel animation. await gu.waitForSidePanel(); //check if right menu is opened on column section - assert.isTrue(await driver.findWait('.test-right-tab-field', 1000).isDisplayed()); + await gu.waitForSidePanel(); + assert.isTrue(await driver.find('.test-right-tab-field').isDisplayed()); await gu.toggleSidePanel("right", "close"); await gu.undo(1); }); diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 5da25aca..96fce930 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -3469,6 +3469,52 @@ export async function switchToWindow(target: string) { } } +/** + * Creates a temporary textarea to the document for pasting the contents of + * the clipboard. + */ +export async function createClipboardTextArea() { + function createTextArea() { + const textArea = window.document.createElement('textarea'); + textArea.style.position = 'absolute'; + textArea.style.top = '0'; + textArea.style.height = '2rem'; + textArea.style.width = '16rem'; + textArea.id = 'clipboardText'; + window.document.body.appendChild(textArea); + } + + await driver.executeScript(createTextArea); +} + +/** + * Removes the temporary textarea added by `createClipboardTextArea`. + */ +export async function removeClipboardTextArea() { + function removeTextArea() { + const textArea = window.document.getElementById('clipboardText'); + if (textArea) { + window.document.body.removeChild(textArea); + } + } + + await driver.executeScript(removeTextArea); +} + +/** + * Sets up a temporary textarea for pasting the contents of the clipboard, + * removing it after all tests have run. + */ +export function withClipboardTextArea() { + before(async function() { + await createClipboardTextArea(); + }); + + after(async function() { + await removeClipboardTextArea(); + }); +} + /* * Returns an instance of `LockableClipboard`, making sure to unlock it after * each test.