(core) Refactor forms implementation

Summary: WIP

Test Plan: Existing tests.

Reviewers: jarek

Reviewed By: jarek

Subscribers: jarek

Differential Revision: https://phab.getgrist.com/D4196
pull/868/head
George Gevoian 3 months ago
parent 6800ebfbad
commit c6fd79ac1f

@ -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';

@ -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));

@ -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<FormLayoutNode>;
// 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<number, FormField>;
/** The root of the FormLayoutNode tree. */
rootLayoutNode: FormLayoutNode;
/** Disables the Submit node if true. */
disabled: Observable<boolean>;
/** Error to show above the Submit node. */
error: Observable<string|null>;
}
/**
* 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<string|null> = 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,
};

@ -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%;
`);

@ -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()]};
}

@ -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) => {

@ -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<any>;
protected bundle: (clb: () => Promise<void>) => Promise<void>;
private _autoLayout: Computed<Box>;
private _autoLayout: Computed<FormLayoutNode>;
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',

@ -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<HTMLElement>): IDomArg
box?.view.selectedBox.set(box);
// Same for structure.
const struct = (structure: BoxType) => ({structure});
const struct = (structure: FormLayoutNodeType) => ({structure});
// Actions:

@ -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<void>;
/**
* 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<void>,
public view: FormView
@ -420,7 +420,7 @@ export function unwrap<T>(val: T | Computed<T>): 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;

@ -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) {

@ -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();

@ -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));

@ -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});

@ -70,3 +70,57 @@ export function handleFormError(err: unknown, errObs: Observable<string|null>) {
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)]));
}

@ -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<void>;
}
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<ICustomWidget[]>;
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 {

@ -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<Form|null>;
readonly formLayout: Computed<FormLayoutNode|null>;
readonly submitting: Observable<boolean>;
readonly submitted: Observable<boolean>;
readonly error: Observable<string|null>;
fetchForm(): Promise<void>;
submitForm(formData: TypedFormData): Promise<void>;
}
export class FormModelImpl extends Disposable implements FormModel {
public readonly form = Observable.create<Form|null>(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<boolean>(this, false);
public readonly submitted = Observable.create<boolean>(this, false);
public readonly error = Observable.create<string|null>(this, null);
private readonly _formAPI: FormAPI = new FormAPIImpl(getHomeUrl());
constructor() {
super();
}
public async fetchForm(): Promise<void> {
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<void> {
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"');
}
}
}

@ -47,16 +47,12 @@ let _urlState: UrlState<IGristUrlState>|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;
}

@ -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;'

@ -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<number, FormField>;
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<Form>;
createRecord(options: CreateRecordOptions): Promise<void>;
}
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<Form> {
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<void> {
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}]}),
});
}
}
}

@ -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'),
);
}

@ -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')),
]);
}
}

@ -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<string|null>(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;
}
`);

@ -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;
`);

@ -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};
}
`);

@ -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} {

@ -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 ?

@ -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,
),

@ -68,7 +68,6 @@ async function switchToPersonalUrl(ev: MouseEvent, appModel: AppModel, org: stri
}
const cssPageContainer = styled(css.pageContainer, `
overflow: auto;
padding-bottom: 40px;
`);

@ -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),
];
}));
}

@ -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),
]);
}

@ -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;
`);

@ -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),
]));
}

@ -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<boolean> {
return _prefersDarkModeObs;
}
let _prefersColorSchemeThemeObs: Computed<Theme>|undefined;
/**
* Returns a singleton observable for the Grist theme matching the current
* user agent color scheme preference ("light" or "dark").
*/
export function prefersColorSchemeThemeObs(): Computed<Theme> {
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<Theme>) {
// 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};`);

@ -38,7 +38,8 @@ export interface ApiErrorDetails {
export type ApiErrorCode =
| 'UserNotConfirmed'
| 'FormNotFound';
| 'FormNotFound'
| 'FormNotPublished';
/**
* An error with an http status code.

@ -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<Box>,
// 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<string> {
const proms = (this.box.children || []).map((child) => RenderBox.new(child, this.ctx).toHTML());
const parts = await Promise.all(proms);
return parts.join('');
}
}
class Label extends RenderBox {
public override async toHTML() {
const text = this.box.text || '';
return `
<div class="grist-label">${text || ''}</div>
`;
}
}
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 `
<div class="grist-paragraph grist-text-${alignment}">${html}</div>
`;
}
}
class Section extends RenderBox {
public override async toHTML() {
return `
<div class="grist-section">
${await super.toHTML()}
</div>
`;
}
}
class Columns extends RenderBox {
public override async toHTML() {
const size = this.box.children?.length || 1;
const content = await super.toHTML();
return `
<div class="grist-columns" style='--grist-columns-count: ${size}'>
${content}
</div>
`;
}
}
class Submit extends RenderBox {
public override async toHTML() {
const text = _.escape(this.ctx.root['submitText'] || 'Submit');
return `
<div class='grist-submit'>
<input type='submit' value='${text}' />
</div>
`;
}
}
class Placeholder extends RenderBox {
public override async toHTML() {
return `
<div>
</div>
`;
}
}
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 `<div class="grist-field">Field not found</div>`;
}
const renderer = this.build(field, this.ctx);
return `
<div class="grist-field">
${await renderer.toHTML(field, this.ctx)}
</div>
`;
}
}
interface Question {
toHTML(field: FieldModel, context: RenderContext): Promise<string>|string;
}
abstract class BaseQuestion implements Question {
public async toHTML(field: FieldModel, context: RenderContext): Promise<string> {
return `
<div class='grist-question'>
${this.label(field)}
<div class='grist-field-content'>
${await this.input(field, context)}
</div>
</div>
`;
}
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 `
<label class='grist-label ${required}' for='${name}'>${label}</label>
`;
}
public abstract input(field: FieldModel, context: RenderContext): string|Promise<string>;
}
class Text extends BaseQuestion {
public input(field: FieldModel, context: RenderContext): string {
const required = field.options.formRequired ? 'required' : '';
return `
<input type='text' name='${this.name(field)}' ${required}/>
`;
}
}
class Date extends BaseQuestion {
public input(field: FieldModel, context: RenderContext): string {
const required = field.options.formRequired ? 'required' : '';
return `
<input type='date' name='${this.name(field)}' ${required}/>
`;
}
}
class DateTime extends BaseQuestion {
public input(field: FieldModel, context: RenderContext): string {
const required = field.options.formRequired ? 'required' : '';
return `
<input type='datetime-local' name='${this.name(field)}' ${required}/>
`;
}
}
class Choice extends BaseQuestion {
public input(field: FieldModel, context: RenderContext): string {
const required = field.options.formRequired ? 'required' : '';
const choices: Array<string|null> = field.options.choices || [];
// Insert empty option.
choices.unshift(null);
return `
<select name='${this.name(field)}' ${required} >
${choices.map((choice) => `<option value='${choice ?? ''}'>${choice ?? CHOOSE_TEXT}</option>`).join('')}
</select>
`;
}
}
class Bool extends BaseQuestion {
public async toHTML(field: FieldModel, context: RenderContext) {
return `
<div class='grist-question'>
<div class='grist-field-content'>
${this.input(field, context)}
</div>
</div>
`;
}
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 `
<label class='grist-switch ${requiredLabel}'>
<input type='checkbox' name='${this.name(field)}' value="1" ${required} />
<div class="grist-widget_switch grist-switch_transition">
<div class="grist-switch_slider"></div>
<div class="grist-switch_circle"></div>
</div>
<span>${label}</span>
</label>
`;
}
}
class ChoiceList extends BaseQuestion {
public input(field: FieldModel, context: RenderContext): string {
const required = field.options.formRequired ? 'required' : '';
const choices: string[] = field.options.choices || [];
return `
<div name='${this.name(field)}' class='grist-checkbox-list ${required}'>
${choices.map((choice) => `
<label class='grist-checkbox'>
<input type='checkbox' name='${this.name(field)}[]' value='${choice}' />
<span>
${choice}
</span>
</label>
`).join('')}
</div>
`;
}
}
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 `
<div name='${this.name(field)}' class='grist-checkbox-list ${required}'>
${choices.map((choice) => `
<label class='grist-checkbox'>
<input type='checkbox'
data-grist-type='${field.type}'
name='${this.name(field)}[]'
value='${String(choice[0])}' />
<span>
${String(choice[1] ?? '')}
</span>
</label>
`).join('')}
</div>
`;
}
}
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]);
// <option type='number' is not standard, we parse it ourselves.
const required = field.options.formRequired ? 'required' : '';
return `
<select name='${this.name(field)}' class='grist-ref' data-grist-type='${field.type}' ${required}>
${choices.map((choice) => `<option value='${String(choice[0])}'>${String(choice[1] ?? '')}</option>`).join('')}
</select>
`;
}
}
/**
* List of all available questions we will render of the form.
* TODO: add other renderers.
*/
const questions: Partial<Record<GristType, new () => 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,
};

@ -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<GristLoadConfig>,
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<GristLoadConfig>, 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<string, string>();
@ -447,6 +437,7 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, 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')!;

@ -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);
}

