Summary: WIP Test Plan: Existing tests. Reviewers: jarek Reviewed By: jarek Subscribers: jarek Differential Revision: https://phab.getgrist.com/D4196pull/868/head
parent
6800ebfbad
commit
c6fd79ac1f
@ -1,5 +1,5 @@
|
|||||||
import {BillingPage} from 'app/client/ui/BillingPage';
|
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';
|
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 {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});
|
@ -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"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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};
|
||||||
|
}
|
||||||
|
`);
|
@ -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),
|
||||||
|
]);
|
||||||
|
}
|
@ -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),
|
|
||||||
]));
|
|
||||||
}
|
|
@ -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 |
Loading…
Reference in new issue