diff --git a/app/client/apiconsole.ts b/app/client/apiconsole.ts index 038e18e1..61f5c256 100644 --- a/app/client/apiconsole.ts +++ b/app/client/apiconsole.ts @@ -2,7 +2,7 @@ import {loadCssFile, loadScript} from 'app/client/lib/loadScript'; import type {AppModel} from 'app/client/models/AppModel'; import {urlState} from 'app/client/models/gristUrlState'; import {reportError} from 'app/client/models/errors'; -import {setUpPage} from 'app/client/ui/setUpPage'; +import {createAppPage} from 'app/client/ui/createAppPage'; import {DocAPIImpl} from 'app/common/UserAPI'; import type {RecordWithStringId} from 'app/plugin/DocApiTypes'; import {dom, styled} from 'grainjs'; @@ -291,7 +291,7 @@ function requestInterceptor(request: SwaggerUI.Request) { return request; } -setUpPage((appModel) => { +createAppPage((appModel) => { // Default Grist page prevents scrolling unnecessarily. document.documentElement.style.overflow = 'initial'; diff --git a/app/client/billingMain.ts b/app/client/billingMain.ts index e60548f4..d5149802 100644 --- a/app/client/billingMain.ts +++ b/app/client/billingMain.ts @@ -1,5 +1,5 @@ import {BillingPage} from 'app/client/ui/BillingPage'; -import {setUpPage} from 'app/client/ui/setUpPage'; +import {createAppPage} from 'app/client/ui/createAppPage'; import {dom} from 'grainjs'; -setUpPage((appModel) => dom.create(BillingPage, appModel)); +createAppPage((appModel) => dom.create(BillingPage, appModel)); diff --git a/app/client/components/FormRenderer.ts b/app/client/components/FormRenderer.ts new file mode 100644 index 00000000..cd8bad90 --- /dev/null +++ b/app/client/components/FormRenderer.ts @@ -0,0 +1,360 @@ +import * as css from 'app/client/components/FormRendererCss'; +import {FormField} from 'app/client/ui/FormAPI'; +import {sanitizeHTML} from 'app/client/ui/sanitizeHTML'; +import {CellValue} from 'app/plugin/GristData'; +import {Disposable, dom, DomContents, Observable} from 'grainjs'; +import {marked} from 'marked'; + +export const CHOOSE_TEXT = '— Choose —'; + +/** + * A node in a recursive, tree-like hierarchy comprising the layout of a form. + */ +export interface FormLayoutNode { + type: FormLayoutNodeType; + children?: Array; + // Unique ID of the field. Used only in the Form widget. + id?: string; + // Used by Layout. + submitText?: string; + successURL?: string; + successText?: string; + anotherResponse?: boolean; + // Used by Field. + formRequired?: boolean; + leaf?: number; + // Used by Label and Paragraph. + text?: string; + // Used by Paragraph. + alignment?: string; +} + +export type FormLayoutNodeType = + | 'Paragraph' + | 'Section' + | 'Columns' + | 'Submit' + | 'Placeholder' + | 'Layout' + | 'Field' + | 'Label' + | 'Separator' + | 'Header'; + +/** + * Context used by FormRenderer to build each node. + */ +export interface FormRendererContext { + /** Field metadata, keyed by field id. */ + fields: Record; + /** The root of the FormLayoutNode tree. */ + rootLayoutNode: FormLayoutNode; + /** Disables the Submit node if true. */ + disabled: Observable; + /** Error to show above the Submit node. */ + error: Observable; +} + +/** + * A renderer for a form layout. + * + * Takes the root FormLayoutNode and additional context for each node, and returns + * the DomContents of the rendered form. + * + * A closely related set of classes exist in `app/client/components/Forms/*`; those are + * specifically used to render a version of a form that is suitable for displaying within + * a Form widget, where submitting a form isn't possible. + * + * 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 { + const Renderer = FormRenderers[layoutNode.type] ?? ParagraphRenderer; + return new Renderer(layoutNode, context); + } + + protected children: FormRenderer[]; + + constructor(protected layoutNode: FormLayoutNode, protected context: FormRendererContext) { + super(); + this.children = (this.layoutNode.children ?? []).map((child) => + this.autoDispose(FormRenderer.new(child, this.context))); + } + + public abstract render(): DomContents; +} + +class LabelRenderer extends FormRenderer { + public render() { + return css.label(this.layoutNode.text ?? ''); + } +} + +class ParagraphRenderer extends FormRenderer { + public render() { + return css.paragraph( + css.paragraph.cls(`-alignment-${this.layoutNode.alignment || 'left'}`), + el => { + el.innerHTML = sanitizeHTML(marked(this.layoutNode.text || '**Lorem** _ipsum_ dolor')); + }, + ); + } +} + +class SectionRenderer extends FormRenderer { + public render() { + return css.section( + this.children.map((child) => child.render()), + ); + } +} + +class ColumnsRenderer extends FormRenderer { + public render() { + return css.columns( + {style: `--grist-columns-count: ${this.children.length || 1}`}, + this.children.map((child) => child.render()), + ); + } +} + +class SubmitRenderer extends FormRenderer { + public render() { + return [ + css.error(dom.text(use => use(this.context.error) ?? '')), + css.submit( + 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'); + }); + }), + ) + ), + ]; + } +} + +class PlaceholderRenderer extends FormRenderer { + public render() { + return dom('div'); + } +} + +class LayoutRenderer extends FormRenderer { + public render() { + return this.children.map((child) => child.render()); + } +} + +class FieldRenderer extends FormRenderer { + public build(field: FormField) { + const Renderer = FieldRenderers[field.type as keyof typeof FieldRenderers] ?? TextRenderer; + return new Renderer(); + } + + public render() { + const field = this.layoutNode.leaf ? this.context.fields[this.layoutNode.leaf] : null; + if (!field) { return null; } + + const renderer = this.build(field); + return css.field(renderer.render(field, this.context)); + } +} + +abstract class BaseFieldRenderer { + public render(field: FormField, context: FormRendererContext) { + return css.field( + this.label(field), + dom('div', this.input(field, context)), + ); + } + + public name(field: FormField) { + return field.colId; + } + + public label(field: FormField) { + return dom('label', + css.label.cls(''), + css.label.cls('-required', Boolean(field.options.formRequired)), + {for: this.name(field)}, + field.question, + ); + } + + public abstract input(field: FormField, context: FormRendererContext): DomContents; +} + +class TextRenderer extends BaseFieldRenderer { + public input(field: FormField) { + return dom('input', { + type: 'text', + name: this.name(field), + required: field.options.formRequired, + }); + } +} + +class DateRenderer extends BaseFieldRenderer { + public input(field: FormField) { + return dom('input', { + type: 'date', + name: this.name(field), + required: field.options.formRequired, + }); + } +} + +class DateTimeRenderer extends BaseFieldRenderer { + public input(field: FormField) { + return dom('input', { + type: 'datetime-local', + name: this.name(field), + required: field.options.formRequired, + }); + } +} + +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)) + ); + } +} + +class BoolRenderer extends BaseFieldRenderer { + public render(field: FormField) { + return css.field( + dom('div', this.input(field)), + ); + } + + public input(field: FormField) { + 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, + }), + css.gristSwitch( + css.gristSwitchSlider(), + css.gristSwitchCircle(), + ), + dom('span', field.question || field.colId) + ); + } +} + +class ChoiceListRenderer extends BaseFieldRenderer { + public input(field: FormField) { + const choices: string[] = field.options.choices ?? []; + const required = 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), + )), + ); + } +} + +class RefListRenderer extends BaseFieldRenderer { + public input(field: FormField) { + const choices: [number, CellValue][] = 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 30 choices. TODO: make limit dynamic. + choices.splice(30); + const required = 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] ?? '')), + )), + ); + } +} + +class RefRenderer extends BaseFieldRenderer { + public input(field: FormField) { + const choices: [number|string, CellValue][] = 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] ?? ''))), + ); + } +} + +const FieldRenderers = { + 'Text': TextRenderer, + 'Choice': ChoiceRenderer, + 'Bool': BoolRenderer, + 'ChoiceList': ChoiceListRenderer, + 'Date': DateRenderer, + 'DateTime': DateTimeRenderer, + 'Ref': RefRenderer, + 'RefList': RefListRenderer, +}; + +const FormRenderers = { + 'Paragraph': ParagraphRenderer, + 'Section': SectionRenderer, + 'Columns': ColumnsRenderer, + 'Submit': SubmitRenderer, + 'Placeholder': PlaceholderRenderer, + 'Layout': LayoutRenderer, + 'Field': FieldRenderer, + 'Label': LabelRenderer, + // Aliases for Paragraph. + 'Separator': ParagraphRenderer, + 'Header': ParagraphRenderer, +}; diff --git a/app/client/components/FormRendererCss.ts b/app/client/components/FormRendererCss.ts new file mode 100644 index 00000000..2745baa4 --- /dev/null +++ b/app/client/components/FormRendererCss.ts @@ -0,0 +1,254 @@ +import {colors, vars} from 'app/client/ui2018/cssVars'; +import {styled} from 'grainjs'; + +export const label = styled('div', ` + &-required::after { + content: "*"; + color: ${vars.primaryBg}; + margin-left: 4px; + } +`); + +export const paragraph = styled('div', ` + &-alignment-left { + text-align: left; + } + &-alignment-center { + text-align: center; + } + &-alignment-right { + text-align: right; + } +`); + +export const section = styled('div', ` + border-radius: 3px; + border: 1px solid ${colors.darkGrey}; + padding: 24px; + margin-top: 24px; + + & > div + div { + margin-top: 16px; + } +`); + +export const columns = styled('div', ` + display: grid; + grid-template-columns: repeat(var(--grist-columns-count), 1fr); + gap: 4px; +`); + +export const submit = styled('div', ` + display: flex; + justify-content: center; + align-items: center; + + & input[type="submit"] { + background-color: ${vars.primaryBg}; + border: 1px solid ${vars.primaryBg}; + color: white; + padding: 10px 24px; + border-radius: 4px; + font-size: 13px; + cursor: pointer; + line-height: inherit; + } + & input[type="submit"]:hover { + border-color: ${vars.primaryBgHover}; + background-color: ${vars.primaryBgHover}; + } +`); + +// TODO: break up into multiple variables, one for each field type. +export const field = styled('div', ` + display: flex; + flex-direction: column; + + & input[type="text"], + & input[type="date"], + & input[type="datetime-local"], + & input[type="number"] { + height: 27px; + padding: 4px 8px; + border: 1px solid ${colors.darkGrey}; + border-radius: 3px; + outline: none; + } + & input[type="text"] { + font-size: 13px; + outline-color: ${vars.primaryBg}; + outline-width: 1px; + line-height: inherit; + width: 100%; + color: ${colors.dark}; + background-color: ${colors.light}; + } + & input[type="datetime-local"], + & input[type="date"] { + width: 100%; + line-height: inherit; + } + & input[type="checkbox"] { + -webkit-appearance: none; + -moz-appearance: none; + padding: 0; + flex-shrink: 0; + display: inline-block; + width: 16px; + height: 16px; + --radius: 3px; + position: relative; + margin-right: 8px; + vertical-align: baseline; + } + & input[type="checkbox"]:checked:enabled, + & input[type="checkbox"]:indeterminate:enabled { + --color: ${vars.primaryBg}; + } + & input[type="checkbox"]:disabled { + --color: ${colors.darkGrey}; + cursor: not-allowed; + } + & input[type="checkbox"]::before, + & input[type="checkbox"]::after { + content: ''; + position: absolute; + top: 0; + left: 0; + height: 16px; + width: 16px; + box-sizing: border-box; + border: 1px solid var(--color, ${colors.darkGrey}); + border-radius: var(--radius); + } + & input[type="checkbox"]:checked::before, + & input[type="checkbox"]:disabled::before, + & input[type="checkbox"]:indeterminate::before { + background-color: var(--color); + } + & input[type="checkbox"]:not(:checked):indeterminate::after { + -webkit-mask-image: var(--icon-Minus); + } + & input[type="checkbox"]:not(:disabled)::after { + background-color: ${colors.light}; + } + & input[type="checkbox"]:checked::after, + & input[type="checkbox"]:indeterminate::after { + content: ''; + position: absolute; + height: 16px; + width: 16px; + -webkit-mask-image: var(--icon-Tick); + -webkit-mask-size: contain; + -webkit-mask-position: center; + -webkit-mask-repeat: no-repeat; + background-color: ${colors.light}; + } + & > .${label.className} { + color: ${colors.dark}; + font-size: 13px; + font-style: normal; + font-weight: 700; + line-height: 16px; /* 145.455% */ + margin-top: 8px; + margin-bottom: 8px; + display: block; + } +`); + +export const error = styled('div', ` + text-align: center; + color: ${colors.error}; + min-height: 22px; +`); + +export const toggle = styled('label', ` + position: relative; + cursor: pointer; + display: inline-flex; + align-items: center; + + & input[type='checkbox'] { + position: absolute; + } + & > span { + margin-left: 8px; + } +`); + +export const gristSwitchSlider = styled('div', ` + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + border-radius: 17px; + -webkit-transition: .4s; + transition: .4s; + + &:hover { + box-shadow: 0 0 1px #2196F3; + } +`); + +export const gristSwitchCircle = styled('div', ` + position: absolute; + cursor: pointer; + content: ""; + height: 13px; + width: 13px; + left: 2px; + bottom: 2px; + background-color: white; + border-radius: 17px; + -webkit-transition: .4s; + transition: .4s; +`); + +export const gristSwitch = styled('div', ` + position: relative; + width: 30px; + height: 17px; + display: inline-block; + flex: none; + + input:checked + & > .${gristSwitchSlider.className} { + background-color: ${vars.primaryBg}; + } + + input:checked + & > .${gristSwitchCircle.className} { + -webkit-transform: translateX(13px); + -ms-transform: translateX(13px); + transform: translateX(13px); + } +`); + +export const checkboxList = styled('div', ` + display: flex; + flex-direction: column; + gap: 4px; +`); + +export const checkbox = styled('label', ` + display: flex; + + &:hover { + --color: ${colors.hover}; + } +`); + +export const select = styled('select', ` + 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%; +`); diff --git a/app/client/components/Forms/Columns.ts b/app/client/components/Forms/Columns.ts index 59fd43cb..6319a802 100644 --- a/app/client/components/Forms/Columns.ts +++ b/app/client/components/Forms/Columns.ts @@ -1,3 +1,4 @@ +import {FormLayoutNode} from 'app/client/components/FormRenderer'; import {buildEditor} from 'app/client/components/Forms/Editor'; import {FieldModel} from 'app/client/components/Forms/Field'; import {buildMenu} from 'app/client/components/Forms/Menu'; @@ -6,7 +7,6 @@ import * as style from 'app/client/components/Forms/styles'; import {makeTestId} from 'app/client/lib/domUtils'; import {icon} from 'app/client/ui2018/icons'; import * as menus from 'app/client/ui2018/menus'; -import {Box} from 'app/common/Forms'; import {inlineStyle, not} from 'app/common/gutil'; import {bundleChanges, Computed, dom, IDomArgs, MultiHolder, Observable, styled} from 'grainjs'; @@ -28,7 +28,7 @@ export class ColumnsModel extends BoxModel { } // Dropping a box on this component (Columns) directly will add it as a new column. - public accept(dropped: Box): BoxModel { + public accept(dropped: FormLayoutNode): BoxModel { if (!this.parent) { throw new Error('No parent'); } // We need to remove it from the parent, so find it first. @@ -206,7 +206,7 @@ export class PlaceholderModel extends BoxModel { ...args, ); - function insertBox(childBox: Box) { + function insertBox(childBox: FormLayoutNode) { // Make sure we have at least as many columns as the index we are inserting at. if (!box.parent) { throw new Error('No parent'); } return box.parent.replace(box, childBox); @@ -218,15 +218,15 @@ export class PlaceholderModel extends BoxModel { } } -export function Paragraph(text: string, alignment?: 'left'|'right'|'center'): Box { +export function Paragraph(text: string, alignment?: 'left'|'right'|'center'): FormLayoutNode { return {type: 'Paragraph', text, alignment}; } -export function Placeholder(): Box { +export function Placeholder(): FormLayoutNode { return {type: 'Placeholder'}; } -export function Columns(): Box { +export function Columns(): FormLayoutNode { return {type: 'Columns', children: [Placeholder(), Placeholder()]}; } diff --git a/app/client/components/Forms/Field.ts b/app/client/components/Forms/Field.ts index e7f11821..5ad55052 100644 --- a/app/client/components/Forms/Field.ts +++ b/app/client/components/Forms/Field.ts @@ -1,3 +1,4 @@ +import {CHOOSE_TEXT, FormLayoutNode} 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'; @@ -7,7 +8,6 @@ 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 {Box, CHOOSE_TEXT} from 'app/common/Forms'; import {Constructor, not} from 'app/common/gutil'; import { BindableValue, @@ -78,7 +78,7 @@ export class FieldModel extends BoxModel { return instance; }); - constructor(box: Box, parent: BoxModel | null, view: FormView) { + constructor(box: FormLayoutNode, parent: BoxModel | null, view: FormView) { super(box, parent, view); this.required = Computed.create(this, (use) => { diff --git a/app/client/components/Forms/FormView.ts b/app/client/components/Forms/FormView.ts index 6a0b5ce5..dbbd5aca 100644 --- a/app/client/components/Forms/FormView.ts +++ b/app/client/components/Forms/FormView.ts @@ -1,6 +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 * 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'; @@ -16,13 +17,13 @@ import DataTableModel from 'app/client/models/DataTableModel'; import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel'; import {ShareRec} from 'app/client/models/entities/ShareRec'; import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec'; -import {urlState} from 'app/client/models/gristUrlState'; +import {docUrl, urlState} from 'app/client/models/gristUrlState'; import {SortedRowSet} from 'app/client/models/rowset'; import {showTransientTooltip} from 'app/client/ui/tooltips'; import {cssButton} from 'app/client/ui2018/buttons'; import {icon} from 'app/client/ui2018/icons'; import {confirmModal} from 'app/client/ui2018/modals'; -import {Box, BoxType, INITIAL_FIELDS_COUNT} from "app/common/Forms"; +import {INITIAL_FIELDS_COUNT} from 'app/common/Forms'; import {Events as BackboneEvents} from 'backbone'; import {Computed, dom, Holder, IDomArgs, MultiHolder, Observable} from 'grainjs'; import defaults from 'lodash/defaults'; @@ -47,7 +48,7 @@ export class FormView extends Disposable { protected menuHolder: Holder; protected bundle: (clb: () => Promise) => Promise; - private _autoLayout: Computed; + private _autoLayout: Computed; private _root: BoxModel; private _savedLayout: any; private _saving: boolean = false; @@ -290,14 +291,14 @@ export class FormView extends Disposable { // Sanity check that type is correct. if (!colIds.every(c => typeof c === 'string')) { throw new Error('Invalid column id'); } this._root.save(async () => { - const boxes: Box[] = []; + const boxes: FormLayoutNode[] = []; for (const colId of colIds) { const fieldRef = await this.viewSection.showColumn(colId); const field = this.viewSection.viewFields().all().find(f => f.getRowId() === fieldRef); if (!field) { continue; } const box = { leaf: fieldRef, - type: 'Field' as BoxType, + type: 'Field' as FormLayoutNodeType, }; boxes.push(box); } @@ -333,8 +334,7 @@ export class FormView extends Disposable { const doc = use(this.gristDoc.docPageModel.currentDoc); if (!doc) { return ''; } const url = urlState().makeUrl({ - api: true, - doc: doc.id, + ...docUrl(doc), form: { vsId: use(this.viewSection.id), }, @@ -723,11 +723,11 @@ export class FormView extends Disposable { * Generates a form template based on the fields in the view section. */ private _formTemplate(fields: ViewFieldRec[]) { - const boxes: Box[] = fields.map(f => { + const boxes: FormLayoutNode[] = fields.map(f => { return { type: 'Field', leaf: f.id() - } as Box; + } as FormLayoutNode; }); const section = { type: 'Section', diff --git a/app/client/components/Forms/Menu.ts b/app/client/components/Forms/Menu.ts index a454f390..55aa4772 100644 --- a/app/client/components/Forms/Menu.ts +++ b/app/client/components/Forms/Menu.ts @@ -1,4 +1,5 @@ import {allCommands} from 'app/client/components/commands'; +import {FormLayoutNodeType} from 'app/client/components/FormRenderer'; import * as components from 'app/client/components/Forms/elements'; import {FormView} from 'app/client/components/Forms/FormView'; import {BoxModel, Place} from 'app/client/components/Forms/Model'; @@ -7,14 +8,13 @@ import {FocusLayer} from 'app/client/lib/FocusLayer'; import {makeT} from 'app/client/lib/localization'; import {getColumnTypes as getNewColumnTypes} from 'app/client/ui/GridViewMenus'; import * as menus from 'app/client/ui2018/menus'; -import {BoxType} from 'app/common/Forms'; import {Computed, dom, IDomArgs, MultiHolder} from 'grainjs'; const t = makeT('FormView'); const testId = makeTestId('test-forms-menu-'); // New box to add, either a new column of type, an existing column (by column id), or a structure. -export type NewBox = {add: string} | {show: string} | {structure: BoxType}; +export type NewBox = {add: string} | {show: string} | {structure: FormLayoutNodeType}; interface Props { /** @@ -77,7 +77,7 @@ export function buildMenu(props: Props, ...args: IDomArgs): IDomArg box?.view.selectedBox.set(box); // Same for structure. - const struct = (structure: BoxType) => ({structure}); + const struct = (structure: FormLayoutNodeType) => ({structure}); // Actions: diff --git a/app/client/components/Forms/Model.ts b/app/client/components/Forms/Model.ts index 70e21a6f..2b891144 100644 --- a/app/client/components/Forms/Model.ts +++ b/app/client/components/Forms/Model.ts @@ -1,6 +1,6 @@ +import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer'; import * as elements from 'app/client/components/Forms/elements'; import {FormView} from 'app/client/components/Forms/FormView'; -import {Box, BoxType} from 'app/common/Forms'; import {bundleChanges, Computed, Disposable, dom, IDomArgs, MutableObsArray, obsArray, Observable} from 'grainjs'; import {v4 as uuidv4} from 'uuid'; @@ -9,7 +9,7 @@ type Callback = () => Promise; /** * A place where to insert a box. */ -export type Place = (box: Box) => BoxModel; +export type Place = (box: FormLayoutNode) => BoxModel; /** * View model constructed from a box JSON structure. @@ -19,7 +19,7 @@ export abstract class BoxModel extends Disposable { /** * A factory method that creates a new BoxModel from a Box JSON by picking the right class based on the type. */ - public static new(box: Box, parent: BoxModel | null, view: FormView | null = null): BoxModel { + public static new(box: FormLayoutNode, parent: BoxModel | null, view: FormView | null = null): BoxModel { const subClassName = `${box.type.split(':')[0]}Model`; const factories = elements as any; const factory = factories[subClassName]; @@ -42,7 +42,7 @@ export abstract class BoxModel extends Disposable { * Type of the box. As the type is bounded to the class that is used to render the box, it is possible * to change the type of the box just by changing this value. The box is then replaced in the parent. */ - public type: BoxType; + public type: FormLayoutNodeType; /** * List of children boxes. */ @@ -65,7 +65,7 @@ export abstract class BoxModel extends Disposable { /** * Don't use it directly, use the BoxModel.new factory method instead. */ - constructor(box: Box, public parent: BoxModel | null, public view: FormView) { + constructor(box: FormLayoutNode, public parent: BoxModel | null, public view: FormView) { super(); this.selected = Computed.create(this, (use) => use(view.selectedBox) === this && use(view.viewSection.hasFocus)); @@ -149,7 +149,7 @@ export abstract class BoxModel extends Disposable { * - child: it will add it as a child. * - swap: swaps with the box */ - public willAccept(box?: Box|BoxModel|null): 'sibling' | 'child' | 'swap' | null { + public willAccept(box?: FormLayoutNode|BoxModel|null): 'sibling' | 'child' | 'swap' | null { // If myself and the dropped element share the same parent, and the parent is a column // element, just swap us. if (this.parent && box instanceof BoxModel && this.parent === box?.parent && box.parent?.type === 'Columns') { @@ -166,7 +166,7 @@ export abstract class BoxModel extends Disposable { * Accepts box from clipboard and inserts it before this box or if this is a container box, then * as a first child. Default implementation is to insert before self. */ - public accept(dropped: Box, hint: 'above'|'below' = 'above') { + public accept(dropped: FormLayoutNode, hint: 'above'|'below' = 'above') { // Get the box that was dropped. if (!dropped) { return null; } if (dropped.id === this.id) { @@ -200,7 +200,7 @@ export abstract class BoxModel extends Disposable { /** * Replaces children at index. */ - public replaceAtIndex(box: Box, index: number) { + public replaceAtIndex(box: FormLayoutNode, index: number) { const newOne = BoxModel.new(box, this); this.children.splice(index, 1, newOne); return newOne; @@ -216,13 +216,13 @@ export abstract class BoxModel extends Disposable { this.replace(box2, box1JSON); } - public append(box: Box) { + public append(box: FormLayoutNode) { const newOne = BoxModel.new(box, this); this.children.push(newOne); return newOne; } - public insert(box: Box, index: number) { + public insert(box: FormLayoutNode, index: number) { const newOne = BoxModel.new(box, this); this.children.splice(index, 0, newOne); return newOne; @@ -232,7 +232,7 @@ export abstract class BoxModel extends Disposable { /** * Replaces existing box with a new one, whenever it is found. */ - public replace(existing: BoxModel, newOne: Box|BoxModel) { + public replace(existing: BoxModel, newOne: FormLayoutNode|BoxModel) { const index = this.children.get().indexOf(existing); if (index < 0) { throw new Error('Cannot replace box that is not in parent'); } const model = newOne instanceof BoxModel ? newOne : BoxModel.new(newOne, this); @@ -246,20 +246,20 @@ export abstract class BoxModel extends Disposable { * Creates a place to insert a box before this box. */ public placeBeforeFirstChild() { - return (box: Box) => this.insert(box, 0); + return (box: FormLayoutNode) => this.insert(box, 0); } // Some other places. public placeAfterListChild() { - return (box: Box) => this.insert(box, this.children.get().length); + return (box: FormLayoutNode) => this.insert(box, this.children.get().length); } public placeAt(index: number) { - return (box: Box) => this.insert(box, index); + return (box: FormLayoutNode) => this.insert(box, index); } public placeAfterChild(child: BoxModel) { - return (box: Box) => this.insert(box, this.children.get().indexOf(child) + 1); + return (box: FormLayoutNode) => this.insert(box, this.children.get().indexOf(child) + 1); } public placeAfterMe() { @@ -319,7 +319,7 @@ export abstract class BoxModel extends Disposable { * The core responsibility of this method is to update this box and all children based on the box JSON. * This is counterpart of the FloatingRowModel, that enables this instance to point to a different box. */ - public update(boxDef: Box) { + public update(boxDef: FormLayoutNode) { // If we have a type and the type is changed, then we need to replace the box. if (this.type && boxDef.type !== this.type) { if (!this.parent) { throw new Error('Cannot replace detached box'); } @@ -329,7 +329,7 @@ export abstract class BoxModel extends Disposable { // Update all properties of self. for (const someKey in boxDef) { - const key = someKey as keyof Box; + const key = someKey as keyof FormLayoutNode; // Skip some keys. if (key === 'id' || key === 'type' || key === 'children') { continue; } // Skip any inherited properties. @@ -365,7 +365,7 @@ export abstract class BoxModel extends Disposable { /** * Serialize this box to JSON. */ - public toJSON(): Box { + public toJSON(): FormLayoutNode { return { id: this.id, type: this.type, @@ -388,7 +388,7 @@ export abstract class BoxModel extends Disposable { export class LayoutModel extends BoxModel { constructor( - box: Box, + box: FormLayoutNode, public parent: BoxModel | null, public _save: (clb?: Callback) => Promise, public view: FormView @@ -420,7 +420,7 @@ export function unwrap(val: T | Computed): T { return val instanceof Computed ? val.get() : val; } -export function parseBox(text: string): Box|null { +export function parseBox(text: string): FormLayoutNode|null { try { const json = JSON.parse(text); return json && typeof json === 'object' && json.type ? json : null; diff --git a/app/client/components/Forms/Section.ts b/app/client/components/Forms/Section.ts index ef9136ac..6e9f64bb 100644 --- a/app/client/components/Forms/Section.ts +++ b/app/client/components/Forms/Section.ts @@ -1,10 +1,10 @@ -import * as style from './styles'; +import {FormLayoutNode} from 'app/client/components/FormRenderer'; import {buildEditor} from 'app/client/components/Forms/Editor'; import {FieldModel} from 'app/client/components/Forms/Field'; import {buildMenu} from 'app/client/components/Forms/Menu'; import {BoxModel} from 'app/client/components/Forms/Model'; +import * as style from 'app/client/components/Forms/styles'; import {makeTestId} from 'app/client/lib/domUtils'; -import {Box} from 'app/common/Forms'; import {dom, styled} from 'grainjs'; const testId = makeTestId('test-forms-'); @@ -51,7 +51,7 @@ export class SectionModel extends BoxModel { * Accepts box from clipboard and inserts it before this box or if this is a container box, then * as a first child. Default implementation is to insert before self. */ - public override accept(dropped: Box) { + public override accept(dropped: FormLayoutNode) { // Get the box that was dropped. if (!dropped) { return null; } if (dropped.id === this.id) { diff --git a/app/client/components/Forms/elements.ts b/app/client/components/Forms/elements.ts index 7f667c79..1ff486de 100644 --- a/app/client/components/Forms/elements.ts +++ b/app/client/components/Forms/elements.ts @@ -1,5 +1,5 @@ +import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer'; import {Columns, Paragraph, Placeholder} from 'app/client/components/Forms/Columns'; -import {Box, BoxType} from 'app/common/Forms'; /** * 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 @@ -12,7 +12,7 @@ export * from './Columns'; export * from './Submit'; export * from './Label'; -export function defaultElement(type: BoxType): Box { +export function defaultElement(type: FormLayoutNodeType): FormLayoutNode { switch(type) { case 'Columns': return Columns(); case 'Placeholder': return Placeholder(); diff --git a/app/client/errorMain.ts b/app/client/errorMain.ts index 89306031..74105505 100644 --- a/app/client/errorMain.ts +++ b/app/client/errorMain.ts @@ -1,3 +1,4 @@ -import {setUpErrPage} from 'app/client/ui/errorPages'; +import {createAppPage} from 'app/client/ui/createAppPage'; +import {createErrPage} from 'app/client/ui/errorPages'; -setUpErrPage(); +createAppPage((appModel) => createErrPage(appModel)); diff --git a/app/client/formMain.ts b/app/client/formMain.ts new file mode 100644 index 00000000..895dc7aa --- /dev/null +++ b/app/client/formMain.ts @@ -0,0 +1,5 @@ +import {createPage} from 'app/client/ui/createPage'; +import {FormPage} from 'app/client/ui/FormPage'; +import {dom} from 'grainjs'; + +createPage(() => dom.create(FormPage), {disableTheme: true}); diff --git a/app/client/lib/formUtils.ts b/app/client/lib/formUtils.ts index 02839862..a1124150 100644 --- a/app/client/lib/formUtils.ts +++ b/app/client/lib/formUtils.ts @@ -70,3 +70,57 @@ export function handleFormError(err: unknown, errObs: Observable) { reportError(err as Error|string); } } + +/** + * A wrapper around FormData that provides type information for fields. + */ +export class TypedFormData { + private _formData: FormData = new FormData(this._formElement); + + constructor(private _formElement: HTMLFormElement) { + + } + + public keys() { + const keys = Array.from(this._formData.keys()); + // Don't return keys for scalar values that just return empty strings. + // Otherwise, Grist won't fire trigger formulas. + return keys.filter(key => { + // If there are multiple values, return the key as is. + if (this._formData.getAll(key).length !== 1) { return true; } + + // If the value is an empty string or null, don't return the key. + const value = this._formData.get(key); + return value !== '' && value !== null; + }); + } + + public type(key: string) { + return this._formElement.querySelector(`[name="${key}"]`)?.getAttribute('data-grist-type'); + } + + public get(key: string) { + const value = this._formData.get(key); + if (value === null) { return null; } + + const type = this.type(key); + return type === 'Ref' || type === 'RefList' ? Number(value) : value; + } + + public getAll(key: string) { + const values = Array.from(this._formData.getAll(key)); + if (['Ref', 'RefList'].includes(String(this.type(key)))) { + return values.map(v => Number(v)); + } else { + return values; + } + } +} + +/** + * Converts TypedFormData into a JSON mapping of Grist fields. + */ +export function typedFormDataToJson(formData: TypedFormData) { + return Object.fromEntries(Array.from(formData.keys()).map(k => + k.endsWith('[]') ? [k.slice(0, -2), ['L', ...formData.getAll(k)]] : [k, formData.get(k)])); +} diff --git a/app/client/models/AppModel.ts b/app/client/models/AppModel.ts index 4af06c73..d3f16f46 100644 --- a/app/client/models/AppModel.ts +++ b/app/client/models/AppModel.ts @@ -10,7 +10,7 @@ import {Notifier} from 'app/client/models/NotifyModel'; import {getFlavor, ProductFlavor} from 'app/client/ui/CustomThemes'; import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrades'; import {SupportGristNudge} from 'app/client/ui/SupportGristNudge'; -import {attachCssThemeVars, prefersDarkModeObs} from 'app/client/ui2018/cssVars'; +import {prefersDarkModeObs} from 'app/client/ui2018/cssVars'; import {AsyncCreate} from 'app/common/AsyncCreate'; import {ICustomWidget} from 'app/common/CustomWidget'; import {OrgUsageSummary} from 'app/common/DocUsage'; @@ -28,7 +28,6 @@ import {getGristConfig} from 'app/common/urlUtils'; import {getOrgName, isTemplatesOrg, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI'; import {getUserPrefObs, getUserPrefsObs, markAsSeen, markAsUnSeen} from 'app/client/models/UserPrefs'; import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs'; -import isEqual from 'lodash/isEqual'; const t = makeT('AppModel'); @@ -48,7 +47,6 @@ const G = getBrowserGlobals('document', 'window'); // TopAppModel is the part of the app model that persists across org and user switches. export interface TopAppModel { - options: TopAppModelOptions; api: UserAPI; isSingleOrg: boolean; productFlavor: ProductFlavor; @@ -148,11 +146,6 @@ export interface AppModel { switchUser(user: FullUser, org?: string): Promise; } -export interface TopAppModelOptions { - /** Defaults to true. */ - attachTheme?: boolean; -} - export class TopAppModelImpl extends Disposable implements TopAppModel { public readonly isSingleOrg: boolean; public readonly productFlavor: ProductFlavor; @@ -170,11 +163,7 @@ export class TopAppModelImpl extends Disposable implements TopAppModel { // up new widgets - that seems ok. private readonly _widgets: AsyncCreate; - constructor( - window: {gristConfig?: GristLoadConfig}, - public readonly api: UserAPI = newUserAPIImpl(), - public readonly options: TopAppModelOptions = {} - ) { + constructor(window: {gristConfig?: GristLoadConfig}, public readonly api: UserAPI = newUserAPIImpl()) { super(); setErrorNotifier(this.notifier); this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg); @@ -356,8 +345,6 @@ export class AppModelImpl extends Disposable implements AppModel { public readonly orgError?: OrgError, ) { super(); - - this._setUpTheme(); this._recordSignUpIfIsNewUser(); const state = urlState().state.get(); @@ -531,23 +518,6 @@ export class AppModelImpl extends Disposable implements AppModel { }, ); } - - private _setUpTheme() { - if ( - this.topAppModel.options.attachTheme === false || - // Custom CSS is incompatible with custom themes. - getGristConfig().enableCustomCss - ) { - return; - } - - attachCssThemeVars(this.currentTheme.get()); - this.autoDispose(this.currentTheme.addListener((newTheme, oldTheme) => { - if (isEqual(newTheme, oldTheme)) { return; } - - attachCssThemeVars(newTheme); - })); - } } export function getHomeUrl(): string { diff --git a/app/client/models/FormModel.ts b/app/client/models/FormModel.ts new file mode 100644 index 00000000..ef347c33 --- /dev/null +++ b/app/client/models/FormModel.ts @@ -0,0 +1,107 @@ +import {FormLayoutNode} 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'; +import {urlState} from 'app/client/models/gristUrlState'; +import {Form, FormAPI, FormAPIImpl} from 'app/client/ui/FormAPI'; +import {ApiError} from 'app/common/ApiError'; +import {safeJsonParse} from 'app/common/gutil'; +import {bundleChanges, Computed, Disposable, Observable} from 'grainjs'; + +const t = makeT('FormModel'); + +export interface FormModel { + readonly form: Observable; + readonly formLayout: Computed; + readonly submitting: Observable; + readonly submitted: Observable; + readonly error: Observable; + fetchForm(): Promise; + submitForm(formData: TypedFormData): Promise; +} + +export class FormModelImpl extends Disposable implements FormModel { + public readonly form = Observable.create(this, null); + public readonly formLayout = Computed.create(this, this.form, (_use, form) => { + if (!form) { return null; } + + return safeJsonParse(form.formLayoutSpec, null) as FormLayoutNode; + }); + public readonly submitting = Observable.create(this, false); + public readonly submitted = Observable.create(this, false); + public readonly error = Observable.create(this, null); + + private readonly _formAPI: FormAPI = new FormAPIImpl(getHomeUrl()); + + constructor() { + super(); + } + + public async fetchForm(): Promise { + try { + bundleChanges(() => { + this.form.set(null); + this.submitted.set(false); + this.error.set(null); + }); + this.form.set(await this._formAPI.getForm(this._getFetchFormParams())); + } catch (e: unknown) { + let error: string | undefined; + if (e instanceof ApiError) { + const code = e.details?.code; + if (code === 'FormNotFound') { + error = t("Oops! The form you're looking for doesn't exist."); + } else if (code === 'FormNotPublished') { + error = t('Oops! This form is no longer published.'); + } else if (e.status === 401 || e.status === 403) { + error = t("You don't have access to this form."); + } else if (e.status === 404) { + error = t("Oops! The form you're looking for doesn't exist."); + } + } + + this.error.set(error || t('There was a problem loading the form.')); + if (!(e instanceof ApiError && (e.status >= 400 && e.status < 500))) { + // Re-throw if the error wasn't a user error (i.e. a 4XX HTTP response). + throw e; + } + } + } + + public async submitForm(formData: TypedFormData): Promise { + const form = this.form.get(); + if (!form) { throw new Error('form is not defined'); } + + const colValues = typedFormDataToJson(formData); + try { + this.submitting.set(true); + await this._formAPI.createRecord({ + ...this._getDocIdOrShareKeyParam(), + tableId: form.formTableId, + colValues, + }); + } finally { + this.submitting.set(false); + } + } + + private _getFetchFormParams() { + const {form} = urlState().state.get(); + if (!form) { throw new Error('invalid urlState: undefined "form"'); } + + return {...this._getDocIdOrShareKeyParam(), vsId: form.vsId}; + } + + private _getDocIdOrShareKeyParam() { + const {doc, form} = urlState().state.get(); + if (!form) { throw new Error('invalid urlState: undefined "form"'); } + + if (doc) { + return {docId: doc}; + } else if (form.shareKey) { + return {shareKey: form.shareKey}; + } else { + throw new Error('invalid urlState: undefined "doc" or "shareKey"'); + } + } +} diff --git a/app/client/models/gristUrlState.ts b/app/client/models/gristUrlState.ts index 74697cab..11ae3c83 100644 --- a/app/client/models/gristUrlState.ts +++ b/app/client/models/gristUrlState.ts @@ -47,16 +47,12 @@ let _urlState: UrlState|undefined; * In addition to setting `doc` and `slug`, it sets additional parameters * from `params` if any are supplied. */ -export function docUrl(doc: Document, params: {org?: string} = {}): IGristUrlState { +export function docUrl(doc: Document): IGristUrlState { const state: IGristUrlState = { doc: doc.urlId || doc.id, slug: getSlugIfNeeded(doc), }; - // TODO: Get non-sample documents with `org` set to fully work (a few tests fail). - if (params.org) { - state.org = params.org; - } return state; } diff --git a/app/client/ui/AppUI.ts b/app/client/ui/AppUI.ts index 13a1bab3..5a659fca 100644 --- a/app/client/ui/AppUI.ts +++ b/app/client/ui/AppUI.ts @@ -17,7 +17,7 @@ import {pagePanels} from 'app/client/ui/PagePanels'; import {RightPanel} from 'app/client/ui/RightPanel'; import {createTopBarDoc, createTopBarHome} from 'app/client/ui/TopBar'; import {WelcomePage} from 'app/client/ui/WelcomePage'; -import {testId} from 'app/client/ui2018/cssVars'; +import {attachTheme, testId} from 'app/client/ui2018/cssVars'; import {getPageTitleSuffix} from 'app/common/gristUrls'; import {getGristConfig} from 'app/common/urlUtils'; import {Computed, dom, IDisposable, IDisposableOwner, Observable, replaceContent, subscribe} from 'grainjs'; @@ -27,10 +27,14 @@ import {Computed, dom, IDisposable, IDisposableOwner, Observable, replaceContent // TODO once #newui is gone, we don't need to worry about this being disposable. // appObj is the App object from app/client/ui/App.ts export function createAppUI(topAppModel: TopAppModel, appObj: App): IDisposable { - const content = dom.maybe(topAppModel.appObs, (appModel) => [ - createMainPage(appModel, appObj), - buildSnackbarDom(appModel.notifier, appModel), - ]); + const content = dom.maybeOwned(topAppModel.appObs, (owner, appModel) => { + owner.autoDispose(attachTheme(appModel.currentTheme)); + + return [ + createMainPage(appModel, appObj), + buildSnackbarDom(appModel.notifier, appModel), + ]; + }); dom.update(document.body, content, { // Cancel out bootstrap's overrides. style: 'font-family: inherit; font-size: inherit; line-height: inherit;' diff --git a/app/client/ui/FormAPI.ts b/app/client/ui/FormAPI.ts new file mode 100644 index 00000000..1e2d65ae --- /dev/null +++ b/app/client/ui/FormAPI.ts @@ -0,0 +1,117 @@ +import {BaseAPI, IOptions} from 'app/common/BaseAPI'; +import {CellValue, ColValues} from 'app/common/DocActions'; + +/** + * Form and associated field metadata from a Grist view section. + * + * Includes the layout of the form, metadata such as the form title, and + * a map of data for each field in the form. All of this is used to build a + * submittable version of the form (see `FormRenderer.ts`, which handles the + * actual building of forms). + */ +export interface Form { + formFieldsById: Record; + formLayoutSpec: string; + formTitle: string; + formTableId: string; +} + +/** + * Metadata for a field in a form. + * + * Form fields are directly related to Grist fields; the former is based on data + * from the latter, with additional metadata specific to forms, like whether a + * form field is required. All of this is used to build a field in a submittable + * version of the form (see `FormRenderer.ts`, which handles the actual building + * of forms). + */ +export interface FormField { + /** The field label. Defaults to the Grist column label or id. */ + question: string; + /** The field description. */ + description: string; + /** The Grist column id of the field. */ + colId: string; + /** The Grist column type of the field (e.g. "Text"). */ + type: string; + /** Additional field options. */ + options: FormFieldOptions; + /** Populated with data from a referenced table. Only set if `type` is a Reference type. */ + refValues: [number, CellValue][] | null; +} + +interface FormFieldOptions { + /** True if the field is required to submit the form. */ + formRequired?: boolean; + /** Populated with a list of options. Only set if the field `type` is a Choice/Reference Liste. */ + choices?: string[]; +} + +export interface FormAPI { + getForm(options: GetFormOptions): Promise
; + createRecord(options: CreateRecordOptions): Promise; +} + +interface GetFormCommonOptions { + vsId: number; +} + +interface GetFormWithDocIdOptions extends GetFormCommonOptions { + docId: string; +} + +interface GetFormWithShareKeyOptions extends GetFormCommonOptions { + shareKey: string; +} + +type GetFormOptions = GetFormWithDocIdOptions | GetFormWithShareKeyOptions; + +interface CreateRecordCommonOptions { + tableId: string; + colValues: ColValues; +} + +interface CreateRecordWithDocIdOptions extends CreateRecordCommonOptions { + docId: string; +} + +interface CreateRecordWithShareKeyOptions extends CreateRecordCommonOptions { + shareKey: string; +} + +type CreateRecordOptions = CreateRecordWithDocIdOptions | CreateRecordWithShareKeyOptions; + +export class FormAPIImpl extends BaseAPI implements FormAPI { + private _url: string; + + constructor(url: string, options: IOptions = {}) { + super(options); + this._url = url.replace(/\/$/, ''); + } + + public async getForm(options: GetFormOptions): Promise { + if ('docId' in options) { + const {docId, vsId} = options; + return this.requestJson(`${this._url}/api/docs/${docId}/forms/${vsId}`, {method: 'GET'}); + } else { + const {shareKey, vsId} = options; + return this.requestJson(`${this._url}/api/s/${shareKey}/forms/${vsId}`, {method: 'GET'}); + } + } + + public async createRecord(options: CreateRecordOptions): Promise { + if ('docId' in options) { + const {docId, tableId, colValues} = options; + return this.requestJson(`${this._url}/api/docs/${docId}/tables/${tableId}/records`, { + method: 'POST', + body: JSON.stringify({records: [{fields: colValues}]}), + }); + } else { + const {shareKey, tableId, colValues} = options; + return this.requestJson(`${this._url}/api/s/${shareKey}/tables/${tableId}/records`, { + method: 'POST', + body: JSON.stringify({records: [{fields: colValues}]}), + }); + } + } +} diff --git a/app/client/ui/FormContainer.ts b/app/client/ui/FormContainer.ts new file mode 100644 index 00000000..4ed7ee9b --- /dev/null +++ b/app/client/ui/FormContainer.ts @@ -0,0 +1,36 @@ +import {makeT} from 'app/client/lib/localization'; +import * as css from 'app/client/ui/FormPagesCss'; +import {icon} from 'app/client/ui2018/icons'; +import {commonUrls} from 'app/common/gristUrls'; +import {DomContents, makeTestId} from 'grainjs'; + +const t = makeT('FormContainer'); + +const testId = makeTestId('test-form-'); + +export function buildFormContainer(buildBody: () => DomContents) { + return css.formContainer( + css.form( + css.formBody( + buildBody(), + ), + css.formFooter( + css.poweredByGrist( + css.poweredByGristLink( + {href: commonUrls.forms, target: '_blank'}, + t('Powered by'), + css.gristLogo(), + ) + ), + css.buildForm( + css.buildFormLink( + {href: commonUrls.forms, target: '_blank'}, + t('Build your own form'), + icon('Expand'), + ), + ), + ), + ), + testId('container'), + ); +} diff --git a/app/client/ui/FormErrorPage.ts b/app/client/ui/FormErrorPage.ts new file mode 100644 index 00000000..2bc87333 --- /dev/null +++ b/app/client/ui/FormErrorPage.ts @@ -0,0 +1,26 @@ +import {makeT} from 'app/client/lib/localization'; +import {buildFormContainer} from 'app/client/ui/FormContainer'; +import * as css from 'app/client/ui/FormPagesCss'; +import {getPageTitleSuffix} from 'app/common/gristUrls'; +import {getGristConfig} from 'app/common/urlUtils'; +import {Disposable, makeTestId} from 'grainjs'; + +const testId = makeTestId('test-form-'); + +const t = makeT('FormErrorPage'); + +export class FormErrorPage extends Disposable { + constructor(private _message: string) { + super(); + document.title = `${t('Error')}${getPageTitleSuffix(getGristConfig())}`; + } + + public buildDom() { + return buildFormContainer(() => [ + css.formErrorMessageImageContainer(css.formErrorMessageImage({ + src: 'img/form-error.svg', + })), + css.formMessageText(this._message, testId('error-text')), + ]); + } +} diff --git a/app/client/ui/FormPage.ts b/app/client/ui/FormPage.ts new file mode 100644 index 00000000..97d233ca --- /dev/null +++ b/app/client/ui/FormPage.ts @@ -0,0 +1,151 @@ +import {FormRenderer} from 'app/client/components/FormRenderer'; +import {handleSubmit, TypedFormData} from 'app/client/lib/formUtils'; +import {makeT} from 'app/client/lib/localization'; +import {FormModel, FormModelImpl} from 'app/client/models/FormModel'; +import {buildFormContainer} from 'app/client/ui/FormContainer'; +import {FormErrorPage} from 'app/client/ui/FormErrorPage'; +import * as css from 'app/client/ui/FormPagesCss'; +import {FormSuccessPage} from 'app/client/ui/FormSuccessPage'; +import {colors} from 'app/client/ui2018/cssVars'; +import {ApiError} from 'app/common/ApiError'; +import {getPageTitleSuffix} from 'app/common/gristUrls'; +import {getGristConfig} from 'app/common/urlUtils'; +import {Disposable, dom, Observable, styled, subscribe} from 'grainjs'; + +const t = makeT('FormPage'); + +export class FormPage extends Disposable { + private readonly _model: FormModel = new FormModelImpl(); + private readonly _error = Observable.create(this, null); + + constructor() { + super(); + this._model.fetchForm().catch(reportError); + + this.autoDispose(subscribe(this._model.form, (_use, form) => { + if (!form) { return; } + + document.title = `${form.formTitle}${getPageTitleSuffix(getGristConfig())}`; + })); + } + + public buildDom() { + return css.pageContainer( + dom.domComputed(use => { + const error = use(this._model.error); + if (error) { return dom.create(FormErrorPage, error); } + + const submitted = use(this._model.submitted); + if (submitted) { return dom.create(FormSuccessPage, this._model); } + + return this._buildFormDom(); + }), + ); + } + + private _buildFormDom() { + return dom.domComputed(use => { + const form = use(this._model.form); + const rootLayoutNode = use(this._model.formLayout); + if (!form || !rootLayoutNode) { return null; } + + const formRenderer = FormRenderer.new(rootLayoutNode, { + fields: form.formFieldsById, + rootLayoutNode, + disabled: this._model.submitting, + error: this._error, + }); + + return buildFormContainer(() => + cssForm( + dom.autoDispose(formRenderer), + formRenderer.render(), + handleSubmit(this._model.submitting, + (_formData, formElement) => this._handleFormSubmit(formElement), + () => this._handleFormSubmitSuccess(), + (e) => this._handleFormError(e), + ), + ), + ); + }); + } + + private async _handleFormSubmit(formElement: HTMLFormElement) { + await this._model.submitForm(new TypedFormData(formElement)); + } + + private async _handleFormSubmitSuccess() { + const formLayout = this._model.formLayout.get(); + if (!formLayout) { throw new Error('formLayout is not defined'); } + + const {successURL} = formLayout; + if (successURL) { + try { + const url = new URL(successURL); + window.location.href = url.href; + return; + } catch { + // If the URL is invalid, just ignore it. + } + } + + this._model.submitted.set(true); + } + + private _handleFormError(e: unknown) { + this._error.set(t('There was an error submitting your form. Please try again.')); + if (!(e instanceof ApiError) || e.status >= 500) { + // If it doesn't look like a user error (i.e. a 4XX HTTP response), report it. + reportError(e as Error|string); + } + } +} + +// TODO: see if we can move the rest of this to `FormRenderer.ts`. +const cssForm = styled('form', ` + color: ${colors.dark}; + font-size: 15px; + line-height: 1.42857143; + + & > div + div { + margin-top: 16px; + } + & h1, + & h2, + & h3, + & h4, + & h5, + & h6 { + margin: 4px 0px; + font-weight: normal; + } + & h1 { + font-size: 24px; + } + & h2 { + font-size: 22px; + } + & h3 { + font-size: 16px; + } + & h4 { + font-size: 13px; + } + & h5 { + font-size: 11px; + } + & h6 { + font-size: 10px; + } + & p { + margin: 0px; + } + & strong { + font-weight: 600; + } + & hr { + border: 0px; + border-top: 1px solid ${colors.darkGrey}; + margin: 4px 0px; + } +`); diff --git a/app/client/ui/FormPagesCss.ts b/app/client/ui/FormPagesCss.ts new file mode 100644 index 00000000..1f8311b3 --- /dev/null +++ b/app/client/ui/FormPagesCss.ts @@ -0,0 +1,139 @@ +import {colors, mediaSmall} from 'app/client/ui2018/cssVars'; +import {styled} from 'grainjs'; + +export const pageContainer = styled('div', ` + background-color: ${colors.lightGrey}; + height: 100%; + width: 100%; + padding: 52px 0px 52px 0px; + overflow: auto; + + @media ${mediaSmall} { + & { + padding: 20px 0px 20px 0px; + } + } +`); + +export const formContainer = styled('div', ` + padding-left: 16px; + padding-right: 16px; +`); + +export const form = styled('div', ` + display: flex; + flex-direction: column; + align-items: center; + background-color: white; + border: 1px solid ${colors.darkGrey}; + border-radius: 3px; + max-width: 600px; + margin: 0px auto; +`); + +export const formBody = styled('div', ` + width: 100%; + padding: 20px 48px 20px 48px; + + @media ${mediaSmall} { + & { + padding: 20px; + } + } +`); + +const formMessageImageContainer = styled('div', ` + margin-top: 28px; + display: flex; + justify-content: center; +`); + +export const formErrorMessageImageContainer = styled(formMessageImageContainer, ` + height: 281px; +`); + +export const formSuccessMessageImageContainer = styled(formMessageImageContainer, ` + height: 215px; +`); + +export const formMessageImage = styled('img', ` + height: 100%; + width: 100%; +`); + +export const formErrorMessageImage = styled(formMessageImage, ` + max-height: 281px; + max-width: 250px; +`); + +export const formSuccessMessageImage = styled(formMessageImage, ` + max-height: 215px; + max-width: 250px; +`); + +export const formMessageText = styled('div', ` + color: ${colors.dark}; + text-align: center; + font-weight: 600; + font-size: 16px; + line-height: 24px; + margin-top: 32px; + margin-bottom: 24px; +`); + +export const formFooter = styled('div', ` + border-top: 1px solid ${colors.darkGrey}; + padding: 8px 16px; + width: 100%; +`); + +export const poweredByGrist = styled('div', ` + color: ${colors.darkText}; + font-size: 13px; + font-style: normal; + font-weight: 600; + line-height: 16px; + display: flex; + align-items: center; + justify-content: center; + padding: 0px 10px; +`); + +export const poweredByGristLink = styled('a', ` + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: ${colors.darkText}; + text-decoration: none; +`); + +export const buildForm = styled('div', ` + display: flex; + align-items: center; + justify-content: center; + margin-top: 8px; +`); + +export const buildFormLink = styled('a', ` + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + line-height: 16px; + text-decoration-line: underline; + color: ${colors.darkGreen}; + --icon-color: ${colors.darkGreen}; +`); + +export const gristLogo = styled('div', ` + width: 58px; + height: 20.416px; + flex-shrink: 0; + background: url(img/logo-grist.png); + background-position: 0 0; + background-size: contain; + background-color: transparent; + background-repeat: no-repeat; + margin-top: 3px; +`); diff --git a/app/client/ui/FormSuccessPage.ts b/app/client/ui/FormSuccessPage.ts new file mode 100644 index 00000000..fa168fd3 --- /dev/null +++ b/app/client/ui/FormSuccessPage.ts @@ -0,0 +1,78 @@ +import {makeT} from 'app/client/lib/localization'; +import {FormModel } from 'app/client/models/FormModel'; +import {buildFormContainer} from 'app/client/ui/FormContainer'; +import * as css from 'app/client/ui/FormPagesCss'; +import {vars} from 'app/client/ui2018/cssVars'; +import {getPageTitleSuffix} from 'app/common/gristUrls'; +import {getGristConfig} from 'app/common/urlUtils'; +import {Computed, Disposable, dom, makeTestId, styled} from 'grainjs'; + +const testId = makeTestId('test-form-'); + +const t = makeT('FormSuccessPage'); + +export class FormSuccessPage extends Disposable { + private _successText = Computed.create(this, this._model.formLayout, (_use, layout) => { + if (!layout) { return null; } + + return layout.successText || t('Thank you! Your response has been recorded.'); + }); + + private _showNewResponseButton = Computed.create(this, this._model.formLayout, (_use, layout) => { + return Boolean(layout?.anotherResponse); + }); + + constructor(private _model: FormModel) { + super(); + document.title = `${t('Form Submitted')}${getPageTitleSuffix(getGristConfig())}`; + } + + public buildDom() { + return buildFormContainer(() => [ + css.formSuccessMessageImageContainer(css.formSuccessMessageImage({ + src: 'img/form-success.svg', + })), + css.formMessageText(dom.text(this._successText), testId('success-text')), + dom.maybe(this._showNewResponseButton, () => + cssFormButtons( + cssFormNewResponseButton( + 'Submit new response', + dom.on('click', () => this._handleClickNewResponseButton()), + ), + ) + ), + ]); + } + + private async _handleClickNewResponseButton() { + await this._model.fetchForm(); + } +} + +const cssFormButtons = styled('div', ` + display: flex; + justify-content: center; + align-items: center; + margin-top: 24px; +`); + +const cssFormNewResponseButton = styled('button', ` + position: relative; + outline: none; + border-style: none; + line-height: normal; + user-select: none; + display: flex; + justify-content: center; + align-items: center; + padding: 12px 24px; + min-height: 40px; + background: ${vars.primaryBg}; + border-radius: 3px; + color: ${vars.primaryFg}; + + &:hover { + cursor: pointer; + background: ${vars.primaryBgHover}; + } +`); diff --git a/app/client/ui/LoginPagesCss.ts b/app/client/ui/LoginPagesCss.ts index 22f44b47..64163d45 100644 --- a/app/client/ui/LoginPagesCss.ts +++ b/app/client/ui/LoginPagesCss.ts @@ -133,7 +133,8 @@ export const textButton = styled(gristTextButton, ` `); export const pageContainer = styled('div', ` - min-height: 100%; + height: 100%; + overflow: auto; background-color: ${theme.loginPageBackdrop}; @media ${mediaXSmall} { diff --git a/app/client/ui/PinnedDocs.ts b/app/client/ui/PinnedDocs.ts index 35d97a7d..e84f3f1e 100644 --- a/app/client/ui/PinnedDocs.ts +++ b/app/client/ui/PinnedDocs.ts @@ -37,7 +37,7 @@ export function buildPinnedDoc(home: HomeModel, doc: Document, workspace: Worksp pinnedDoc( isRenaming || doc.removedAt ? null : - urlState().setLinkUrl(docUrl(doc, isExample ? {org: workspace.orgDomain} : undefined)), + urlState().setLinkUrl({...docUrl(doc), ...(isExample ? {org: workspace.orgDomain} : {})}), pinnedDoc.cls('-no-access', !roles.canView(doc.access)), pinnedDocPreview( (doc.options?.icon ? diff --git a/app/client/ui/TemplateDocs.ts b/app/client/ui/TemplateDocs.ts index 73928421..6e3fc6bb 100644 --- a/app/client/ui/TemplateDocs.ts +++ b/app/client/ui/TemplateDocs.ts @@ -40,7 +40,7 @@ function buildTemplateDoc(home: HomeModel, doc: Document, workspace: Workspace, } else { return css.docRowWrapper( cssDocRowLink( - urlState().setLinkUrl(docUrl(doc, {org: workspace.orgDomain})), + urlState().setLinkUrl({...docUrl(doc), org: workspace.orgDomain}), cssDocName(doc.name, testId('template-doc-title')), doc.options?.description ? cssDocRowDetails(doc.options.description, testId('template-doc-description')) : null, ), diff --git a/app/client/ui/WelcomeSitePicker.ts b/app/client/ui/WelcomeSitePicker.ts index 07b648b3..12ef6570 100644 --- a/app/client/ui/WelcomeSitePicker.ts +++ b/app/client/ui/WelcomeSitePicker.ts @@ -68,7 +68,6 @@ async function switchToPersonalUrl(ev: MouseEvent, appModel: AppModel, org: stri } const cssPageContainer = styled(css.pageContainer, ` - overflow: auto; padding-bottom: 40px; `); diff --git a/app/client/ui/createAppPage.ts b/app/client/ui/createAppPage.ts new file mode 100644 index 00000000..dfe242f9 --- /dev/null +++ b/app/client/ui/createAppPage.ts @@ -0,0 +1,38 @@ +import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; +import {setupLocale} from 'app/client/lib/localization'; +import {AppModel, TopAppModelImpl} from 'app/client/models/AppModel'; +import {reportError, setUpErrorHandling} from 'app/client/models/errors'; +import {buildSnackbarDom} from 'app/client/ui/NotifyUI'; +import {addViewportTag} from 'app/client/ui/viewport'; +import {attachCssRootVars, attachTheme} from 'app/client/ui2018/cssVars'; +import {BaseAPI} from 'app/common/BaseAPI'; +import {dom, DomContents} from 'grainjs'; + +const G = getBrowserGlobals('document', 'window'); + +/** + * Sets up the application model, error handling, and global styles, and replaces + * the DOM body with the result of calling `buildAppPage`. + */ +export function createAppPage(buildAppPage: (appModel: AppModel) => DomContents) { + setUpErrorHandling(); + + const topAppModel = TopAppModelImpl.create(null, {}); + + addViewportTag(); + attachCssRootVars(topAppModel.productFlavor); + setupLocale().catch(reportError); + + // Add globals needed by test utils. + G.window.gristApp = { + testNumPendingApiRequests: () => BaseAPI.numPendingRequests(), + }; + dom.update(document.body, dom.maybeOwned(topAppModel.appObs, (owner, appModel) => { + owner.autoDispose(attachTheme(appModel.currentTheme)); + + return [ + buildAppPage(appModel), + buildSnackbarDom(appModel.notifier, appModel), + ]; + })); +} diff --git a/app/client/ui/createPage.ts b/app/client/ui/createPage.ts new file mode 100644 index 00000000..a5fa5c69 --- /dev/null +++ b/app/client/ui/createPage.ts @@ -0,0 +1,39 @@ +import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; +import {setupLocale} from 'app/client/lib/localization'; +import {reportError, setErrorNotifier, setUpErrorHandling} from 'app/client/models/errors'; +import {Notifier} from 'app/client/models/NotifyModel'; +import {buildSnackbarDom} from 'app/client/ui/NotifyUI'; +import {addViewportTag} from 'app/client/ui/viewport'; +import {attachCssRootVars, attachTheme, prefersColorSchemeThemeObs} from 'app/client/ui2018/cssVars'; +import {BaseAPI} from 'app/common/BaseAPI'; +import {dom, DomContents} from 'grainjs'; + +const G = getBrowserGlobals('document', 'window'); + +/** + * Sets up error handling and global styles, and replaces the DOM body with the + * result of calling `buildPage`. + */ +export function createPage(buildPage: () => DomContents, options: {disableTheme?: boolean} = {}) { + const {disableTheme} = options; + + setUpErrorHandling(); + + addViewportTag(); + attachCssRootVars('grist'); + setupLocale().catch(reportError); + + // Add globals needed by test utils. + G.window.gristApp = { + testNumPendingApiRequests: () => BaseAPI.numPendingRequests(), + }; + + const notifier = Notifier.create(null); + setErrorNotifier(notifier); + + dom.update(document.body, () => [ + disableTheme ? null : dom.autoDispose(attachTheme(prefersColorSchemeThemeObs())), + buildPage(), + buildSnackbarDom(notifier, null), + ]); +} diff --git a/app/client/ui/errorPages.ts b/app/client/ui/errorPages.ts index a9c9d146..569ad2ac 100644 --- a/app/client/ui/errorPages.ts +++ b/app/client/ui/errorPages.ts @@ -4,12 +4,10 @@ import {getLoginUrl, getMainOrgUrl, getSignupUrl, urlState} from 'app/client/mod import {AppHeader} from 'app/client/ui/AppHeader'; import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon'; import {pagePanels} from 'app/client/ui/PagePanels'; -import {setUpPage} from 'app/client/ui/setUpPage'; import {createTopBarHome} from 'app/client/ui/TopBar'; import {bigBasicButtonLink, bigPrimaryButtonLink} from 'app/client/ui2018/buttons'; -import {colors, mediaSmall, theme, vars} from 'app/client/ui2018/cssVars'; -import {icon} from 'app/client/ui2018/icons'; -import {commonUrls, getPageTitleSuffix} from 'app/common/gristUrls'; +import {theme, vars} from 'app/client/ui2018/cssVars'; +import {getPageTitleSuffix} from 'app/common/gristUrls'; import {getGristConfig} from 'app/common/urlUtils'; import {dom, DomElementArg, makeTestId, observable, styled} from 'grainjs'; @@ -17,21 +15,12 @@ const testId = makeTestId('test-'); const t = makeT('errorPages'); -export function setUpErrPage() { - const {errPage} = getGristConfig(); - const attachTheme = errPage !== 'form-not-found'; - setUpPage((appModel) => { - return createErrPage(appModel); - }, {attachTheme}); -} - export function createErrPage(appModel: AppModel) { const {errMessage, errPage} = getGristConfig(); return errPage === 'signed-out' ? createSignedOutPage(appModel) : errPage === 'not-found' ? createNotFoundPage(appModel, errMessage) : errPage === 'access-denied' ? createForbiddenPage(appModel, errMessage) : errPage === 'account-deleted' ? createAccountDeletedPage(appModel) : - errPage === 'form-not-found' ? createFormNotFoundPage(errMessage) : createOtherErrorPage(appModel, errMessage); } @@ -109,43 +98,6 @@ export function createNotFoundPage(appModel: AppModel, message?: string) { ]); } -/** - * Creates a form-specific "Not Found" page. - */ -export function createFormNotFoundPage(message?: string) { - document.title = t("Form not found"); - - return cssFormErrorPage( - cssFormErrorContainer( - cssFormError( - cssFormErrorBody( - cssFormErrorImage({src: 'forms/form-not-found.svg'}), - cssFormErrorText( - message ?? t('An unknown error occurred.'), - testId('error-text'), - ), - ), - cssFormErrorFooter( - cssFormPoweredByGrist( - cssFormPoweredByGristLink( - {href: commonUrls.forms, target: '_blank'}, - t('Powered by'), - cssGristLogo(), - ) - ), - cssFormBuildForm( - cssFormBuildFormLink( - {href: commonUrls.forms, target: '_blank'}, - t('Build your own form'), - icon('Expand'), - ), - ), - ), - ), - ), - ); -} - /** * Creates a generic error page with the given message. */ @@ -225,110 +177,3 @@ const cssErrorText = styled('div', ` const cssButtonWrap = styled('div', ` margin-bottom: 8px; `); - -const cssFormErrorPage = styled('div', ` - background-color: ${colors.lightGrey}; - height: 100%; - width: 100%; - padding: 52px 0px 52px 0px; - overflow: auto; - - @media ${mediaSmall} { - & { - padding: 20px 0px 20px 0px; - } - } -`); - -const cssFormErrorContainer = styled('div', ` - padding-left: 16px; - padding-right: 16px; -`); - -const cssFormError = styled('div', ` - display: flex; - text-align: center; - flex-direction: column; - align-items: center; - background-color: white; - border: 1px solid ${colors.darkGrey}; - border-radius: 3px; - max-width: 600px; - margin: 0px auto; -`); - -const cssFormErrorBody = styled('div', ` - padding: 48px 16px 0px 16px; -`); - -const cssFormErrorImage = styled('img', ` - width: 100%; - height: 100%; - max-width: 250px; - max-height: 281px; -`); - -const cssFormErrorText = styled('div', ` - font-weight: 600; - font-size: 16px; - line-height: 24px; - margin-top: 32px; - margin-bottom: 24px; -`); - -const cssFormErrorFooter = styled('div', ` - border-top: 1px solid ${colors.darkGrey}; - padding: 8px 16px; - width: 100%; -`); - -const cssFormPoweredByGrist = styled('div', ` - color: ${colors.darkText}; - font-size: 13px; - font-style: normal; - font-weight: 600; - line-height: 16px; - display: flex; - align-items: center; - justify-content: center; - padding: 0px 10px; -`); - -const cssFormPoweredByGristLink = styled('a', ` - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - color: ${colors.darkText}; - text-decoration: none; -`); - -const cssFormBuildForm = styled('div', ` - display: flex; - align-items: center; - justify-content: center; - margin-top: 8px; -`); - -const cssFormBuildFormLink = styled('a', ` - display: flex; - align-items: center; - justify-content: center; - font-size: 11px; - line-height: 16px; - text-decoration-line: underline; - color: ${colors.darkGreen}; - --icon-color: ${colors.darkGreen}; -`); - -const cssGristLogo = styled('div', ` - width: 58px; - height: 20.416px; - flex-shrink: 0; - background: url(forms/logo.png); - background-position: 0 0; - background-size: contain; - background-color: transparent; - background-repeat: no-repeat; - margin-top: 3px; -`); diff --git a/app/client/ui/setUpPage.ts b/app/client/ui/setUpPage.ts deleted file mode 100644 index 4e0d4f2c..00000000 --- a/app/client/ui/setUpPage.ts +++ /dev/null @@ -1,43 +0,0 @@ -import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; -import {setupLocale} from 'app/client/lib/localization'; -import {AppModel, newUserAPIImpl, TopAppModelImpl} from 'app/client/models/AppModel'; -import {setUpErrorHandling} from 'app/client/models/errors'; -import {buildSnackbarDom} from 'app/client/ui/NotifyUI'; -import {addViewportTag} from 'app/client/ui/viewport'; -import {attachCssRootVars} from 'app/client/ui2018/cssVars'; -import {BaseAPI} from 'app/common/BaseAPI'; -import {dom, DomContents} from 'grainjs'; - -const G = getBrowserGlobals('document', 'window'); - -export interface SetUpPageOptions { - /** Defaults to true. */ - attachTheme?: boolean; -} - -/** - * Sets up error handling and global styles, and replaces the DOM body with - * the result of calling `buildPage`. - */ -export function setUpPage( - buildPage: (appModel: AppModel) => DomContents, - options: SetUpPageOptions = {} -) { - const {attachTheme = true} = options; - setUpErrorHandling(); - const topAppModel = TopAppModelImpl.create(null, {}, newUserAPIImpl(), {attachTheme}); - attachCssRootVars(topAppModel.productFlavor); - addViewportTag(); - - void setupLocale(); - - // Add globals needed by test utils. - G.window.gristApp = { - testNumPendingApiRequests: () => BaseAPI.numPendingRequests(), - }; - - dom.update(document.body, dom.maybe(topAppModel.appObs, (appModel) => [ - buildPage(appModel), - buildSnackbarDom(appModel.notifier, appModel), - ])); -} diff --git a/app/client/ui2018/cssVars.ts b/app/client/ui2018/cssVars.ts index fe1e26b2..5e862432 100644 --- a/app/client/ui2018/cssVars.ts +++ b/app/client/ui2018/cssVars.ts @@ -11,8 +11,11 @@ import {getStorage} from 'app/client/lib/storage'; import {urlState} from 'app/client/models/gristUrlState'; import {getTheme, ProductFlavor} from 'app/client/ui/CustomThemes'; import {Theme, ThemeAppearance} from 'app/common/ThemePrefs'; -import {dom, DomElementMethod, makeTestId, Observable, styled, TestId} from 'grainjs'; +import {getThemeColors} from 'app/common/Themes'; +import {getGristConfig} from 'app/common/urlUtils'; +import {Computed, dom, DomElementMethod, makeTestId, Observable, styled, TestId} from 'grainjs'; import debounce = require('lodash/debounce'); +import isEqual = require('lodash/isEqual'); import values = require('lodash/values'); const VAR_PREFIX = 'grist'; @@ -1021,6 +1024,32 @@ export function prefersDarkModeObs(): PausableObservable { return _prefersDarkModeObs; } +let _prefersColorSchemeThemeObs: Computed|undefined; + +/** + * Returns a singleton observable for the Grist theme matching the current + * user agent color scheme preference ("light" or "dark"). + */ +export function prefersColorSchemeThemeObs(): Computed { + if (!_prefersColorSchemeThemeObs) { + const obs = Computed.create(null, prefersDarkModeObs(), (_use, prefersDarkTheme) => { + if (prefersDarkTheme) { + return { + appearance: 'dark', + colors: getThemeColors('GristDark'), + } as const; + } else { + return { + appearance: 'light', + colors: getThemeColors('GristLight'), + } as const; + } + }); + _prefersColorSchemeThemeObs = obs; + } + return _prefersColorSchemeThemeObs; +} + /** * Attaches the global css properties to the document's root to make them available in the page. */ @@ -1036,10 +1065,25 @@ export function attachCssRootVars(productFlavor: ProductFlavor, varsOnly: boolea document.body.classList.add(`interface-${interfaceStyle}`); } +export function attachTheme(themeObs: Observable) { + // Attach the current theme to the DOM. + attachCssThemeVars(themeObs.get()); + + // Whenever the theme changes, re-attach it to the DOM. + return themeObs.addListener((newTheme, oldTheme) => { + if (isEqual(newTheme, oldTheme)) { return; } + + attachCssThemeVars(newTheme); + }); +} + /** * Attaches theme-related css properties to the theme style element. */ -export function attachCssThemeVars({appearance, colors: themeColors}: Theme) { +function attachCssThemeVars({appearance, colors: themeColors}: Theme) { + // Custom CSS is incompatible with custom themes. + if (getGristConfig().enableCustomCss) { return; } + // Prepare the custom properties needed for applying the theme. const properties = Object.entries(themeColors) .map(([name, value]) => `--grist-theme-${name}: ${value};`); diff --git a/app/common/ApiError.ts b/app/common/ApiError.ts index 7f602775..e8b746ae 100644 --- a/app/common/ApiError.ts +++ b/app/common/ApiError.ts @@ -38,7 +38,8 @@ export interface ApiErrorDetails { export type ApiErrorCode = | 'UserNotConfirmed' - | 'FormNotFound'; + | 'FormNotFound' + | 'FormNotPublished'; /** * An error with an http status code. diff --git a/app/common/Forms.ts b/app/common/Forms.ts index 39824cb3..b5780b0e 100644 --- a/app/common/Forms.ts +++ b/app/common/Forms.ts @@ -1,395 +1,4 @@ -import {isHiddenCol} from 'app/common/gristTypes'; -import {CellValue, GristType} from 'app/plugin/GristData'; -import {MaybePromise} from 'app/plugin/gutil'; -import _ from 'lodash'; -import {marked} from 'marked'; - -/** - * This file is a part of the Forms project. It contains a logic to render an HTML form from a JSON definition. - * TODO: Client version has its own implementation, we should merge them but it is hard to tell currently - * what are the similarities and differences as a Client code should also support browsing. - */ - -/** - * All allowed boxes. - */ -export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' - | 'Placeholder' | 'Layout' | 'Field' | 'Label' - | 'Separator' | 'Header' - ; - /** * Number of fields to show in the form by default. */ export const INITIAL_FIELDS_COUNT = 9; - -export const CHOOSE_TEXT = '— Choose —'; - -/** - * Box model is a JSON that represents a form element. Every element can be converted to this element and every - * ViewModel should be able to read it and built itself from it. - */ -export interface Box { - type: BoxType, - children?: Array, - - // Some properties used by some boxes (like form itself) - submitText?: string, - successURL?: string, - successText?: string, - anotherResponse?: boolean, - - // Unique ID of the field, used only in UI. - id?: string, - - // Some properties used by fields and stored in the column/field. - formRequired?: boolean, - // Used by Label and Paragraph. - text?: string, - // Used by Paragraph. - alignment?: string, - // Used by Field. - leaf?: number, -} - -/** - * When a form is rendered, it is given a context that can be used to access Grist data and sanitize HTML. - */ -export interface RenderContext { - root: Box; - field(id: number): FieldModel; -} - -export interface FieldOptions { - formRequired?: boolean; - choices?: string[]; -} - -export interface FieldModel { - /** - * The question to ask. Fallbacks to column's label than column's id. - */ - question: string; - description: string; - colId: string; - type: string; - isFormula: boolean; - options: FieldOptions; - values(): MaybePromise<[number, CellValue][]>; -} - -/** - * The RenderBox is the main building block for the form. Each main block has its own, and is responsible for - * rendering itself and its children. - */ -export class RenderBox { - public static new(box: Box, ctx: RenderContext): RenderBox { - const ctr = elements[box.type] ?? Paragraph; - return new ctr(box, ctx); - } - - constructor(protected box: Box, protected ctx: RenderContext) { - - } - - public async toHTML(): Promise { - const proms = (this.box.children || []).map((child) => RenderBox.new(child, this.ctx).toHTML()); - const parts = await Promise.all(proms); - return parts.join(''); - } -} - -class Label extends RenderBox { - public override async toHTML() { - const text = this.box.text || ''; - return ` -
${text || ''}
- `; - } -} - -class Paragraph extends RenderBox { - public override async toHTML() { - const text = this.box['text'] || '**Lorem** _ipsum_ dolor'; - const alignment = this.box['alignment'] || 'left'; - const html = marked(text); - return ` -
${html}
- `; - } -} - -class Section extends RenderBox { - public override async toHTML() { - return ` -
- ${await super.toHTML()} -
- `; - } -} - -class Columns extends RenderBox { - public override async toHTML() { - const size = this.box.children?.length || 1; - const content = await super.toHTML(); - return ` -
- ${content} -
- `; - } -} - -class Submit extends RenderBox { - public override async toHTML() { - const text = _.escape(this.ctx.root['submitText'] || 'Submit'); - return ` -
- -
- `; - } -} - -class Placeholder extends RenderBox { - public override async toHTML() { - return ` -
-
- `; - } -} - -class Layout extends RenderBox { - /** Nothing, default is enough */ -} - -/** - * Field is a special kind of box, as it renders a Grist field (a Question). It provides a default frame, like label and - * description, and then renders the field itself in same way as the main Boxes where rendered. - */ -class Field extends RenderBox { - - public build(field: FieldModel, context: RenderContext) { - const ctr = (questions as any)[field.type as any] as { new(): Question } || Text; - return new ctr(); - } - - public async toHTML() { - const field = this.box.leaf ? this.ctx.field(this.box.leaf) : null; - if (!field) { - return `
Field not found
`; - } - const renderer = this.build(field, this.ctx); - return ` -
- ${await renderer.toHTML(field, this.ctx)} -
- `; - } -} - -interface Question { - toHTML(field: FieldModel, context: RenderContext): Promise|string; -} - -abstract class BaseQuestion implements Question { - public async toHTML(field: FieldModel, context: RenderContext): Promise { - return ` -
- ${this.label(field)} -
- ${await this.input(field, context)} -
-
- `; - } - - public name(field: FieldModel): string { - const excludeFromFormData = ( - field.isFormula || - field.type === 'Attachments' || - isHiddenCol(field.colId) - ); - return `${excludeFromFormData ? '_' : ''}${field.colId}`; - } - - public label(field: FieldModel): string { - // This might be HTML. - const label = field.question; - const name = this.name(field); - const required = field.options.formRequired ? 'grist-label-required' : ''; - return ` - - `; - } - - public abstract input(field: FieldModel, context: RenderContext): string|Promise; -} - -class Text extends BaseQuestion { - public input(field: FieldModel, context: RenderContext): string { - const required = field.options.formRequired ? 'required' : ''; - return ` - - `; - } -} - -class Date extends BaseQuestion { - public input(field: FieldModel, context: RenderContext): string { - const required = field.options.formRequired ? 'required' : ''; - return ` - - `; - } -} - -class DateTime extends BaseQuestion { - public input(field: FieldModel, context: RenderContext): string { - const required = field.options.formRequired ? 'required' : ''; - return ` - - `; - } -} - -class Choice extends BaseQuestion { - public input(field: FieldModel, context: RenderContext): string { - const required = field.options.formRequired ? 'required' : ''; - const choices: Array = field.options.choices || []; - // Insert empty option. - choices.unshift(null); - return ` - - `; - } -} - -class Bool extends BaseQuestion { - public async toHTML(field: FieldModel, context: RenderContext) { - return ` -
-
- ${this.input(field, context)} -
-
- `; - } - - public input(field: FieldModel, context: RenderContext): string { - const requiredLabel = field.options.formRequired ? 'grist-label-required' : ''; - const required = field.options.formRequired ? 'required' : ''; - const label = field.question ? field.question : field.colId; - return ` - - `; - } -} - -class ChoiceList extends BaseQuestion { - public input(field: FieldModel, context: RenderContext): string { - const required = field.options.formRequired ? 'required' : ''; - const choices: string[] = field.options.choices || []; - return ` -
- ${choices.map((choice) => ` - - `).join('')} -
- `; - } -} - -class RefList extends BaseQuestion { - public async input(field: FieldModel, context: RenderContext) { - const required = field.options.formRequired ? 'required' : ''; - const choices: [number, CellValue][] = (await field.values()) ?? []; - // Sort by the second value, which is the display value. - choices.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); - // Support for 30 choices, TODO: make it dynamic. - choices.splice(30); - return ` -
- ${choices.map((choice) => ` - - `).join('')} -
- `; - } -} - -class Ref extends BaseQuestion { - public async input(field: FieldModel) { - const choices: [number|string, CellValue][] = (await field.values()) ?? []; - // Sort by the second value, which is the display value. - choices.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); - // Support for 1000 choices, TODO: make it dynamic. - choices.splice(1000); - // Insert empty option. - choices.unshift(['', CHOOSE_TEXT]); - // `).join('')} - - `; - } -} - -/** - * List of all available questions we will render of the form. - * TODO: add other renderers. - */ -const questions: Partial Question>> = { - 'Text': Text, - 'Choice': Choice, - 'Bool': Bool, - 'ChoiceList': ChoiceList, - 'Date': Date, - 'DateTime': DateTime, - 'Ref': Ref, - 'RefList': RefList, -}; - -/** - * List of all available boxes we will render of the form. - */ -const elements = { - 'Paragraph': Paragraph, - 'Section': Section, - 'Columns': Columns, - 'Submit': Submit, - 'Placeholder': Placeholder, - 'Layout': Layout, - 'Field': Field, - 'Label': Label, - - // Those are just aliases for Paragraph. - 'Separator': Paragraph, - 'Header': Paragraph, -}; diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index a6eb1283..c1e40af9 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -147,8 +147,6 @@ export interface IGristUrlState { // But this barely works, and is suitable only for documents. For decoding it // indicates that the URL probably points to an API endpoint. viaShare?: boolean; // Accessing document via a special share. - - // Form URLs can currently be encoded but not decoded. form?: { vsId: number; // a view section id of a form. shareKey?: string; // only one of shareKey or doc should be set. @@ -284,31 +282,15 @@ export function encodeUrl(gristConfig: Partial, if (state.docPage) { parts.push(`/p/${state.docPage}`); } + if (state.form) { + parts.push(`/f/${state.form.vsId}`); + } + } else if (state.form?.shareKey) { + parts.push(`forms/${encodeURIComponent(state.form.shareKey)}/${encodeURIComponent(state.form.vsId)}`); } else if (state.homePage === 'trash' || state.homePage === 'templates') { parts.push(`p/${state.homePage}`); } - /** - * Form URLS can take two forms. If a docId/urlId is set, rather than - * a share key, the returned form URL will only be accessible by users - * with access to the document. This is currently only used for the - * preview functionality in the widget, where document access is a - * pre-requisite. - * - * When a share key is set, the returned form URL will be accessible - * by anyone, so long as the form is published. - * - * Only one of `doc` (docId/urlId) or `shareKey` should be set. - */ - if (state.form) { - if (state.doc) { parts.push('/'); } - parts.push('forms/'); - if (state.form.shareKey) { - parts.push(state.form.shareKey + '/'); - } - parts.push(String(state.form.vsId)); - } - if (state.account) { parts.push(state.account === 'account' ? 'account' : `account/${state.account}`); } @@ -392,13 +374,21 @@ export function decodeUrl(gristConfig: Partial, location: Locat const parts = location.pathname.slice(1).split('/'); const state: IGristUrlState = {}; - // Bare minimum we can do to detect API URLs. - if (parts[0] === 'api') { // When it starts with /api/... - parts.shift(); - state.api = true; - } else if (parts[0] === 'o' && parts[2] === 'api') { // or with /o/{org}/api/... - parts.splice(2, 1); + // Bare minimum we can do to detect API URLs: if it starts with /api/ or /o/{org}/api/... + if (parts[0] === 'api' || (parts[0] === 'o' && parts[2] === 'api')) { state.api = true; + parts.splice(parts[0] === 'api' ? 0 : 2, 1); + } + + // Bare minimum we can do to detect form URLs with share keys: if it starts with /forms/ or /o/{org}/forms/... + if (parts[0] === 'forms' || (parts[0] === 'o' && parts[2] === 'forms')) { + const startIndex = parts[0] === 'forms' ? 0 : 2; + // Form URLs have two parts to extract: the share key and the view section id. + state.form = { + shareKey: parts[startIndex + 1], + vsId: parseInt(parts[startIndex + 2], 10), + }; + parts.splice(startIndex, 3); } const map = new Map(); @@ -447,6 +437,7 @@ export function decodeUrl(gristConfig: Partial, location: Locat if (fork.forkId) { state.fork = fork; } if (map.has('slug')) { state.slug = map.get('slug'); } if (map.has('p')) { state.docPage = parseDocPage(map.get('p')!); } + if (map.has('f')) { state.form = {vsId: parseInt(map.get('f')!, 10)}; } } else { if (map.has('p')) { const p = map.get('p')!; diff --git a/app/gen-server/lib/DocApiForwarder.ts b/app/gen-server/lib/DocApiForwarder.ts index 1df8d4ca..3c74c966 100644 --- a/app/gen-server/lib/DocApiForwarder.ts +++ b/app/gen-server/lib/DocApiForwarder.ts @@ -70,7 +70,7 @@ export class DocApiForwarder { app.use('/api/docs/:docId/webhooks', withDoc); app.use('/api/docs/:docId/assistant', withDoc); app.use('/api/docs/:docId/sql', withDoc); - app.use('/api/docs/:docId/forms/:id', withDoc); + app.use('/api/docs/:docId/forms/:vsId', withDoc); app.use('^/api/docs$', withoutDoc); } diff --git a/app/server/lib/AppEndpoint.ts b/app/server/lib/AppEndpoint.ts index 7ba59a54..05fd54e1 100644 --- a/app/server/lib/AppEndpoint.ts +++ b/app/server/lib/AppEndpoint.ts @@ -214,6 +214,17 @@ export function attachAppEndpoint(options: AttachOptions): void { plugins }}); }); + // Handlers for form preview URLs: one with a slug and one without. + app.get('/doc/:urlId([^/]+)/f/:vsId', ...docMiddleware, expressWrap(async (req, res) => { + return sendAppPage(req, res, {path: 'form.html', status: 200, config: {}, googleTagManager: 'anon'}); + })); + app.get('/:urlId([^-/]{12,})/:slug([^/]+)/f/:vsId', ...docMiddleware, expressWrap(async (req, res) => { + return sendAppPage(req, res, {path: 'form.html', status: 200, config: {}, googleTagManager: 'anon'}); + })); + // Handler for form URLs that include a share key. + app.get('/forms/:shareKey([^/]+)/:vsId', ...formMiddleware, expressWrap(async (req, res) => { + return sendAppPage(req, res, {path: 'form.html', status: 200, config: {}, googleTagManager: 'anon'}); + })); // The * is a wildcard in express 4, rather than a regex symbol. // See https://expressjs.com/en/guide/routing.html app.get('/doc/:urlId([^/]+):remainder(*)', ...docMiddleware, docHandler); @@ -227,18 +238,4 @@ export function attachAppEndpoint(options: AttachOptions): void { ...docMiddleware, docHandler); app.get('/:urlId([^-/]{12,})(/:slug([^/]+):remainder(*))?', ...docMiddleware, docHandler); - app.get('/forms/:urlId([^/]+)/:sectionId', ...formMiddleware, expressWrap(async (req, res) => { - const formUrl = gristServer.getHomeUrl(req, - `/api/s/${req.params.urlId}/forms/${req.params.sectionId}`); - const response = await fetch(formUrl, { - headers: getTransitiveHeaders(req), - }); - if (response.ok) { - const html = await response.text(); - res.send(html); - } else { - const error = await response.json(); - throw new ApiError(error?.error ?? 'An unknown error occurred.', response.status, error?.details); - } - })); } diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index ca2c9694..940348b2 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -12,9 +12,9 @@ import { UserAction } from 'app/common/DocActions'; import {DocData} from 'app/common/DocData'; -import {extractTypeFromColType, isRaisedException} from "app/common/gristTypes"; -import {Box, BoxType, FieldModel, INITIAL_FIELDS_COUNT, RenderBox, RenderContext} from "app/common/Forms"; -import {buildUrlId, commonUrls, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls"; +import {extractTypeFromColType, 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"; import {SchemaTypes} from "app/common/schema"; import {SortFunc} from 'app/common/SortFunc'; @@ -64,7 +64,6 @@ import {GristServer} from 'app/server/lib/GristServer'; import {HashUtil} from 'app/server/lib/HashUtil'; import {makeForkIds} from "app/server/lib/idUtils"; import log from 'app/server/lib/log'; -import {getAppPathTo} from 'app/server/lib/places'; import { getDocId, getDocScope, @@ -86,8 +85,6 @@ import {fetchDoc, globalUploadSet, handleOptionalUpload, handleUpload, import * as assert from 'assert'; import contentDisposition from 'content-disposition'; import {Application, NextFunction, Request, RequestHandler, Response} from "express"; -import * as fse from 'fs-extra'; -import * as handlebars from 'handlebars'; import * as _ from "lodash"; import LRUCache from 'lru-cache'; import * as moment from 'moment'; @@ -159,18 +156,6 @@ function validateCore(checker: Checker, req: Request, body: any) { } } -/** - * Helper used in forms rendering for purifying html. - */ -handlebars.registerHelper('dompurify', (html: string) => { - return new handlebars.SafeString(` - - `); -}); - export class DocWorkerApi { // Map from docId to number of requests currently being handled for that doc private _currentUsage = new Map(); @@ -182,8 +167,7 @@ export class DocWorkerApi { constructor(private _app: Application, private _docWorker: DocWorker, private _docWorkerMap: IDocWorkerMap, private _docManager: DocManager, - private _dbManager: HomeDBManager, private _grist: GristServer, - private _staticPath: string) {} + private _dbManager: HomeDBManager, private _grist: GristServer) {} /** * Adds endpoints for the doc api. @@ -1388,49 +1372,48 @@ export class DocWorkerApi { })); /** - * Get the specified section's form as HTML. - * - * Forms are typically accessed via shares, with URLs like: https://docs.getgrist.com/forms/${shareKey}/${id}. - * - * AppEndpoint.ts handles forwarding of such URLs to this endpoint. + * Get the specified view section's form data. */ - this._app.get('/api/docs/:docId/forms/:id', canView, + this._app.get('/api/docs/:docId/forms/:vsId', canView, withDoc(async (activeDoc, req, res) => { + if (!activeDoc.docData) { + throw new ApiError('DocData not available', 500); + } + + const sectionId = integerParam(req.params.vsId, 'vsId'); const docSession = docSessionFromRequest(req); const linkId = getDocSessionShare(docSession); - const sectionId = integerParam(req.params.id, 'id'); if (linkId) { /* If accessed via a share, the share's `linkId` will be present and * we'll need to check that the form is in fact published, and that the * share key is associated with the form, before granting access to the * form. */ - this._assertFormIsPublished({ + this._assertIsPublishedForm({ docData: activeDoc.docData, linkId, sectionId, }); } - const Views_section = activeDoc.docData!.getMetaTable('_grist_Views_section'); + + const Views_section = activeDoc.docData.getMetaTable('_grist_Views_section'); const section = Views_section.getRecord(sectionId); if (!section) { - throw new ApiError('Form not found', 404); + throw new ApiError('Form not found', 404, {code: 'FormNotFound'}); } - const Tables = activeDoc.docData!.getMetaTable('_grist_Tables'); - const tableRecord = Tables.getRecord(section.tableRef); - const Views_section_field = activeDoc.docData!.getMetaTable('_grist_Views_section_field'); - const fields = Views_section_field.filterRecords({parentId: sectionId}); - const Tables_column = activeDoc.docData!.getMetaTable('_grist_Tables_column'); - - // Read the box specs - const spec = section.layoutSpec; - let box: Box = safeJsonParse(spec ? String(spec) : '', null); - if (!box) { - const editable = fields.filter(f => { + + const Views_section_field = activeDoc.docData.getMetaTable('_grist_Views_section_field'); + const Tables_column = activeDoc.docData.getMetaTable('_grist_Tables_column'); + const fields = Views_section_field + .filterRecords({parentId: sectionId}) + .filter(f => { const col = Tables_column.getRecord(f.colRef); - // Can't do attachments and formulas. + // Formulas and attachments are currently unsupported. return col && !(col.isFormula && col.formula) && col.type !== 'Attachment'; }); - box = { + + let {layoutSpec: formLayoutSpec} = section; + if (!formLayoutSpec) { + formLayoutSpec = JSON.stringify({ type: 'Layout', children: [ {type: 'Label'}, @@ -1440,107 +1423,80 @@ export class DocWorkerApi { children: [ {type: 'Label'}, {type: 'Label'}, - ...editable.slice(0, INITIAL_FIELDS_COUNT).map(f => ({ - type: 'Field' as BoxType, - leaf: f.id - })) - ] - } + ...fields.slice(0, INITIAL_FIELDS_COUNT).map(f => ({ + type: 'Field', + leaf: f.id, + })), + ], + }, ], - }; + }); } - // Cache the table reads based on tableId. We are caching only the promise, not the result, + // Cache the table reads based on tableId. We are caching only the promise, not the result. const table = _.memoize( - (tableId: string) => readTable(req, activeDoc, tableId, { }, { }).then(r => asRecords(r)) + (tableId: string) => readTable(req, activeDoc, tableId, {}, {}).then(r => asRecords(r)) ); - const readValues = async (tId: string, colId: string) => { - const records = await table(tId); - return records.map(r => [r.id as number, r.fields[colId]]); + const getTableValues = async (tableId: string, colId: string) => { + const records = await table(tableId); + return records.map(r => [r.id as number, r.fields[colId]] as const); }; - const refValues = (col: MetaRowRecord<'_grist_Tables_column'>) => { - return async () => { - const refId = col.visibleCol; - if (!refId) { return [] as any; } - const refCol = Tables_column.getRecord(refId); - if (!refCol) { return []; } - const refTable = Tables.getRecord(refCol.parentId); - if (!refTable) { return []; } - const refTableId = refTable.tableId as string; - const refColId = refCol.colId as string; - if (!refTableId || !refColId) { return () => []; } - if (typeof refTableId !== 'string' || typeof refColId !== 'string') { return []; } - return await readValues(refTableId, refColId); - }; - }; + const Tables = activeDoc.docData.getMetaTable('_grist_Tables'); - const context: RenderContext = { - field(fieldRef: number): FieldModel { - const field = Views_section_field.getRecord(fieldRef); - if (!field) { throw new Error(`Field ${fieldRef} not found`); } - const col = Tables_column.getRecord(field.colRef); - if (!col) { throw new Error(`Column ${field.colRef} not found`); } - const fieldOptions = safeJsonParse(field.widgetOptions as string, {}); - const colOptions = safeJsonParse(col.widgetOptions as string, {}); - const options = {...colOptions, ...fieldOptions}; - const type = extractTypeFromColType(col.type as string); - const colId = col.colId as string; - - return { - colId, - description: fieldOptions.description || col.description, - question: options.question || col.label || colId, - options, - type, - isFormula: Boolean(col.isFormula && col.formula), - // If this is reference field, we will need to fetch the referenced table. - values: refValues(col) - }; - }, - root: box + const getRefTableValues = async (col: MetaRowRecord<'_grist_Tables_column'>) => { + const refId = col.visibleCol; + if (!refId) { return [] as any; } + + const refCol = Tables_column.getRecord(refId); + if (!refCol) { return []; } + + const refTable = Tables.getRecord(refCol.parentId); + if (!refTable) { return []; } + + const refTableId = refTable.tableId as string; + const refColId = refCol.colId as string; + if (!refTableId || !refColId) { return () => []; } + if (typeof refTableId !== 'string' || typeof refColId !== 'string') { return []; } + + return await getTableValues(refTableId, refColId); }; - // Now render the box to HTML. + const formFields = await Promise.all(fields.map(async (field) => { + const col = Tables_column.getRecord(field.colRef); + if (!col) { throw new Error(`Column ${field.colRef} not found`); } + + const fieldOptions = safeJsonParse(field.widgetOptions as string, {}); + const colOptions = safeJsonParse(col.widgetOptions as string, {}); + const options = {...colOptions, ...fieldOptions}; + const type = extractTypeFromColType(col.type as string); + const colId = col.colId as string; + + return [field.id, { + colId, + description: fieldOptions.description || col.description, + question: options.question || col.label || colId, + options, + type, + refValues: isFullReferencingType(col.type) ? await getRefTableValues(col) : null, + }] as const; + })); + const formFieldsById = Object.fromEntries(formFields); + + const getTableName = () => { + const rawSectionRef = Tables.getRecord(section.tableRef)?.rawViewSectionRef; + if (!rawSectionRef) { return null; } + + const rawSection = activeDoc.docData! + .getMetaTable('_grist_Views_section') + .getRecord(rawSectionRef); + return rawSection?.title ?? null; + }; - let redirectUrl = !box.successURL ? '' : box.successURL; - // Make sure it is a valid URL. - try { - new URL(redirectUrl); - } catch (e) { - redirectUrl = ''; - } + const formTableId = await getRealTableId(String(section.tableRef), {activeDoc, req}); + const formTitle = section.title || getTableName() || formTableId; - const html = await RenderBox.new(box, context).toHTML(); - // And wrap it with the form template. - const form = await fse.readFile(path.join(getAppPathTo(this._staticPath, 'static'), - 'forms/form.html'), 'utf8'); - const staticOrigin = process.env.APP_STATIC_URL || ""; - const staticBaseUrl = `${staticOrigin}/v/${this._grist.getTag()}/`; - // Fill out the blanks and send the result. - const doc = await this._dbManager.getDoc(req); - const tableId = await getRealTableId(String(section.tableRef), {activeDoc, req}); - - const rawSectionRef = tableRecord?.rawViewSectionRef; - const rawSection = !rawSectionRef ? null : - activeDoc.docData!.getMetaTable('_grist_Views_section').getRecord(rawSectionRef); - const tableName = rawSection?.title; - - const template = handlebars.compile(form); - const renderedHtml = template({ - // Trusted content generated by us. - BASE: staticBaseUrl, - DOC_URL: await this._grist.getResourceUrl(doc, 'html'), - TABLE_ID: tableId, - ANOTHER_RESPONSE: Boolean(box.anotherResponse), - // Not trusted content entered by user. - CONTENT: html, - SUCCESS_TEXT: box.successText || 'Thank you! Your response has been recorded.', - SUCCESS_URL: redirectUrl, - TITLE: `${section.title || tableName || tableId || 'Form'} - Grist`, - FORMS_LANDING_PAGE_URL: commonUrls.forms, - }); this._grist.getTelemetry().logEvent(req, 'visitedForm', { full: { docIdDigest: activeDoc.docName, @@ -1548,55 +1504,52 @@ export class DocWorkerApi { altSessionId: req.altSessionId, }, }); - res.status(200).send(renderedHtml); + + res.status(200).json({ + formFieldsById, + formLayoutSpec, + formTableId, + formTitle, + }); }) ); } /** - * Throws if the specified section is not of a published form. + * Throws if the specified section is not a published form. */ - private _assertFormIsPublished(params: { - docData: DocData | null, + private _assertIsPublishedForm(params: { + docData: DocData, linkId: string, sectionId: number, }) { const {docData, linkId, sectionId} = params; - if (!docData) { - throw new ApiError('DocData not available', 500); - } - - const notFoundError = () => { - throw new ApiError("Oops! The form you're looking for doesn't exist.", 404, { - code: 'FormNotFound', - }); - }; // Check that the request is for a valid section in the document. const sections = docData.getMetaTable('_grist_Views_section'); const section = sections.getRecord(sectionId); - if (!section) { return notFoundError(); } + if (!section) { throw new ApiError('Form not found', 404, {code: 'FormNotFound'}); } // Check that the section is for a form. const sectionShareOptions = safeJsonParse(section.shareOptions, {}); - if (!sectionShareOptions.form) { return notFoundError(); } + if (!sectionShareOptions.form) { throw new ApiError('Form not found', 404, {code: 'FormNotFound'}); } // Check that the form is associated with a share. const viewId = section.parentId; const pages = docData.getMetaTable('_grist_Pages'); const page = pages.getRecords().find(p => p.viewRef === viewId); - if (!page) { return notFoundError(); } + if (!page) { throw new ApiError('Form not found', 404, {code: 'FormNotFound'}); } const shares = docData.getMetaTable('_grist_Shares'); const share = shares.getRecord(page.shareRef); - if (!share) { return notFoundError(); } + if (!share) { throw new ApiError('Form not found', 404, {code: 'FormNotFound'}); } // Check that the share's link id matches the expected link id. - if (share.linkId !== linkId) { return notFoundError(); } + if (share.linkId !== linkId) { throw new ApiError('Form not found', 404, {code: 'FormNotFound'}); } // Finally, check that both the section and share are published. if (!sectionShareOptions.publish || !safeJsonParse(share.options, {})?.publish) { - throw new ApiError('Oops! This form is no longer published.', 404, {code: 'FormNotFound'}); + throw new ApiError('Form not published', 404, {code: 'FormNotPublished'}); } } @@ -2140,9 +2093,9 @@ export class DocWorkerApi { export function addDocApiRoutes( app: Application, docWorker: DocWorker, docWorkerMap: IDocWorkerMap, docManager: DocManager, dbManager: HomeDBManager, - grist: GristServer, staticPath: string + grist: GristServer ) { - const api = new DocWorkerApi(app, docWorker, docWorkerMap, docManager, dbManager, grist, staticPath); + const api = new DocWorkerApi(app, docWorker, docWorkerMap, docManager, dbManager, grist); api.addEndpoints(); } diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 5d95661c..c1127958 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -1284,7 +1284,7 @@ export class FlexServer implements GristServer { this._addSupportPaths(docAccessMiddleware); if (!isSingleUserMode()) { - addDocApiRoutes(this.app, docWorker, this._docWorkerMap, docManager, this._dbManager, this, this.appRoot); + addDocApiRoutes(this.app, docWorker, this._docWorkerMap, docManager, this._dbManager, this); } } @@ -1513,7 +1513,6 @@ export class FlexServer implements GristServer { if (resp.headersSent || !this._sendAppPage) { return next(err); } try { const errPage = ( - err.details?.code === 'FormNotFound' ? 'form-not-found' : err.status === 403 ? 'access-denied' : err.status === 404 ? 'not-found' : 'other-error' diff --git a/buildtools/webpack.config.js b/buildtools/webpack.config.js index 0ffb29e1..eb0bca39 100644 --- a/buildtools/webpack.config.js +++ b/buildtools/webpack.config.js @@ -15,6 +15,7 @@ module.exports = { errorPages: "app/client/errorMain", apiconsole: "app/client/apiconsole", billing: "app/client/billingMain", + form: "app/client/formMain", // Include client test harness if it is present (it won't be in // docker image). ...(fs.existsSync("test/client-harness/client.js") ? { diff --git a/sandbox/grist/useractions.py b/sandbox/grist/useractions.py index 3c13e60d..110b1954 100644 --- a/sandbox/grist/useractions.py +++ b/sandbox/grist/useractions.py @@ -2165,11 +2165,7 @@ class UserActions(object): title = '' section = self._docmodel.add(view_sections, tableRef=tableRef, parentKey=section_type, title=title, borderWidth=1, defaultWidth=100)[0] - # TODO: We should address the automatic selection of fields for charts - # and forms in a better way. - limit = 2 if section_type == 'chart' else 9 if section_type == 'form' else None - self._RebuildViewFields(tableId, section.id, - limit=limit) + self._RebuildViewFields(tableId, section.id) return section def _create_record_card_view_section(self, tableRef, tableId, view_sections): @@ -2277,8 +2273,7 @@ class UserActions(object): parentKey=view_section_type, title=title, borderWidth=1, defaultWidth=100, sortColRefs='[]')[0] - self._RebuildViewFields(table_id, section.id, - limit=(2 if view_section_type == 'chart' else None)) + self._RebuildViewFields(table_id, section.id) return {"id": section.id} # TODO: Deprecated; should just use RemoveRecord('_grist_Views_section', view_id) @@ -2294,7 +2289,7 @@ class UserActions(object): # Methods for creating and maintaining default views. This is a work-in-progress. #-------------------------------------------------------------------------------- - def _RebuildViewFields(self, table_id, section_row_id, limit=None): + def _RebuildViewFields(self, table_id, section_row_id): """ Does the actual work of rebuilding ViewFields to correspond to the table's columns. """ @@ -2305,7 +2300,8 @@ class UserActions(object): if section_rec.fields: self._docmodel.remove(section_rec.fields) - is_card = section_rec.parentKey in ('single', 'detail') + section_type = section_rec.parentKey + is_card = section_type in ('single', 'detail') is_record_card = section_rec == table_rec.recordCardViewSectionRef if is_card and not is_record_card: # Copy settings from the table's record card section to the new section. @@ -2317,6 +2313,14 @@ class UserActions(object): cols = [c for c in table_rec.columns if column.is_visible_column(c.colId) # TODO: hack to avoid auto-adding the 'group' column when detaching summary tables. and c.colId != 'group'] + limit = None + if section_type == 'chart': + # TODO: We should address the automatic selection of fields for charts in a better way. + limit = 2 + elif section_type == 'form': + # Attachments and formulas are currently unsupported in forms. + cols = [c for c in cols if not (c.type == 'Attachments' or (c.isFormula and c.formula))] + limit = 9 cols.sort(key=lambda c: c.parentPos) if limit is not None: cols = cols[:limit] diff --git a/static/form.html b/static/form.html new file mode 100644 index 00000000..1c73d99a --- /dev/null +++ b/static/form.html @@ -0,0 +1,14 @@ + + + + + + + + Loading...<!-- INSERT TITLE SUFFIX --> + + + + + + diff --git a/static/forms/README.md b/static/forms/README.md deleted file mode 100644 index 62ac4ae4..00000000 --- a/static/forms/README.md +++ /dev/null @@ -1,19 +0,0 @@ -## grist-form-submit.js - -File is taken from https://github.com/gristlabs/grist-form-submit. But it is modified to work with -forms, especially for: -- Ref and RefList columns, as by default it sends numbers as strings (FormData issue), and Grist - doesn't know how to convert them back to numbers. -- Empty strings are not sent at all - otherwise Grist won't be able to fire trigger formulas - correctly and provide default values for columns. -- By default it requires a redirect URL, now it is optional. - - -## purify.min.js - -File taken from https://www.npmjs.com/package/dompurify. It is used to sanitize HTML. It wasn't -modified at all. - -## form.html - -This is handlebars template filled by DocApi.ts diff --git a/static/forms/form.css b/static/forms/form.css deleted file mode 100644 index 1281288f..00000000 --- a/static/forms/form.css +++ /dev/null @@ -1,533 +0,0 @@ -html, -body { - background-color: #f7f7f7; - padding: 0px; - margin: 0px; - line-height: 1.42857143; -} - -* { - box-sizing: border-box; -} - -.grist-form-container { - --icon-Tick: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjYxODMwNjksNC42NzcwMjg0NyBDMTEuNzk2Njc4OSw0LjQ2NjIyNTE3IDEyLjExMjE2NzgsNC40Mzk5MzQ0MyAxMi4zMjI5NzExLDQuNjE4MzA2NDUgQzEyLjUzMzc3NDQsNC43OTY2Nzg0OCAxMi41NjAwNjUyLDUuMTEyMTY3NDEgMTIuMzgxNjkzMSw1LjMyMjk3MDcxIEw2LjUzMDY4ODI3LDEyLjIzNzc5NDYgTDMuNjQ2NDQ2NjEsOS4zNTM1NTI5OCBDMy40NTExODQ0Niw5LjE1ODI5MDg0IDMuNDUxMTg0NDYsOC44NDE3MDgzNSAzLjY0NjQ0NjYxLDguNjQ2NDQ2MiBDMy44NDE3MDg3Niw4LjQ1MTE4NDA2IDQuMTU4MjkxMjQsOC40NTExODQwNiA0LjM1MzU1MzM5LDguNjQ2NDQ2MiBMNi40NjkzMTE3MywxMC43NjIyMDQ1IEwxMS42MTgzMDY5LDQuNjc3MDI4NDcgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+); - --icon-Minus: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB4PSIyIiB5PSI3LjUiIHdpZHRoPSIxMiIgaGVpZ2h0PSIxIiByeD0iLjUiLz48L3N2Zz4=); - --icon-Expand: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsOS4xNzQ2MzA1MiBMMTAuOTIxODI3Myw2LjE4OTAyMzIgQzExLjE2ODQ3NDIsNS45MzY5OTIyNyAxMS41NjgzNjc5LDUuOTM2OTkyMjcgMTEuODE1MDE0OCw2LjE4OTAyMzIgQzEyLjA2MTY2MTcsNi40NDEwNTQxMyAxMi4wNjE2NjE3LDYuODQ5Njc3MDEgMTEuODE1MDE0OCw3LjEwMTcwNzk0IEw4LDExIEw0LjE4NDk4NTE5LDcuMTAxNzA3OTQgQzMuOTM4MzM4MjcsNi44NDk2NzcwMSAzLjkzODMzODI3LDYuNDQxMDU0MTMgNC4xODQ5ODUxOSw2LjE4OTAyMzIgQzQuNDMxNjMyMTEsNS45MzY5OTIyNyA0LjgzMTUyNTc4LDUuOTM2OTkyMjcgNS4wNzgxNzI3LDYuMTg5MDIzMiBMOCw5LjE3NDYzMDUyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIgdHJhbnNmb3JtPSJyb3RhdGUoLTkwIDggOC41KSIvPjwvc3ZnPg=='); - --primary: #16b378; - --primary-dark: #009058; - --dark-gray: #D9D9D9; - --light-gray: #bfbfbf; - --light: white; - - color: #262633; - background-color: #f7f7f7; - min-height: 100%; - width: 100%; - padding: 52px 0px 52px 0px; - font-size: 15px; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Liberation Sans", Helvetica, Arial, sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; -} - -.grist-form-container .grist-form-confirm { - background-color: white; - text-align: center; - display: flex; - flex-direction: column; - align-items: center; - gap: 16px; - border: 1px solid var(--dark-gray); - border-radius: 3px; - max-width: 600px; - margin: 0px auto; -} - -.grist-form { - margin: 0px auto; - background-color: white; - border: 1px solid var(--dark-gray); - width: 600px; - border-radius: 8px; - display: flex; - flex-direction: column; - max-width: calc(100% - 32px); - margin-bottom: 16px; - padding-top: 20px; - --grist-form-padding: 48px; - padding-left: var(--grist-form-padding); - padding-right: var(--grist-form-padding); -} - -@media screen and (max-width: 600px) { - .grist-form-container { - padding: 20px 0px 20px 0px; - } - - .grist-form { - --grist-form-padding: 20px; - } -} - -.grist-form > div + div { - margin-top: 16px; -} - -.grist-form .grist-section { - border-radius: 3px; - border: 1px solid var(--dark-gray); - padding: 16px 24px; - padding: 24px; - margin-top: 24px; -} - -.grist-form .grist-section > div + div { - margin-top: 16px; -} - -.grist-form input[type="text"], -.grist-form input[type="date"], -.grist-form input[type="datetime-local"], -.grist-form input[type="number"] { - height: 27px; - padding: 4px 8px; - border: 1px solid var(--dark-gray); - border-radius: 3px; - outline: none; -} - -.grist-form .grist-field { - display: flex; - flex-direction: column; -} - -.grist-form .grist-field .grist-field-description { - color: #222; - font-size: 12px; - font-weight: 400; - margin-top: 4px; - white-space: pre-wrap; - font-style: italic; - font-weight: 400; - line-height: 1.6; -} - -.grist-form .grist-field input[type="text"] { - padding: 4px 8px; - border-radius: 3px; - border: 1px solid var(--dark-gray); - font-size: 13px; - outline-color: var(--primary); - outline-width: 1px; - line-height: inherit; - width: 100%; -} - -.grist-form .grist-submit, .grist-form-container button { - display: flex; - justify-content: center; - align-items: center; -} - -.grist-form input[type="submit"], .grist-form-container button { - background-color: var(--primary); - border: 1px solid var(--primary); - color: white; - padding: 10px 24px; - border-radius: 4px; - font-size: 13px; - cursor: pointer; - line-height: inherit; -} - -.grist-form input[type="datetime-local"] { - width: 100%; - line-height: inherit; -} - -.grist-form input[type="date"] { - width: 100%; - line-height: inherit; -} - -.grist-form .grist-columns { - display: grid; - grid-template-columns: repeat(var(--grist-columns-count), 1fr); - gap: 4px; -} - -.grist-form select { - padding: 4px 8px; - border-radius: 3px; - border: 1px solid var(--dark-gray); - font-size: 13px; - outline-color: var(--primary); - outline-width: 1px; - background: white; - line-height: inherit; - height: 27px; - flex: auto; - width: 100%; -} - -.grist-form .grist-checkbox-list { - display: flex; - flex-direction: column; - gap: 4px; -} - -.grist-form .grist-checkbox { - display: flex; -} -.grist-form .grist-checkbox:hover { - --color: var(--light-gray); -} - -.grist-form input[type="checkbox"] { - -webkit-appearance: none; - -moz-appearance: none; - padding: 0; - flex-shrink: 0; - display: inline-block; - width: 16px; - height: 16px; - --radius: 3px; - position: relative; - margin-right: 8px; - vertical-align: baseline; -} - -.grist-form input[type="checkbox"]:checked:enabled, .grist-form input[type="checkbox"]:indeterminate:enabled { - --color: var(--primary); -} - -.grist-form input[type="checkbox"]:disabled { - --color: var(--dark-gray); - cursor: not-allowed; -} - -.grist-form input[type="checkbox"]::before, .grist-form input[type="checkbox"]::after { - content: ''; - - position: absolute; - top: 0; - left: 0; - - height: 16px; - width: 16px; - - box-sizing: border-box; - border: 1px solid var(--color, var(--dark-gray)); - border-radius: var(--radius); -} - -.grist-form input[type="checkbox"]:checked::before, .grist-form input[type="checkbox"]:disabled::before, .grist-form input[type="checkbox"]:indeterminate::before { - background-color: var(--color); -} - -.grist-form input[type="checkbox"]:not(:checked):indeterminate::after { - -webkit-mask-image: var(--icon-Minus); -} - -.grist-form input[type="checkbox"]:not(:disabled)::after { - background-color: var(--light); -} - -.grist-form input[type="checkbox"]:checked::after, .grist-form input[type="checkbox"]:indeterminate::after { - content: ''; - position: absolute; - height: 16px; - width: 16px; - -webkit-mask-image: var(--icon-Tick); - -webkit-mask-size: contain; - -webkit-mask-position: center; - -webkit-mask-repeat: no-repeat; - background-color: var(--light); -} - -.grist-form .grist-submit input[type="submit"]:hover, .grist-form-container button:hover { - border-color: var(--primary-dark); - background-color: var(--primary-dark); -} - -.grist-power-by { - color: #494949; - font-size: 13px; - font-style: normal; - font-weight: 600; - line-height: 16px; - display: flex; - align-items: center; - justify-content: center; - padding-left: 10px; - padding-right: 10px; -} - -.grist-power-by a { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - color: #494949; - text-decoration: none; -} - -.grist-logo { - width: 58px; - height: 20.416px; - flex-shrink: 0; - background: url(logo.png); - background-position: 0 0; - background-size: contain; - background-color: transparent; - background-repeat: no-repeat; - margin-top: 3px; -} - -.grist-question > .grist-label { - color: var(--dark, #262633); - font-size: 13px; - font-style: normal; - font-weight: 700; - line-height: 16px; /* 145.455% */ - margin-top: 8px; - margin-bottom: 8px; - display: block; -} - -.grist-label-required::after { - content: "*"; - color: var(--primary, #16b378); - margin-left: 4px; -} - -/* Markdown reset */ - -.grist-form h1, -.grist-form h2, -.grist-form h3, -.grist-form h4, -.grist-form h5, -.grist-form h6 { - margin: 4px 0px; - font-weight: normal; -} -.grist-form h1 { - font-size: 24px; -} -.grist-form h2 { - font-size: 22px; -} -.grist-form h3 { - font-size: 16px; -} -.grist-form h4 { - font-size: 13px; -} -.grist-form h5 { - font-size: 11px; -} -.grist-form h6 { - font-size: 10px; -} -.grist-form p { - margin: 0px; -} -.grist-form strong { - font-weight: 600; -} -.grist-form hr { - border: 0px; - border-top: 1px solid var(--dark-gray); - margin: 4px 0px; -} - -.grist-text-left { - text-align: left; -} -.grist-text-right { - text-align: right; -} -.grist-text-center { - text-align: center; -} - -.grist-switch { - cursor: pointer; - display: inline-flex; - align-items: center; -} -.grist-switch input[type='checkbox']::after { - content: none; -} -.grist-switch input[type='checkbox']::before { - content: none; -} -.grist-switch input[type='checkbox'] { - position: absolute; -} -.grist-switch > span { - margin-left: 8px; -} - -/* Slider component */ -.grist-widget_switch { - position: relative; - width: 30px; - height: 17px; - display: inline-block; - flex: none; -} - -.grist-switch_slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: var(--grist-theme-switch-slider-fg, #ccc); - border-radius: 17px; -} - -.grist-switch_slider:hover { - box-shadow: 0 0 1px #2196F3; -} - -.grist-switch_circle { - position: absolute; - cursor: pointer; - content: ""; - height: 13px; - width: 13px; - left: 2px; - bottom: 2px; - background-color: var(--grist-theme-switch-circle-fg, white); - border-radius: 17px; -} - -input:checked + .grist-switch_transition > .grist-switch_slider { - background-color: var(--primary, #16b378); -} - -input:checked + .grist-switch_transition > .grist-switch_circle { - -webkit-transform: translateX(13px); - -ms-transform: translateX(13px); - transform: translateX(13px); -} - -.grist-switch_on > .grist-switch_slider { - background-color: var(--grist-actual-cell-color, #2CB0AF); -} - -.grist-switch_on > .grist-switch_circle { - -webkit-transform: translateX(13px); - -ms-transform: translateX(13px); - transform: translateX(13px); -} - -.grist-switch_transition > .grist-switch_slider, .grist-switch_transition > .grist-switch_circle { - -webkit-transition: .4s; - transition: .4s; -} - -.grist-form-confirm-container { - padding-left: 16px; - padding-right: 16px; -} - -.grist-form-confirm-body { - padding: 48px 16px 16px 16px; -} - -.grist-form-confirm-image { - width: 100%; - height: 100%; - max-width: 250px; - max-height: 215px; -} - -.grist-form-confirm-text { - font-weight: 600; - font-size: 16px; - line-height: 24px; - margin-top: 32px; - white-space: prewrap; -} - -.grist-form-confirm-buttons { - display: flex; - justify-content: center; - align-items: center; - margin-top: 24px; -} - -.grist-form-confirm-new-response-button { - position: relative; - outline: none; - border-style: none; - line-height: normal; - user-select: none; - display: flex; - justify-content: center; - align-items: center; - padding: 12px 24px; - min-height: 40px; - background: var(--primary, #16B378); - border-radius: 3px; - color: #FFFFFF; -} - -.grist-form-confirm-new-response-button:hover { - background: var(--primary-dark); - cursor: pointer; -} - -.grist-form-footer, -.grist-form-confirm-footer { - border-top: 1px solid var(--dark-gray); - padding: 8px 16px; -} - -.grist-form-footer { - margin-left: calc(-1 * var(--grist-form-padding)); - margin-right: calc(-1 * var(--grist-form-padding)); -} - -.grist-form-confirm-footer { - width: 100%; -} - -.grist-form-build-form-link-container { - display: flex; - align-items: center; - justify-content: center; - margin-top: 8px; -} - -.grist-form-build-form-link { - display: flex; - align-items: center; - justify-content: center; - font-size: 11px; - line-height: 16px; - text-decoration-line: underline; - color: var(--primary-dark); -} - -.grist-form-icon { - position: relative; - display: inline-block; - vertical-align: middle; - -webkit-mask-repeat: no-repeat; - -webkit-mask-position: center; - -webkit-mask-size: contain; - width: 16px; - height: 16px; - background-color: black; -} - -.grist-form-icon-expand { - -webkit-mask-image: var(--icon-Expand); - background-color: var(--primary-dark); -} diff --git a/static/forms/form.html b/static/forms/form.html deleted file mode 100644 index da26a595..00000000 --- a/static/forms/form.html +++ /dev/null @@ -1,108 +0,0 @@ - - - - - {{#if BASE}} - - {{/if}} - {{ TITLE }} - - - - - - - - - -
- - {{ dompurify CONTENT }} - - - -
- -
-
- - - - diff --git a/static/forms/grist-form-submit.js b/static/forms/grist-form-submit.js deleted file mode 100644 index 6286cf9d..00000000 --- a/static/forms/grist-form-submit.js +++ /dev/null @@ -1,211 +0,0 @@ -// If the script is loaded multiple times, only register the handlers once. -if (!window.gristFormSubmit) { - (function() { - -/** - * gristFormSubmit(gristDocUrl, gristTableId, formData) - * - `gristDocUrl` should be the URL of the Grist document, from step 1 of the setup instructions. - * - `gristTableId` should be the table ID from step 2. - * - `formData` should be a [FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData) - * object, typically obtained as `new FormData(formElement)`. Inside the `submit` event handler, it - * can be convenient to use `new FormData(event.target)`. - * - formElement is the form element that was submitted. - * - * This function sends values from `formData` to add a new record in the specified Grist table. It - * returns a promise for the result of the add-record API call. In case of an error, the promise - * will be rejected with an error message. - */ -async function gristFormSubmit(docUrl, tableId, formData, formElement) { - // Pick out the server and docId from the docUrl. - const match = /^(https?:\/\/[^\/]+(?:\/o\/[^\/]+)?)\/(?:doc\/([^\/?#]+)|([^\/?#]{12,})\/)/.exec(docUrl); - if (!match) { throw new Error("Invalid Grist doc URL " + docUrl); } - const server = match[1]; - const docId = match[2] || match[3]; - - // Construct the URL to use for the add-record API endpoint. - const destUrl = server + "/api/docs/" + docId + "/tables/" + tableId + "/records"; - - const payload = {records: [{fields: formDataToJson(formData, formElement)}]}; - const options = { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify(payload), - }; - - const resp = await window.fetch(destUrl, options); - if (resp.status !== 200) { - // Try to report a helpful error. - let body = '', error, match; - try { body = await resp.json(); } catch (e) {} - if (typeof body.error === 'string' && (match = /KeyError '(.*)'/.exec(body.error))) { - error = 'No column "' + match[1] + '" in table "' + tableId + '". ' + - 'Be sure to use column ID rather than column label'; - } else { - error = body.error || String(body); - } - throw new Error('Failed to add record: ' + error); - } - - return await resp.json(); -} - - -// Convert FormData into a mapping of Grist fields. Skips any keys starting with underscore. -// For fields with multiple values (such as to populate ChoiceList), use field names like `foo[]` -// (with the name ending in a pair of empty square brackets). -function formDataToJson(f) { - const keys = Array.from(f.keys()).filter(k => !k.startsWith("_")); - return Object.fromEntries(keys.map(k => - k.endsWith('[]') ? [k.slice(0, -2), ['L', ...f.getAll(k)]] : [k, f.get(k)])); -} - -/** - * TypedFormData is a wrapper around FormData that provides type information for the fields. - */ -class TypedFormData { - constructor(formElement, formData) { - if (!(formElement instanceof HTMLFormElement)) throw new Error("formElement must be a form"); - if (formData && !(formData instanceof FormData)) throw new Error("formData must be a FormData"); - this._formData = formData ?? new FormData(formElement); - this._formElement = formElement; - } - keys() { - const keys = Array.from(this._formData.keys()); - - // Don't return keys for scalar values which just return empty string. - // Otherwise Grist won't fire trigger formulas. - return keys.filter(key => { - // If there are multiple values, return this key as it is. - if (this._formData.getAll(key).length !== 1) { return true; } - // If the value is empty string or null, don't return the key. - const value = this._formData.get(key); - return value !== '' && value !== null; - }); - } - type(key) { - return this._formElement?.querySelector(`[name="${key}"]`)?.getAttribute('data-grist-type'); - } - get(key) { - const value = this._formData.get(key); - if (value === null) { return null; } - const type = this.type(key); - return type === 'Ref' || type === 'RefList' ? Number(value) : value; - } - getAll(key) { - const values = Array.from(this._formData.getAll(key)); - if (['Ref', 'RefList'].includes(this.type(key))) { - return values.map(v => Number(v)); - } - return values; - } -} - - -// Handle submissions for plain forms that include special data-grist-* attributes. -async function handleSubmitPlainForm(ev) { - if (!['data-grist-doc', 'data-grist-table'] - .some(attr => ev.target.hasAttribute(attr))) { - // This form isn't configured for Grist at all; don't interfere with it. - return; - } - - ev.preventDefault(); - try { - const docUrl = ev.target.getAttribute('data-grist-doc'); - const tableId = ev.target.getAttribute('data-grist-table'); - if (!docUrl) { throw new Error("Missing attribute data-grist-doc='GRIST_DOC_URL'"); } - if (!tableId) { throw new Error("Missing attribute data-grist-table='GRIST_TABLE_ID'"); } - - const successUrl = ev.target.getAttribute('data-grist-success-url'); - - await gristFormSubmit(docUrl, tableId, new TypedFormData(ev.target)); - - // On success, redirect to the requested URL. - if (successUrl) { - window.location.href = successUrl; - } - - } catch (err) { - reportSubmitError(ev, err); - } -} - -function reportSubmitError(ev, err) { - console.warn("grist-form-submit error:", err.message); - // Find an element to use for the validation message to alert the user. - let scapegoat = null; - ( - (scapegoat = ev.submitter)?.setCustomValidity || - (scapegoat = ev.target.querySelector('input[type=submit]'))?.setCustomValidity || - (scapegoat = ev.target.querySelector('button'))?.setCustomValidity || - (scapegoat = [...ev.target.querySelectorAll('input')].pop())?.setCustomValidity - ) - scapegoat?.setCustomValidity("Form misconfigured: " + err.message); - ev.target.reportValidity(); -} - -// Handle submissions for Contact Form 7 forms. -async function handleSubmitWPCF7(ev) { - try { - const formId = ev.detail.contactFormId; - const docUrl = ev.target.querySelector('[data-grist-doc]')?.getAttribute('data-grist-doc'); - const tableId = ev.target.querySelector('[data-grist-table]')?.getAttribute('data-grist-table'); - if (!docUrl) { throw new Error("Missing attribute data-grist-doc='GRIST_DOC_URL'"); } - if (!tableId) { throw new Error("Missing attribute data-grist-table='GRIST_TABLE_ID'"); } - - await gristFormSubmit(docUrl, tableId, new TypedFormData(ev.target)); - console.log("grist-form-submit WPCF7 Form %s: Added record", formId); - - } catch (err) { - console.warn("grist-form-submit WPCF7 Form %s misconfigured:", formId, err.message); - } -} - -function setUpGravityForms(options) { - // Use capture to get the event before GravityForms processes it. - document.addEventListener('submit', ev => handleSubmitGravityForm(ev, options), true); -} -gristFormSubmit.setUpGravityForms = setUpGravityForms; - -async function handleSubmitGravityForm(ev, options) { - try { - ev.preventDefault(); - ev.stopPropagation(); - - const docUrl = options.docUrl; - const tableId = options.tableId; - if (!docUrl) { throw new Error("setUpGravityForm: missing docUrl option"); } - if (!tableId) { throw new Error("setUpGravityForm: missing tableId option"); } - - const f = new TypedFormData(ev.target); - for (const key of Array.from(f.keys())) { - // Skip fields other than input fields. - if (!key.startsWith("input_")) { - f.delete(key); - continue; - } - // Rename multiple fields to use "[]" convention rather than ".N" convention. - const multi = key.split("."); - if (multi.length > 1) { - f.append(multi[0] + "[]", f.get(key)); - f.delete(key); - } - } - console.warn("Processed FormData", f); - await gristFormSubmit(docUrl, tableId, f); - - // Follow through by doing the form submission normally. - ev.target.submit(); - - } catch (err) { - reportSubmitError(ev, err); - return; - } -} - -window.gristFormSubmit = gristFormSubmit; -document.addEventListener('submit', handleSubmitPlainForm); -document.addEventListener('wpcf7mailsent', handleSubmitWPCF7); - - })(); -} diff --git a/static/forms/purify.min.js b/static/forms/purify.min.js deleted file mode 100644 index 7a4da768..00000000 --- a/static/forms/purify.min.js +++ /dev/null @@ -1,3 +0,0 @@ -/*! @license DOMPurify 3.0.6 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.0.6/LICENSE */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).DOMPurify=t()}(this,(function(){"use strict";const{entries:e,setPrototypeOf:t,isFrozen:n,getPrototypeOf:o,getOwnPropertyDescriptor:r}=Object;let{freeze:i,seal:a,create:l}=Object,{apply:c,construct:s}="undefined"!=typeof Reflect&&Reflect;i||(i=function(e){return e}),a||(a=function(e){return e}),c||(c=function(e,t,n){return e.apply(t,n)}),s||(s=function(e,t){return new e(...t)});const u=N(Array.prototype.forEach),m=N(Array.prototype.pop),f=N(Array.prototype.push),p=N(String.prototype.toLowerCase),d=N(String.prototype.toString),h=N(String.prototype.match),g=N(String.prototype.replace),T=N(String.prototype.indexOf),y=N(String.prototype.trim),E=N(RegExp.prototype.test),A=(_=TypeError,function(){for(var e=arguments.length,t=new Array(e),n=0;n1?n-1:0),r=1;r2&&void 0!==arguments[2]?arguments[2]:p;t&&t(e,null);let i=o.length;for(;i--;){let t=o[i];if("string"==typeof t){const e=r(t);e!==t&&(n(o)||(o[i]=e),t=e)}e[t]=!0}return e}function S(t){const n=l(null);for(const[o,i]of e(t))void 0!==r(t,o)&&(n[o]=i);return n}function R(e,t){for(;null!==e;){const n=r(e,t);if(n){if(n.get)return N(n.get);if("function"==typeof n.value)return N(n.value)}e=o(e)}return function(e){return console.warn("fallback value for",e),null}}const w=i(["a","abbr","acronym","address","area","article","aside","audio","b","bdi","bdo","big","blink","blockquote","body","br","button","canvas","caption","center","cite","code","col","colgroup","content","data","datalist","dd","decorator","del","details","dfn","dialog","dir","div","dl","dt","element","em","fieldset","figcaption","figure","font","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","img","input","ins","kbd","label","legend","li","main","map","mark","marquee","menu","menuitem","meter","nav","nobr","ol","optgroup","option","output","p","picture","pre","progress","q","rp","rt","ruby","s","samp","section","select","shadow","small","source","spacer","span","strike","strong","style","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","track","tt","u","ul","var","video","wbr"]),D=i(["svg","a","altglyph","altglyphdef","altglyphitem","animatecolor","animatemotion","animatetransform","circle","clippath","defs","desc","ellipse","filter","font","g","glyph","glyphref","hkern","image","line","lineargradient","marker","mask","metadata","mpath","path","pattern","polygon","polyline","radialgradient","rect","stop","style","switch","symbol","text","textpath","title","tref","tspan","view","vkern"]),L=i(["feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feDistantLight","feDropShadow","feFlood","feFuncA","feFuncB","feFuncG","feFuncR","feGaussianBlur","feImage","feMerge","feMergeNode","feMorphology","feOffset","fePointLight","feSpecularLighting","feSpotLight","feTile","feTurbulence"]),v=i(["animate","color-profile","cursor","discard","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","foreignobject","hatch","hatchpath","mesh","meshgradient","meshpatch","meshrow","missing-glyph","script","set","solidcolor","unknown","use"]),x=i(["math","menclose","merror","mfenced","mfrac","mglyph","mi","mlabeledtr","mmultiscripts","mn","mo","mover","mpadded","mphantom","mroot","mrow","ms","mspace","msqrt","mstyle","msub","msup","msubsup","mtable","mtd","mtext","mtr","munder","munderover","mprescripts"]),k=i(["maction","maligngroup","malignmark","mlongdiv","mscarries","mscarry","msgroup","mstack","msline","msrow","semantics","annotation","annotation-xml","mprescripts","none"]),C=i(["#text"]),O=i(["accept","action","align","alt","autocapitalize","autocomplete","autopictureinpicture","autoplay","background","bgcolor","border","capture","cellpadding","cellspacing","checked","cite","class","clear","color","cols","colspan","controls","controlslist","coords","crossorigin","datetime","decoding","default","dir","disabled","disablepictureinpicture","disableremoteplayback","download","draggable","enctype","enterkeyhint","face","for","headers","height","hidden","high","href","hreflang","id","inputmode","integrity","ismap","kind","label","lang","list","loading","loop","low","max","maxlength","media","method","min","minlength","multiple","muted","name","nonce","noshade","novalidate","nowrap","open","optimum","pattern","placeholder","playsinline","poster","preload","pubdate","radiogroup","readonly","rel","required","rev","reversed","role","rows","rowspan","spellcheck","scope","selected","shape","size","sizes","span","srclang","start","src","srcset","step","style","summary","tabindex","title","translate","type","usemap","valign","value","width","xmlns","slot"]),I=i(["accent-height","accumulate","additive","alignment-baseline","ascent","attributename","attributetype","azimuth","basefrequency","baseline-shift","begin","bias","by","class","clip","clippathunits","clip-path","clip-rule","color","color-interpolation","color-interpolation-filters","color-profile","color-rendering","cx","cy","d","dx","dy","diffuseconstant","direction","display","divisor","dur","edgemode","elevation","end","fill","fill-opacity","fill-rule","filter","filterunits","flood-color","flood-opacity","font-family","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-weight","fx","fy","g1","g2","glyph-name","glyphref","gradientunits","gradienttransform","height","href","id","image-rendering","in","in2","k","k1","k2","k3","k4","kerning","keypoints","keysplines","keytimes","lang","lengthadjust","letter-spacing","kernelmatrix","kernelunitlength","lighting-color","local","marker-end","marker-mid","marker-start","markerheight","markerunits","markerwidth","maskcontentunits","maskunits","max","mask","media","method","mode","min","name","numoctaves","offset","operator","opacity","order","orient","orientation","origin","overflow","paint-order","path","pathlength","patterncontentunits","patterntransform","patternunits","points","preservealpha","preserveaspectratio","primitiveunits","r","rx","ry","radius","refx","refy","repeatcount","repeatdur","restart","result","rotate","scale","seed","shape-rendering","specularconstant","specularexponent","spreadmethod","startoffset","stddeviation","stitchtiles","stop-color","stop-opacity","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke","stroke-width","style","surfacescale","systemlanguage","tabindex","targetx","targety","transform","transform-origin","text-anchor","text-decoration","text-rendering","textlength","type","u1","u2","unicode","values","viewbox","visibility","version","vert-adv-y","vert-origin-x","vert-origin-y","width","word-spacing","wrap","writing-mode","xchannelselector","ychannelselector","x","x1","x2","xmlns","y","y1","y2","z","zoomandpan"]),M=i(["accent","accentunder","align","bevelled","close","columnsalign","columnlines","columnspan","denomalign","depth","dir","display","displaystyle","encoding","fence","frame","height","href","id","largeop","length","linethickness","lspace","lquote","mathbackground","mathcolor","mathsize","mathvariant","maxsize","minsize","movablelimits","notation","numalign","open","rowalign","rowlines","rowspacing","rowspan","rspace","rquote","scriptlevel","scriptminsize","scriptsizemultiplier","selection","separator","separators","stretchy","subscriptshift","supscriptshift","symmetric","voffset","width","xmlns"]),U=i(["xlink:href","xml:id","xlink:title","xml:space","xmlns:xlink"]),P=a(/\{\{[\w\W]*|[\w\W]*\}\}/gm),F=a(/<%[\w\W]*|[\w\W]*%>/gm),H=a(/\${[\w\W]*}/gm),z=a(/^data-[\-\w.\u00B7-\uFFFF]/),B=a(/^aria-[\-\w]+$/),W=a(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),G=a(/^(?:\w+script|data):/i),Y=a(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),j=a(/^html$/i);var q=Object.freeze({__proto__:null,MUSTACHE_EXPR:P,ERB_EXPR:F,TMPLIT_EXPR:H,DATA_ATTR:z,ARIA_ATTR:B,IS_ALLOWED_URI:W,IS_SCRIPT_OR_DATA:G,ATTR_WHITESPACE:Y,DOCTYPE_NAME:j});const X=function(){return"undefined"==typeof window?null:window},K=function(e,t){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let n=null;const o="data-tt-policy-suffix";t&&t.hasAttribute(o)&&(n=t.getAttribute(o));const r="dompurify"+(n?"#"+n:"");try{return e.createPolicy(r,{createHTML:e=>e,createScriptURL:e=>e})}catch(e){return console.warn("TrustedTypes policy "+r+" could not be created."),null}};var V=function t(){let n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:X();const o=e=>t(e);if(o.version="3.0.6",o.removed=[],!n||!n.document||9!==n.document.nodeType)return o.isSupported=!1,o;let{document:r}=n;const a=r,c=a.currentScript,{DocumentFragment:s,HTMLTemplateElement:_,Node:N,Element:P,NodeFilter:F,NamedNodeMap:H=n.NamedNodeMap||n.MozNamedAttrMap,HTMLFormElement:z,DOMParser:B,trustedTypes:G}=n,Y=P.prototype,V=R(Y,"cloneNode"),$=R(Y,"nextSibling"),Z=R(Y,"childNodes"),J=R(Y,"parentNode");if("function"==typeof _){const e=r.createElement("template");e.content&&e.content.ownerDocument&&(r=e.content.ownerDocument)}let Q,ee="";const{implementation:te,createNodeIterator:ne,createDocumentFragment:oe,getElementsByTagName:re}=r,{importNode:ie}=a;let ae={};o.isSupported="function"==typeof e&&"function"==typeof J&&te&&void 0!==te.createHTMLDocument;const{MUSTACHE_EXPR:le,ERB_EXPR:ce,TMPLIT_EXPR:se,DATA_ATTR:ue,ARIA_ATTR:me,IS_SCRIPT_OR_DATA:fe,ATTR_WHITESPACE:pe}=q;let{IS_ALLOWED_URI:de}=q,he=null;const ge=b({},[...w,...D,...L,...x,...C]);let Te=null;const ye=b({},[...O,...I,...M,...U]);let Ee=Object.seal(l(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),Ae=null,_e=null,Ne=!0,be=!0,Se=!1,Re=!0,we=!1,De=!1,Le=!1,ve=!1,xe=!1,ke=!1,Ce=!1,Oe=!0,Ie=!1;const Me="user-content-";let Ue=!0,Pe=!1,Fe={},He=null;const ze=b({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let Be=null;const We=b({},["audio","video","img","source","image","track"]);let Ge=null;const Ye=b({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),je="http://www.w3.org/1998/Math/MathML",qe="http://www.w3.org/2000/svg",Xe="http://www.w3.org/1999/xhtml";let Ke=Xe,Ve=!1,$e=null;const Ze=b({},[je,qe,Xe],d);let Je=null;const Qe=["application/xhtml+xml","text/html"],et="text/html";let tt=null,nt=null;const ot=r.createElement("form"),rt=function(e){return e instanceof RegExp||e instanceof Function},it=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!nt||nt!==e){if(e&&"object"==typeof e||(e={}),e=S(e),Je=Je=-1===Qe.indexOf(e.PARSER_MEDIA_TYPE)?et:e.PARSER_MEDIA_TYPE,tt="application/xhtml+xml"===Je?d:p,he="ALLOWED_TAGS"in e?b({},e.ALLOWED_TAGS,tt):ge,Te="ALLOWED_ATTR"in e?b({},e.ALLOWED_ATTR,tt):ye,$e="ALLOWED_NAMESPACES"in e?b({},e.ALLOWED_NAMESPACES,d):Ze,Ge="ADD_URI_SAFE_ATTR"in e?b(S(Ye),e.ADD_URI_SAFE_ATTR,tt):Ye,Be="ADD_DATA_URI_TAGS"in e?b(S(We),e.ADD_DATA_URI_TAGS,tt):We,He="FORBID_CONTENTS"in e?b({},e.FORBID_CONTENTS,tt):ze,Ae="FORBID_TAGS"in e?b({},e.FORBID_TAGS,tt):{},_e="FORBID_ATTR"in e?b({},e.FORBID_ATTR,tt):{},Fe="USE_PROFILES"in e&&e.USE_PROFILES,Ne=!1!==e.ALLOW_ARIA_ATTR,be=!1!==e.ALLOW_DATA_ATTR,Se=e.ALLOW_UNKNOWN_PROTOCOLS||!1,Re=!1!==e.ALLOW_SELF_CLOSE_IN_ATTR,we=e.SAFE_FOR_TEMPLATES||!1,De=e.WHOLE_DOCUMENT||!1,xe=e.RETURN_DOM||!1,ke=e.RETURN_DOM_FRAGMENT||!1,Ce=e.RETURN_TRUSTED_TYPE||!1,ve=e.FORCE_BODY||!1,Oe=!1!==e.SANITIZE_DOM,Ie=e.SANITIZE_NAMED_PROPS||!1,Ue=!1!==e.KEEP_CONTENT,Pe=e.IN_PLACE||!1,de=e.ALLOWED_URI_REGEXP||W,Ke=e.NAMESPACE||Xe,Ee=e.CUSTOM_ELEMENT_HANDLING||{},e.CUSTOM_ELEMENT_HANDLING&&rt(e.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(Ee.tagNameCheck=e.CUSTOM_ELEMENT_HANDLING.tagNameCheck),e.CUSTOM_ELEMENT_HANDLING&&rt(e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(Ee.attributeNameCheck=e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),e.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(Ee.allowCustomizedBuiltInElements=e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),we&&(be=!1),ke&&(xe=!0),Fe&&(he=b({},[...C]),Te=[],!0===Fe.html&&(b(he,w),b(Te,O)),!0===Fe.svg&&(b(he,D),b(Te,I),b(Te,U)),!0===Fe.svgFilters&&(b(he,L),b(Te,I),b(Te,U)),!0===Fe.mathMl&&(b(he,x),b(Te,M),b(Te,U))),e.ADD_TAGS&&(he===ge&&(he=S(he)),b(he,e.ADD_TAGS,tt)),e.ADD_ATTR&&(Te===ye&&(Te=S(Te)),b(Te,e.ADD_ATTR,tt)),e.ADD_URI_SAFE_ATTR&&b(Ge,e.ADD_URI_SAFE_ATTR,tt),e.FORBID_CONTENTS&&(He===ze&&(He=S(He)),b(He,e.FORBID_CONTENTS,tt)),Ue&&(he["#text"]=!0),De&&b(he,["html","head","body"]),he.table&&(b(he,["tbody"]),delete Ae.tbody),e.TRUSTED_TYPES_POLICY){if("function"!=typeof e.TRUSTED_TYPES_POLICY.createHTML)throw A('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof e.TRUSTED_TYPES_POLICY.createScriptURL)throw A('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');Q=e.TRUSTED_TYPES_POLICY,ee=Q.createHTML("")}else void 0===Q&&(Q=K(G,c)),null!==Q&&"string"==typeof ee&&(ee=Q.createHTML(""));i&&i(e),nt=e}},at=b({},["mi","mo","mn","ms","mtext"]),lt=b({},["foreignobject","desc","title","annotation-xml"]),ct=b({},["title","style","font","a","script"]),st=b({},D);b(st,L),b(st,v);const ut=b({},x);b(ut,k);const mt=function(e){let t=J(e);t&&t.tagName||(t={namespaceURI:Ke,tagName:"template"});const n=p(e.tagName),o=p(t.tagName);return!!$e[e.namespaceURI]&&(e.namespaceURI===qe?t.namespaceURI===Xe?"svg"===n:t.namespaceURI===je?"svg"===n&&("annotation-xml"===o||at[o]):Boolean(st[n]):e.namespaceURI===je?t.namespaceURI===Xe?"math"===n:t.namespaceURI===qe?"math"===n&<[o]:Boolean(ut[n]):e.namespaceURI===Xe?!(t.namespaceURI===qe&&!lt[o])&&(!(t.namespaceURI===je&&!at[o])&&(!ut[n]&&(ct[n]||!st[n]))):!("application/xhtml+xml"!==Je||!$e[e.namespaceURI]))},ft=function(e){f(o.removed,{element:e});try{e.parentNode.removeChild(e)}catch(t){e.remove()}},pt=function(e,t){try{f(o.removed,{attribute:t.getAttributeNode(e),from:t})}catch(e){f(o.removed,{attribute:null,from:t})}if(t.removeAttribute(e),"is"===e&&!Te[e])if(xe||ke)try{ft(t)}catch(e){}else try{t.setAttribute(e,"")}catch(e){}},dt=function(e){let t=null,n=null;if(ve)e=""+e;else{const t=h(e,/^[\r\n\t ]+/);n=t&&t[0]}"application/xhtml+xml"===Je&&Ke===Xe&&(e=''+e+"");const o=Q?Q.createHTML(e):e;if(Ke===Xe)try{t=(new B).parseFromString(o,Je)}catch(e){}if(!t||!t.documentElement){t=te.createDocument(Ke,"template",null);try{t.documentElement.innerHTML=Ve?ee:o}catch(e){}}const i=t.body||t.documentElement;return e&&n&&i.insertBefore(r.createTextNode(n),i.childNodes[0]||null),Ke===Xe?re.call(t,De?"html":"body")[0]:De?t.documentElement:i},ht=function(e){return ne.call(e.ownerDocument||e,e,F.SHOW_ELEMENT|F.SHOW_COMMENT|F.SHOW_TEXT,null)},gt=function(e){return e instanceof z&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||!(e.attributes instanceof H)||"function"!=typeof e.removeAttribute||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore||"function"!=typeof e.hasChildNodes)},Tt=function(e){return"function"==typeof N&&e instanceof N},yt=function(e,t,n){ae[e]&&u(ae[e],(e=>{e.call(o,t,n,nt)}))},Et=function(e){let t=null;if(yt("beforeSanitizeElements",e,null),gt(e))return ft(e),!0;const n=tt(e.nodeName);if(yt("uponSanitizeElement",e,{tagName:n,allowedTags:he}),e.hasChildNodes()&&!Tt(e.firstElementChild)&&E(/<[/\w]/g,e.innerHTML)&&E(/<[/\w]/g,e.textContent))return ft(e),!0;if(!he[n]||Ae[n]){if(!Ae[n]&&_t(n)){if(Ee.tagNameCheck instanceof RegExp&&E(Ee.tagNameCheck,n))return!1;if(Ee.tagNameCheck instanceof Function&&Ee.tagNameCheck(n))return!1}if(Ue&&!He[n]){const t=J(e)||e.parentNode,n=Z(e)||e.childNodes;if(n&&t){for(let o=n.length-1;o>=0;--o)t.insertBefore(V(n[o],!0),$(e))}}return ft(e),!0}return e instanceof P&&!mt(e)?(ft(e),!0):"noscript"!==n&&"noembed"!==n&&"noframes"!==n||!E(/<\/no(script|embed|frames)/i,e.innerHTML)?(we&&3===e.nodeType&&(t=e.textContent,u([le,ce,se],(e=>{t=g(t,e," ")})),e.textContent!==t&&(f(o.removed,{element:e.cloneNode()}),e.textContent=t)),yt("afterSanitizeElements",e,null),!1):(ft(e),!0)},At=function(e,t,n){if(Oe&&("id"===t||"name"===t)&&(n in r||n in ot))return!1;if(be&&!_e[t]&&E(ue,t));else if(Ne&&E(me,t));else if(!Te[t]||_e[t]){if(!(_t(e)&&(Ee.tagNameCheck instanceof RegExp&&E(Ee.tagNameCheck,e)||Ee.tagNameCheck instanceof Function&&Ee.tagNameCheck(e))&&(Ee.attributeNameCheck instanceof RegExp&&E(Ee.attributeNameCheck,t)||Ee.attributeNameCheck instanceof Function&&Ee.attributeNameCheck(t))||"is"===t&&Ee.allowCustomizedBuiltInElements&&(Ee.tagNameCheck instanceof RegExp&&E(Ee.tagNameCheck,n)||Ee.tagNameCheck instanceof Function&&Ee.tagNameCheck(n))))return!1}else if(Ge[t]);else if(E(de,g(n,pe,"")));else if("src"!==t&&"xlink:href"!==t&&"href"!==t||"script"===e||0!==T(n,"data:")||!Be[e]){if(Se&&!E(fe,g(n,pe,"")));else if(n)return!1}else;return!0},_t=function(e){return e.indexOf("-")>0},Nt=function(e){yt("beforeSanitizeAttributes",e,null);const{attributes:t}=e;if(!t)return;const n={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:Te};let r=t.length;for(;r--;){const i=t[r],{name:a,namespaceURI:l,value:c}=i,s=tt(a);let f="value"===a?c:y(c);if(n.attrName=s,n.attrValue=f,n.keepAttr=!0,n.forceKeepAttr=void 0,yt("uponSanitizeAttribute",e,n),f=n.attrValue,n.forceKeepAttr)continue;if(pt(a,e),!n.keepAttr)continue;if(!Re&&E(/\/>/i,f)){pt(a,e);continue}we&&u([le,ce,se],(e=>{f=g(f,e," ")}));const p=tt(e.nodeName);if(At(p,s,f)){if(!Ie||"id"!==s&&"name"!==s||(pt(a,e),f=Me+f),Q&&"object"==typeof G&&"function"==typeof G.getAttributeType)if(l);else switch(G.getAttributeType(p,s)){case"TrustedHTML":f=Q.createHTML(f);break;case"TrustedScriptURL":f=Q.createScriptURL(f)}try{l?e.setAttributeNS(l,a,f):e.setAttribute(a,f),m(o.removed)}catch(e){}}}yt("afterSanitizeAttributes",e,null)},bt=function e(t){let n=null;const o=ht(t);for(yt("beforeSanitizeShadowDOM",t,null);n=o.nextNode();)yt("uponSanitizeShadowNode",n,null),Et(n)||(n.content instanceof s&&e(n.content),Nt(n));yt("afterSanitizeShadowDOM",t,null)};return o.sanitize=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null,r=null,i=null,l=null;if(Ve=!e,Ve&&(e="\x3c!--\x3e"),"string"!=typeof e&&!Tt(e)){if("function"!=typeof e.toString)throw A("toString is not a function");if("string"!=typeof(e=e.toString()))throw A("dirty is not a string, aborting")}if(!o.isSupported)return e;if(Le||it(t),o.removed=[],"string"==typeof e&&(Pe=!1),Pe){if(e.nodeName){const t=tt(e.nodeName);if(!he[t]||Ae[t])throw A("root node is forbidden and cannot be sanitized in-place")}}else if(e instanceof N)n=dt("\x3c!----\x3e"),r=n.ownerDocument.importNode(e,!0),1===r.nodeType&&"BODY"===r.nodeName||"HTML"===r.nodeName?n=r:n.appendChild(r);else{if(!xe&&!we&&!De&&-1===e.indexOf("<"))return Q&&Ce?Q.createHTML(e):e;if(n=dt(e),!n)return xe?null:Ce?ee:""}n&&ve&&ft(n.firstChild);const c=ht(Pe?e:n);for(;i=c.nextNode();)Et(i)||(i.content instanceof s&&bt(i.content),Nt(i));if(Pe)return e;if(xe){if(ke)for(l=oe.call(n.ownerDocument);n.firstChild;)l.appendChild(n.firstChild);else l=n;return(Te.shadowroot||Te.shadowrootmode)&&(l=ie.call(a,l,!0)),l}let m=De?n.outerHTML:n.innerHTML;return De&&he["!doctype"]&&n.ownerDocument&&n.ownerDocument.doctype&&n.ownerDocument.doctype.name&&E(j,n.ownerDocument.doctype.name)&&(m="\n"+m),we&&u([le,ce,se],(e=>{m=g(m,e," ")})),Q&&Ce?Q.createHTML(m):m},o.setConfig=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};it(e),Le=!0},o.clearConfig=function(){nt=null,Le=!1},o.isValidAttribute=function(e,t,n){nt||it({});const o=tt(e),r=tt(t);return At(o,r,n)},o.addHook=function(e,t){"function"==typeof t&&(ae[e]=ae[e]||[],f(ae[e],t))},o.removeHook=function(e){if(ae[e])return m(ae[e])},o.removeHooks=function(e){ae[e]&&(ae[e]=[])},o.removeAllHooks=function(){ae={}},o}();return V})); -//# sourceMappingURL=purify.min.js.map diff --git a/static/forms/form-not-found.svg b/static/img/form-error.svg similarity index 100% rename from static/forms/form-not-found.svg rename to static/img/form-error.svg diff --git a/static/forms/form-submitted.svg b/static/img/form-success.svg similarity index 100% rename from static/forms/form-submitted.svg rename to static/img/form-success.svg diff --git a/static/forms/logo.png b/static/img/logo-grist.png similarity index 100% rename from static/forms/logo.png rename to static/img/logo-grist.png diff --git a/test/client/models/gristUrlState.ts b/test/client/models/gristUrlState.ts index 868fa9fc..c7065fef 100644 --- a/test/client/models/gristUrlState.ts +++ b/test/client/models/gristUrlState.ts @@ -240,12 +240,18 @@ describe('gristUrlState', function() { // Check form URLs in prod setup. They are produced on document pages. await state.pushUrl({org: 'foo', doc: 'abc'}); state.loadState(); - assert.equal(state.makeUrl({doc: undefined, form: { vsId: 4, shareKey: 'key' }}), - 'https://foo.example.com/forms/key/4'); - assert.equal(state.makeUrl({api: true, doc: 'abc', form: { vsId: 4 }}), - 'https://foo.example.com/api/docs/abc/forms/4'); - assert.equal(state.makeUrl({api: true, form: { vsId: 4 }}), - 'https://foo.example.com/api/docs/abc/forms/4'); + assert.equal( + state.makeUrl({doc: undefined, form: {vsId: 4, shareKey: 'key'}}), + 'https://foo.example.com/forms/key/4' + ); + assert.equal( + state.makeUrl({doc: 'abc', form: {vsId: 4}}), + 'https://foo.example.com/doc/abc/f/4' + ); + assert.equal( + state.makeUrl({doc: 'abc', slug: '123', form: {vsId: 4}}), + 'https://foo.example.com/abc/123/f/4' + ); }); it('should produce correct results with single-org config', async function() { @@ -279,12 +285,18 @@ describe('gristUrlState', function() { // Check form URLs in single org setup from document pages. await state.pushUrl({org: 'foo', doc: 'abc'}); state.loadState(); - assert.equal(state.makeUrl({doc: undefined, form: { vsId: 4, shareKey: 'key' }}), - 'https://example.com/o/foo/forms/key/4'); - assert.equal(state.makeUrl({api: true, doc: 'abc', form: { vsId: 4 }}), - 'https://example.com/o/foo/api/docs/abc/forms/4'); - assert.equal(state.makeUrl({api: true, form: { vsId: 4 }}), - 'https://example.com/o/foo/api/docs/abc/forms/4'); + assert.equal( + state.makeUrl({doc: undefined, form: {vsId: 4, shareKey: 'key'}}), + 'https://example.com/o/foo/forms/key/4' + ); + assert.equal( + state.makeUrl({doc: 'abc', form: {vsId: 4}}), + 'https://example.com/o/foo/doc/abc/f/4' + ); + assert.equal( + state.makeUrl({doc: 'abc', slug: '123', form: {vsId: 4}}), + 'https://example.com/o/foo/abc/123/f/4' + ); }); it('should produce correct results with custom config', async function() { diff --git a/test/nbrowser/FormView.ts b/test/nbrowser/FormView.ts index 22b637e3..fd2fcff9 100644 --- a/test/nbrowser/FormView.ts +++ b/test/nbrowser/FormView.ts @@ -1,4 +1,3 @@ -import {CHOOSE_TEXT} from 'app/common/Forms'; import {UserAPI} from 'app/common/UserAPI'; import {escapeRegExp} from 'lodash'; import {addToRepl, assert, driver, Key, WebElement, WebElementPromise} from 'mocha-webdriver'; @@ -99,8 +98,13 @@ describe('FormView', function() { } async function waitForConfirm() { + await gu.waitForServer(); await gu.waitToPass(async () => { - assert.isTrue(await driver.findWait('.grist-form-confirm', 1000).isDisplayed()); + assert.isTrue(await driver.findWait('.test-form-container', 2000).isDisplayed()); + assert.equal( + await driver.find('.test-form-success-text').getText(), + 'Thank you! Your response has been recorded.' + ); }); } @@ -169,7 +173,7 @@ describe('FormView', function() { await gu.closeRawTable(); await gu.onNewTab(async () => { await driver.get(formUrl); - await driver.find('input[type="submit"]').click(); + await driver.findWait('input[type="submit"]', 2000).click(); await waitForConfirm(); }); await expectSingle('Hello from trigger'); @@ -181,7 +185,7 @@ describe('FormView', function() { // We are in a new window. await gu.onNewTab(async () => { await driver.get(formUrl); - await driver.findWait('input[name="D"]', 1000).click(); + await driver.findWait('input[name="D"]', 2000).click(); await gu.sendKeys('Hello World'); await driver.find('input[type="submit"]').click(); await waitForConfirm(); @@ -196,7 +200,7 @@ describe('FormView', function() { // We are in a new window. await gu.onNewTab(async () => { await driver.get(formUrl); - await driver.findWait('input[name="D"]', 1000).click(); + await driver.findWait('input[name="D"]', 2000).click(); await gu.sendKeys('1984'); await driver.find('input[type="submit"]').click(); await waitForConfirm(); @@ -211,7 +215,7 @@ describe('FormView', function() { // We are in a new window. await gu.onNewTab(async () => { await driver.get(formUrl); - await driver.findWait('input[name="D"]', 1000).click(); + await driver.findWait('input[name="D"]', 2000).click(); await driver.executeScript( () => (document.querySelector('input[name="D"]') as HTMLInputElement).value = '2000-01-01' ); @@ -243,11 +247,12 @@ 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); // Make sure options are there. assert.deepEqual( - await driver.findAll('select[name="D"] option', e => e.getText()), [CHOOSE_TEXT, 'Foo', 'Bar', 'Baz'] + await driver.findAll('select[name="D"] option', e => e.getText()), ['— Choose —', 'Foo', 'Bar', 'Baz'] ); - await driver.findWait('select[name="D"]', 1000).click(); + await select.click(); await driver.find("option[value='Bar']").click(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); @@ -261,7 +266,7 @@ describe('FormView', function() { // We are in a new window. await gu.onNewTab(async () => { await driver.get(formUrl); - await driver.findWait('input[name="D"]', 1000).click(); + await driver.findWait('input[name="D"]', 2000).click(); await gu.sendKeys('1984'); await driver.find('input[type="submit"]').click(); await waitForConfirm(); @@ -276,14 +281,14 @@ describe('FormView', function() { // We are in a new window. await gu.onNewTab(async () => { await driver.get(formUrl); - await driver.findWait('input[name="D"]', 1000).findClosest("label").click(); + await driver.findWait('input[name="D"]', 2000).findClosest("label").click(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); await expectSingle(true); await gu.onNewTab(async () => { await driver.get(formUrl); - await driver.find('input[type="submit"]').click(); + await driver.findWait('input[type="submit"]', 2000).click(); await waitForConfirm(); }); await expectInD([true, false]); @@ -309,8 +314,8 @@ 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"]', 1000).click(); - await driver.findWait('input[name="D[]"][value="Baz"]', 1000).click(); + await driver.findWait('input[name="D[]"][value="Foo"]', 2000).click(); + await driver.find('input[name="D[]"][value="Baz"]').click(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -334,15 +339,16 @@ 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); assert.deepEqual( await driver.findAll('select[name="D"] option', e => e.getText()), - [CHOOSE_TEXT, ...['Bar', 'Baz', 'Foo']] + ['— Choose —', ...['Bar', 'Baz', 'Foo']] ); assert.deepEqual( await driver.findAll('select[name="D"] option', e => e.value()), ['', ...['2', '3', '1']] ); - await driver.findWait('select[name="D"]', 1000).click(); + await select.click(); await driver.find('option[value="2"]').click(); await driver.find('input[type="submit"]').click(); await waitForConfirm(); @@ -373,11 +379,11 @@ 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"]', 1000).click(); - await driver.findWait('input[name="D[]"][value="2"]', 1000).click(); - assert.equal(await driver.find('.grist-checkbox:has(input[name="D[]"][value="1"])').getText(), 'Foo'); - assert.equal(await driver.find('.grist-checkbox:has(input[name="D[]"][value="2"])').getText(), 'Bar'); - assert.equal(await driver.find('.grist-checkbox:has(input[name="D[]"][value="3"])').getText(), 'Baz'); + await driver.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.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[type="submit"]').click(); await waitForConfirm(); }); @@ -391,7 +397,7 @@ describe('FormView', function() { await removeForm(); }); - it('can submit a form with a formula field', async function() { + it('excludes formula fields from forms', async function() { const formUrl = await createFormWith('Text'); // Temporarily make A a formula column. @@ -401,12 +407,12 @@ describe('FormView', function() { ]); 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. await gu.onNewTab(async () => { await driver.get(formUrl); - await driver.findWait('input[name="D"]', 1000).click(); + await driver.findWait('input[name="D"]', 2000).click(); await gu.sendKeys('Hello World'); - await driver.find('input[name="_A"]').click(); - await gu.sendKeys('goodbye'); + assert.isFalse(await driver.find('input[name="A"]').isPresent()); await driver.find('input[type="submit"]').click(); await waitForConfirm(); }); @@ -431,9 +437,10 @@ describe('FormView', function() { await gu.waitForServer(); await gu.onNewTab(async () => { await driver.get(formUrl); - assert.match( - await driver.findWait('.test-error-text', 2000).getText(), - /Oops! This form is no longer published\./ + assert.isTrue(await driver.findWait('.test-form-container', 2000).isDisplayed()); + assert.equal( + await driver.find('.test-form-error-text').getText(), + 'Oops! This form is no longer published.' ); }); @@ -443,7 +450,7 @@ describe('FormView', function() { await gu.waitForServer(); await gu.onNewTab(async () => { await driver.get(formUrl); - await driver.findWait('input[name="D"]', 1000); + await driver.findWait('input[name="D"]', 2000); }); }); @@ -1250,7 +1257,7 @@ describe('FormView', function() { // We are in a new window. await gu.onNewTab(async () => { await driver.get(formUrl); - await driver.findWait('input[name="D"]', 1000).click(); + await driver.findWait('input[name="D"]', 2000).click(); await gu.sendKeys('Hello World'); await driver.find('input[type="submit"]').click(); await waitForConfirm();