@ -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);
}
}));
}

@ -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(`
<script data-html="${handlebars.escapeExpression(html)}">
document.write(DOMPurify.sanitize(document.currentScript.getAttribute('data-html')));
document.currentScript.remove(); // remove the script tag so it is easier to inspect the DOM
</script>
`);
});
export class DocWorkerApi {
// Map from docId to number of requests currently being handled for that doc
private _currentUsage = new Map<string, number>();
@ -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();
}

@ -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'

@ -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") ? {

@ -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]

@ -0,0 +1,14 @@
<!doctype html>
<html>
<head>
<meta charset="utf8">
<!-- INSERT BASE -->
<link rel="icon" type="image/x-icon" href="icons/favicon.png" />
<link rel="stylesheet" href="icons/icons.css">
<title>Loading...<!-- INSERT TITLE SUFFIX --></title>
</head>
<body>
<!-- INSERT CONFIG -->
<script crossorigin="anonymous" src="form.bundle.js"></script>
</body>
</html>

@ -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

@ -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);
}

@ -1,108 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf8">
{{#if BASE}}
<base href="{{ BASE }}">
{{/if}}
<title>{{ TITLE }}</title>
<link rel="icon" type="image/x-icon" href="icons/favicon.png" />
<script src="forms/grist-form-submit.js"></script>
<script src="forms/purify.min.js"></script>
<script>
// Make all links open in a new tab.
DOMPurify.addHook('uponSanitizeAttribute', (node) => {
if (!('target' in node)) { return; }
node.setAttribute('target', '_blank');
// Make sure that this is set explicitly, as it's often set by the browser.
node.setAttribute('rel', 'noopener');
});
</script>
<link rel="stylesheet" href="forms/form.css">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<main class='grist-form-container'>
<form class='grist-form'
onsubmit="event.target.parentElement.querySelector('.grist-form-confirm').style.display = 'flex', event.target.style.display = 'none'"
data-grist-doc="{{ DOC_URL }}"
data-grist-table="{{ TABLE_ID }}"
data-grist-success-url="{{ SUCCESS_URL }}"
>
{{ dompurify CONTENT }}
<div class='grist-form-footer'>
<div class="grist-power-by">
<a href="{{ FORMS_LANDING_PAGE_URL }}" target="_blank">
<div>Powered by</div>
<div class="grist-logo"></div>
</a>
</div>
<div class='grist-form-build-form-link-container'>
<a class='grist-form-build-form-link' href="{{ FORMS_LANDING_PAGE_URL }}" target="_blank">
Build your own form
<div class="grist-form-icon grist-form-icon-expand"></div>
</a>
</div>
</div>
</form>
<div class="grist-form-confirm-container">
<div class='grist-form-confirm' style='display: none'>
<div class="grist-form-confirm-body">
<img class='grist-form-confirm-image' src="forms/form-submitted.svg">
<div class='grist-form-confirm-text'>
{{ SUCCESS_TEXT }}
</div>
{{#if ANOTHER_RESPONSE }}
<div class='grist-form-confirm-buttons'>
<button
class='grist-form-confirm-new-response-button'
onclick='window.location.reload()'
>
Submit new response
</button>
</div>
{{/if}}
</div>
<div class='grist-form-confirm-footer'>
<div class="grist-power-by">
<a href="https://www.getgrist.com/forms/?utm_source=grist-forms&utm_medium=grist-forms&utm_campaign=forms-footer" target="_blank">
<div>Powered by</div>
<div class="grist-logo"></div>
</a>
</div>
<div class='grist-form-build-form-link-container'>
<a class='grist-form-build-form-link' href="https://www.getgrist.com/forms/?utm_source=grist-forms&utm_medium=grist-forms&utm_campaign=forms-footer" target="_blank">
Build your own form
<div class="grist-form-icon grist-form-icon-expand"></div>
</a>
</div>
</div>
</div>
</div>
</main>
<script>
// Validate choice list on submit
document.querySelector('.grist-form input[type="submit"]').addEventListener('click', function(event) {
// When submit is pressed make sure that all choice lists that are required
// have at least one option selected
const choiceLists = document.querySelectorAll('.grist-checkbox-list.required:not(:has(input:checked))');
Array.from(choiceLists).forEach(function(choiceList) {
// If the form has at least one checkbox make it required
const firstCheckbox = choiceList.querySelector('input[type="checkbox"]');
firstCheckbox?.setAttribute('required', 'required');
});
// All other required choice lists with at least one option selected are no longer required
const choiceListsRequired = document.querySelectorAll('.grist-checkbox-list.required:has(input:checked)');
Array.from(choiceListsRequired).forEach(function(choiceList) {
// If the form has at least one checkbox make it required
const firstCheckbox = choiceList.querySelector('input[type="checkbox"]');
firstCheckbox?.removeAttribute('required');
});
});
</script>
</body>
</html>

@ -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);
})();
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

@ -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() {

@ -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();

Loading…
Cancel
Save