@ -1,8 +1,8 @@
|
||||
# Welcome to the contribution guide for Grist!
|
||||
|
||||
You are eager to contribute to Grist? That's awesome! See below some contributions you can make:
|
||||
- [translate](/documentation/translate.md)
|
||||
- [write tutorials and user documentation](https://github.com/gristlabs/grist-help)
|
||||
- [translate](/documentation/translations.md)
|
||||
- [write tutorials and user documentation](https://github.com/gristlabs/grist-help?tab=readme-ov-file#grist-help-center)
|
||||
- [develop](/documentation/develop.md)
|
||||
- [report issues or suggest enhancement](https://github.com/gristlabs/grist-core/issues/new)
|
||||
|
||||
|
@ -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,259 @@
|
||||
import { AppModel } from 'app/client/models/AppModel';
|
||||
import { createAppPage } from 'app/client/ui/createAppPage';
|
||||
import { pagePanels } from 'app/client/ui/PagePanels';
|
||||
import { BootProbeInfo, BootProbeResult } from 'app/common/BootProbe';
|
||||
import { removeTrailingSlash } from 'app/common/gutil';
|
||||
import { getGristConfig } from 'app/common/urlUtils';
|
||||
import { Disposable, dom, Observable, styled, UseCBOwner } from 'grainjs';
|
||||
|
||||
const cssBody = styled('div', `
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
`);
|
||||
|
||||
const cssHeader = styled('div', `
|
||||
padding: 20px;
|
||||
`);
|
||||
|
||||
const cssResult = styled('div', `
|
||||
max-width: 500px;
|
||||
`);
|
||||
|
||||
/**
|
||||
*
|
||||
* A "boot" page for inspecting the state of the Grist installation.
|
||||
*
|
||||
* TODO: deferring using any localization machinery so as not
|
||||
* to have to worry about its failure modes yet, but it should be
|
||||
* fine as long as assets served locally are used.
|
||||
*
|
||||
*/
|
||||
export class Boot extends Disposable {
|
||||
|
||||
// The back end will offer a set of probes (diagnostics) we
|
||||
// can use. Probes have unique IDs.
|
||||
public probes: Observable<BootProbeInfo[]>;
|
||||
|
||||
// Keep track of probe results we have received, by probe ID.
|
||||
public results: Map<string, Observable<BootProbeResult>>;
|
||||
|
||||
// Keep track of probe requests we are making, by probe ID.
|
||||
public requests: Map<string, BootProbe>;
|
||||
|
||||
constructor(_appModel: AppModel) {
|
||||
super();
|
||||
// Setting title in constructor seems to be how we are doing this,
|
||||
// based on other similar pages.
|
||||
document.title = 'Booting Grist';
|
||||
this.probes = Observable.create(this, []);
|
||||
this.results = new Map();
|
||||
this.requests = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the page. Uses the generic Grist layout with an empty
|
||||
* side panel, just for convenience. Could be made a lot prettier.
|
||||
*/
|
||||
public buildDom() {
|
||||
const config = getGristConfig();
|
||||
const errMessage = config.errMessage;
|
||||
if (!errMessage) {
|
||||
// Probe tool URLs are relative to the current URL. Don't trust configuration,
|
||||
// because it may be buggy if the user is here looking at the boot page
|
||||
// to figure out some problem.
|
||||
const url = new URL(removeTrailingSlash(document.location.href));
|
||||
url.pathname += '/probe';
|
||||
fetch(url.href).then(async resp => {
|
||||
const _probes = await resp.json();
|
||||
this.probes.set(_probes.probes);
|
||||
}).catch(e => reportError(e));
|
||||
}
|
||||
|
||||
const rootNode = dom('div',
|
||||
dom.domComputed(
|
||||
use => {
|
||||
return pagePanels({
|
||||
leftPanel: {
|
||||
panelWidth: Observable.create(this, 240),
|
||||
panelOpen: Observable.create(this, false),
|
||||
hideOpener: true,
|
||||
header: null,
|
||||
content: null,
|
||||
},
|
||||
headerMain: cssHeader(dom('h1', 'Grist Boot')),
|
||||
contentMain: this.buildBody(use, {errMessage}),
|
||||
});
|
||||
}
|
||||
),
|
||||
);
|
||||
return rootNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The body of the page is very simple right now, basically a
|
||||
* placeholder. Make a section for each probe, and kick them off in
|
||||
* parallel, showing results as they come in.
|
||||
*/
|
||||
public buildBody(use: UseCBOwner, options: {errMessage?: string}) {
|
||||
if (options.errMessage) {
|
||||
return cssBody(cssResult(this.buildError()));
|
||||
}
|
||||
return cssBody([
|
||||
...use(this.probes).map(probe => {
|
||||
const {id} = probe;
|
||||
let result = this.results.get(id);
|
||||
if (!result) {
|
||||
result = Observable.create(this, {});
|
||||
this.results.set(id, result);
|
||||
}
|
||||
let request = this.requests.get(id);
|
||||
if (!request) {
|
||||
request = new BootProbe(id, this);
|
||||
this.requests.set(id, request);
|
||||
}
|
||||
request.start();
|
||||
return cssResult(
|
||||
this.buildResult(probe, use(result), probeDetails[id]));
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* This is used when there is an attempt to access the boot page
|
||||
* but something isn't right - either the page isn't enabled, or
|
||||
* the key in the URL is wrong. Give the user some information about
|
||||
* how to set things up.
|
||||
*/
|
||||
public buildError() {
|
||||
return dom(
|
||||
'div',
|
||||
dom('p',
|
||||
'A diagnostics page can be made available at:',
|
||||
dom('blockquote', '/boot/GRIST_BOOT_KEY'),
|
||||
'GRIST_BOOT_KEY is an environment variable ',
|
||||
' set before Grist starts. It should only',
|
||||
' contain characters that are valid in a URL.',
|
||||
' It should be a secret, since no authentication is needed',
|
||||
' to visit the diagnostics page.'),
|
||||
dom('p',
|
||||
'You are seeing this page because either the key is not set,',
|
||||
' or it is not in the URL.'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An ugly rendering of information returned by the probe.
|
||||
*/
|
||||
public buildResult(info: BootProbeInfo, result: BootProbeResult,
|
||||
details: ProbeDetails|undefined) {
|
||||
const out: (HTMLElement|string|null)[] = [];
|
||||
out.push(dom('h2', info.name));
|
||||
if (details) {
|
||||
out.push(dom('p', '> ', details.info));
|
||||
}
|
||||
if (result.verdict) {
|
||||
out.push(dom('pre', result.verdict));
|
||||
}
|
||||
if (result.success !== undefined) {
|
||||
out.push(result.success ? '✅' : '❌');
|
||||
}
|
||||
if (result.done === true) {
|
||||
out.push(dom('p', 'no fault detected'));
|
||||
}
|
||||
if (result.details) {
|
||||
for (const [key, val] of Object.entries(result.details)) {
|
||||
out.push(dom(
|
||||
'div',
|
||||
key,
|
||||
dom('input', dom.prop('value', JSON.stringify(val)))));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single diagnostic.
|
||||
*/
|
||||
export class BootProbe {
|
||||
constructor(public id: string, public boot: Boot) {
|
||||
const url = new URL(removeTrailingSlash(document.location.href));
|
||||
url.pathname = url.pathname + '/probe/' + id;
|
||||
fetch(url.href).then(async resp => {
|
||||
const _probes: BootProbeResult = await resp.json();
|
||||
const ob = boot.results.get(id);
|
||||
if (ob) {
|
||||
ob.set(_probes);
|
||||
}
|
||||
}).catch(e => console.error(e));
|
||||
}
|
||||
|
||||
public start() {
|
||||
let result = this.boot.results.get(this.id);
|
||||
if (!result) {
|
||||
result = Observable.create(this.boot, {});
|
||||
this.boot.results.set(this.id, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a stripped down page to show boot information.
|
||||
* Make sure the API isn't used since it may well be unreachable
|
||||
* due to a misconfiguration, especially in multi-server setups.
|
||||
*/
|
||||
createAppPage(appModel => {
|
||||
return dom.create(Boot, appModel);
|
||||
}, {
|
||||
useApi: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Basic information about diagnostics is kept on the server,
|
||||
* but it can be useful to show extra details and tips in the
|
||||
* client.
|
||||
*/
|
||||
const probeDetails: Record<string, ProbeDetails> = {
|
||||
'boot-page': {
|
||||
info: `
|
||||
This boot page should not be too easy to access. Either turn
|
||||
it off when configuration is ok (by unsetting GRIST_BOOT_KEY)
|
||||
or make GRIST_BOOT_KEY long and cryptographically secure.
|
||||
`,
|
||||
},
|
||||
|
||||
'health-check': {
|
||||
info: `
|
||||
Grist has a small built-in health check often used when running
|
||||
it as a container.
|
||||
`,
|
||||
},
|
||||
|
||||
'host-header': {
|
||||
info: `
|
||||
Requests arriving to Grist should have an accurate Host
|
||||
header. This is essential when GRIST_SERVE_SAME_ORIGIN
|
||||
is set.
|
||||
`,
|
||||
},
|
||||
|
||||
'system-user': {
|
||||
info: `
|
||||
It is good practice not to run Grist as the root user.
|
||||
`,
|
||||
},
|
||||
|
||||
'reachable': {
|
||||
info: `
|
||||
The main page of Grist should be available.
|
||||
`
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Information about the probe.
|
||||
*/
|
||||
interface ProbeDetails {
|
||||
info: string;
|
||||
}
|
||||
|
@ -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,119 @@
|
||||
import {BaseAPI, IOptions} from 'app/common/BaseAPI';
|
||||
import {CellValue, ColValues} from 'app/common/DocActions';
|
||||
import {addCurrentOrgToPath} from 'app/common/urlUtils';
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
constructor(private _homeUrl: string, options: IOptions = {}) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
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}]}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private get _url(): string {
|
||||
return addCurrentOrgToPath(this._homeUrl);
|
||||
}
|
||||
}
|
@ -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,40 @@
|
||||
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
|
||||
import {setupLocale} from 'app/client/lib/localization';
|
||||
import {AppModel, TopAppModelImpl, TopAppModelOptions} 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,
|
||||
modelOptions: TopAppModelOptions = {}) {
|
||||
setUpErrorHandling();
|
||||
|
||||
const topAppModel = TopAppModelImpl.create(null, {}, undefined, modelOptions);
|
||||
|
||||
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,22 @@
|
||||
|
||||
export type BootProbeIds =
|
||||
'boot-page' |
|
||||
'health-check' |
|
||||
'reachable' |
|
||||
'host-header' |
|
||||
'system-user'
|
||||
;
|
||||
|
||||
export interface BootProbeResult {
|
||||
verdict?: string;
|
||||
success?: boolean;
|
||||
done?: boolean;
|
||||
severity?: 'fault' | 'warning' | 'hmm';
|
||||
details?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface BootProbeInfo {
|
||||
id: BootProbeIds;
|
||||
name: string;
|
||||
}
|
||||
|
@ -0,0 +1,185 @@
|
||||
import { ApiError } from 'app/common/ApiError';
|
||||
import { BootProbeIds, BootProbeResult } from 'app/common/BootProbe';
|
||||
import { removeTrailingSlash } from 'app/common/gutil';
|
||||
import { expressWrap, jsonErrorHandler } from 'app/server/lib/expressWrap';
|
||||
import { GristServer } from 'app/server/lib/GristServer';
|
||||
import * as express from 'express';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
/**
|
||||
* Self-diagnostics useful when installing Grist.
|
||||
*/
|
||||
export class BootProbes {
|
||||
// List of probes.
|
||||
public _probes = new Array<Probe>();
|
||||
|
||||
// Probes indexed by id.
|
||||
public _probeById = new Map<string, Probe>();
|
||||
|
||||
public constructor(private _app: express.Application,
|
||||
private _server: GristServer,
|
||||
private _base: string) {
|
||||
this._addProbes();
|
||||
}
|
||||
|
||||
public addEndpoints() {
|
||||
// Return a list of available probes.
|
||||
this._app.use(`${this._base}/probe$`, expressWrap(async (_, res) => {
|
||||
res.json({
|
||||
'probes': this._probes.map(probe => {
|
||||
return { id: probe.id, name: probe.name };
|
||||
}),
|
||||
});
|
||||
}));
|
||||
|
||||
// Return result of running an individual probe.
|
||||
this._app.use(`${this._base}/probe/:probeId`, expressWrap(async (req, res) => {
|
||||
const probe = this._probeById.get(req.params.probeId);
|
||||
if (!probe) {
|
||||
throw new ApiError('unknown probe', 400);
|
||||
}
|
||||
const result = await probe.apply(this._server, req);
|
||||
res.json(result);
|
||||
}));
|
||||
|
||||
// Fall-back for errors.
|
||||
this._app.use(`${this._base}/probe`, jsonErrorHandler);
|
||||
}
|
||||
|
||||
private _addProbes() {
|
||||
this._probes.push(_homeUrlReachableProbe);
|
||||
this._probes.push(_statusCheckProbe);
|
||||
this._probes.push(_userProbe);
|
||||
this._probes.push(_bootProbe);
|
||||
this._probes.push(_hostHeaderProbe);
|
||||
this._probeById = new Map(this._probes.map(p => [p.id, p]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An individual probe has an id, a name, an optional description,
|
||||
* and a method that returns a probe result.
|
||||
*/
|
||||
export interface Probe {
|
||||
id: BootProbeIds;
|
||||
name: string;
|
||||
description?: string;
|
||||
apply: (server: GristServer, req: express.Request) => Promise<BootProbeResult>;
|
||||
}
|
||||
|
||||
const _homeUrlReachableProbe: Probe = {
|
||||
id: 'reachable',
|
||||
name: 'Grist is reachable',
|
||||
apply: async (server, req) => {
|
||||
const url = server.getHomeUrl(req);
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (resp.status !== 200) {
|
||||
throw new ApiError(await resp.text(), resp.status);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
details: {
|
||||
error: String(e),
|
||||
},
|
||||
severity: 'fault',
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const _statusCheckProbe: Probe = {
|
||||
id: 'health-check',
|
||||
name: 'Built-in Health check',
|
||||
apply: async (server, req) => {
|
||||
const baseUrl = server.getHomeUrl(req);
|
||||
const url = new URL(baseUrl);
|
||||
url.pathname = removeTrailingSlash(url.pathname) + '/status';
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (resp.status !== 200) {
|
||||
throw new Error(`Failed with status ${resp.status}`);
|
||||
}
|
||||
const txt = await resp.text();
|
||||
if (!txt.includes('is alive')) {
|
||||
throw new Error(`Failed, page has unexpected content`);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: String(e),
|
||||
severity: 'fault',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const _userProbe: Probe = {
|
||||
id: 'system-user',
|
||||
name: 'System user is sane',
|
||||
apply: async () => {
|
||||
if (process.getuid && process.getuid() === 0) {
|
||||
return {
|
||||
success: false,
|
||||
verdict: 'User appears to be root (UID 0)',
|
||||
severity: 'warning',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const _bootProbe: Probe = {
|
||||
id: 'boot-page',
|
||||
name: 'Boot page exposure',
|
||||
apply: async (server) => {
|
||||
if (!server.hasBoot) {
|
||||
return { success: true };
|
||||
}
|
||||
const maybeSecureEnough = String(process.env.GRIST_BOOT_KEY).length > 10;
|
||||
return {
|
||||
success: maybeSecureEnough,
|
||||
severity: 'hmm',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Based on:
|
||||
* https://github.com/gristlabs/grist-core/issues/228#issuecomment-1803304438
|
||||
*
|
||||
* When GRIST_SERVE_SAME_ORIGIN is set, requests arriving to Grist need
|
||||
* to have an accurate Host header.
|
||||
*/
|
||||
const _hostHeaderProbe: Probe = {
|
||||
id: 'host-header',
|
||||
name: 'Host header is sane',
|
||||
apply: async (server, req) => {
|
||||
const host = req.header('host');
|
||||
const url = new URL(server.getHomeUrl(req));
|
||||
if (url.hostname === 'localhost') {
|
||||
return {
|
||||
done: true,
|
||||
};
|
||||
}
|
||||
if (String(url.hostname).toLowerCase() !== String(host).toLowerCase()) {
|
||||
return {
|
||||
success: false,
|
||||
severity: 'hmm',
|
||||
};
|
||||
}
|
||||
return {
|
||||
done: true,
|
||||
};
|
||||
},
|
||||
};
|
@ -0,0 +1,148 @@
|
||||
# Disposal and Cleanup
|
||||
|
||||
Garbage-collected languages make you think that you don't need to worry about cleanup for your objects. In reality, there are still often cases when you do. This page gives some examples, and describes a library to simplify it.
|
||||
|
||||
## What's the problem
|
||||
|
||||
In the examples, we care about a situation when you have a JS object that is responsible for certain UI, i.e. DOM, listening to DOM changes to update state elsewhere, and listening to outside changes to update state to the DOM.
|
||||
|
||||
### DOM Elements
|
||||
So this JS object knows how to create the DOM. Removing the DOM, when the component is to be removed, is usually easy: `parentNode.removeNode(child)`. Since it's a manual operation, you may define some method to do this, named perhaps "destroy" or "dispose" or "cleanup".
|
||||
|
||||
If there is logic tied to your DOM either via JQuery events, or KnockoutJS bindings, you'll want to clean up the node specially: for JQuery, use `.remove()` or `.empty()` methods; for KnockoutJS, use `ko.removeNode()` or `ko.cleanNode()`. KnockoutJS's methods automatically call JQuery-related cleanup functions if JQuery is loaded in the page.
|
||||
|
||||
### Subscriptions and Computed Observables
|
||||
But there is more. Consider this knockout code, adapted from their simplest example of a computed observable:
|
||||
|
||||
function FullNameWidget(firstName, lastName) {
|
||||
this.fullName = ko.computed(function() {
|
||||
return firstName() + " " + lastName();
|
||||
});
|
||||
...
|
||||
}
|
||||
|
||||
Here we have a constructor for a component which takes two observables as constructor parameters, and creates a new observable which depends on the two inputs. Whenever `firstName` or `lastName` changes, `this.fullName` get recomputed. This makes it easy to create knockout-based bindings, e.g. to have a DOM element reflect the full name when either first or last name changes.
|
||||
|
||||
Now, what happens when this component is destroyed? It removes its associated DOM. Now when `firstName` or `lastName` change, there are no visible changes. But the function to recompute `this.fullName` still gets called, and still retains a reference to `this`, preventing the object from being garbage-collected.
|
||||
|
||||
The issue is that `this.fullName` is subscribed to `firstName` and `lastName` observables. It needs to be unsubscribed when the component is destroyed.
|
||||
|
||||
KnockoutJS recognizes it, and makes it easy: just call `this.firstName.dispose()`. We just have to remember to do it when we destroy the component.
|
||||
|
||||
This situation would exist without knockout too: the issue is that the component is listening to external changes to update the DOM that it is responsible for. When the component is gone, it should stop listening.
|
||||
|
||||
### Tying life of subscriptions to DOM
|
||||
Since the situation above is so common in KnockoutJS, it offers some assistance. Specifically, when a computed observable is created using knockout's own binding syntax (by specifying a JS expression in an HTML attribute), knockout will clean it up automatically when the DOM node is removed using `ko.removeNode()` or `ko.cleanNode()`.
|
||||
|
||||
Knockout also allows to tie other cleanup to DOM node removal, documented at [Custom disposal logic](http://knockoutjs.com/documentation/custom-bindings-disposal.html) page.
|
||||
|
||||
In the example above, you could use `ko.utils.domNodeDisposal.addDisposeCallback(node, function() { self.fullName.dispose(); })`, and when you destroy the component and remove the `node` via `ko.removeNode()` or `ko.cleanNode()`, the `fullName` observable will be properly disposed.
|
||||
|
||||
### Other knockout subscriptions
|
||||
There are other situations with subscriptions. For example, we may want to subscribe to a `viewId` observable, and when it changes, replace the currently-rendered View component. This might look like so
|
||||
|
||||
function GristDoc() {
|
||||
this.viewId = ko.observable();
|
||||
this.viewId.subscribe(function(viewId) {
|
||||
this.loadView(viewId);
|
||||
}, this);
|
||||
}
|
||||
|
||||
Once GristDoc is destroyed, the subscription to `this.viewId` still exists, so `this.viewId` retains a reference to `this` (for calling the callback). Technically, there is no problem: as long as there are no references to `this.viewId` from outside this object, the whole cycle should be garbage-collected.
|
||||
|
||||
But it's very risky: if anything else has a reference to `this.viewId` (e.g. if `this.viewId` is itself subscribed to, say, `window.history` changes), then the entire `GristDoc` is unavailable to garbage-collection, including all the DOM to which it probably retains references even after that DOM is detached from the page.
|
||||
|
||||
Beside the memory leak, it means that when `this.viewId` changes, it will continue calling `this.loadView()`, continuing to update DOM that no one will ever see. Over time, that would of course slow down the browser, but would be hard to detect and debug.
|
||||
|
||||
Again, KnockoutJS offers a way to unsubscribe: `.subscribe()` returns a `ko.subscription` object, which in turn has a `dispose()` method. We just need to call it, and the callback will be unsubscribed.
|
||||
|
||||
### Backbone Events
|
||||
|
||||
To be clear, the problem isn't with Knockout, it's with the idea of subscribing to outside events. Backbone allows listening to events, which creates the same problem, and Backbone offers a similar solution.
|
||||
|
||||
For example, let's say you have a component that listens to an outside event and does stuff. With a made-up example, you might have a constructor like:
|
||||
|
||||
function Game(basket) {
|
||||
basket.on('points:scored', function(team, points) {
|
||||
// Update UI to show updated points for the team.
|
||||
});
|
||||
}
|
||||
|
||||
Let's say that a `Game` object is destroyed, and a new one created, but the `basket` persists across Games. As the user continues to score points on the basket, the old (supposedly destroyed) Game object continues to have that inline callback called. It may not be showing anything, but only because the DOM it's updating is no longer attached to the page. It's still taking resources, and may even continue to send stuff to the server.
|
||||
|
||||
We need to clean up when we destroy the Game object. In this example, it's pretty annoying. We'd have to save the `basket` object and callback in member variables (like `this.basket`, `this.callback`), so that in the cleanup method, we could call `this.basket.off('points:scored', this.callback)`.
|
||||
|
||||
Many people have gotten bitten with that in Backbone (see this [stackoverflow post](http://stackoverflow.com/questions/14041042/backbone-0-9-9-difference-between-listento-and-on)) with a bunch of links to blog posts about it).
|
||||
|
||||
Backbone's solution is `listenTo()` method. You'd use it like so:
|
||||
|
||||
function Game(basket) {
|
||||
this.listenTo(basket, 'points:scored', function(team, points) {
|
||||
// Update UI to show updated points for the team.
|
||||
});
|
||||
}
|
||||
|
||||
Then when you destroy the Game object, you only have to call `this.stopListening()`. It keeps track of what you listened to, and unsubscribes. You just have to remember to call it. (Certain objects in Backbone will call `stopListening()` automatically when they are being cleaned up.)
|
||||
|
||||
### Internal events
|
||||
|
||||
If a component listens to an event on a DOM element it itself owns, and if it's using JQuery, then we don't need to do anything special. If on destruction of the component, we clean up the DOM element using `ko.removeNode()`, the JQuery event bindings should automatically be removed. (This hasn't been rigorously verified, but if correct, is a reason to use JQuery for browser events rather than native `addEventListener`.)
|
||||
|
||||
|
||||
## How to do cleanup uniformly
|
||||
|
||||
Since we need to destroy the components' DOM explicitly, the components should provide a method to call for that. By analogy with KnockoutJS, let's call it `dispose()`.
|
||||
|
||||
- We know that it needs to remove the DOM that the component is responsible for, probably using `ko.removeNode`.
|
||||
- If the component used Backbone's `listenTo()`, it should call `stopListening()` to unsubscribe from Backbone events.
|
||||
- If the component maintains any knockout subscriptions or computed observables, it should call `.dispose()` on them.
|
||||
- If the component owns other components, then those should be cleaned up recursively, by calling `.dispose()` on those.
|
||||
|
||||
The trick is how to make it easy to remember to do all necessary cleanup. I propose keeping track when the object to clean up first enters the picture.
|
||||
|
||||
## 'Disposable' class
|
||||
|
||||
The idea is to have a class that can be mixed into (or inherited by) any object, and whose purpose is to keep track of things this object "owns", that it should be responsible for cleaning up. To combine the examples above:
|
||||
|
||||
function Component(firstName, lastName, basket) {
|
||||
this.fullName = this.autoDispose(ko.computed(function() {
|
||||
return firstName() + " " + lastName();
|
||||
}));
|
||||
|
||||
this.viewId = ko.observable();
|
||||
this.autoDispose(this.viewId.subscribe(function(viewId) {
|
||||
this.loadView(viewId);
|
||||
}, this));
|
||||
|
||||
this.ourDom = this.autoDispose(somewhere.appendChild(some_dom_we_create));
|
||||
|
||||
this.listenTo(basket, 'points:scored', function(team, points) {
|
||||
// Update UI to show updated points for the team.
|
||||
});
|
||||
}
|
||||
|
||||
Note the `this.autoDispose()` calls. They mark the argument as being owned by `this`. When `this.dispose()` is called, those values get disposed of as well.
|
||||
|
||||
The disposal itself is fairly straightforward: if the object has a `dispose` method, we'll call that. If it's a DOM node, we'll call `ko.removeNode` on it. The `dispose()` method of Disposable objects will always call `this.stopListening()` if such a method exists, so that subscriptions using Backbone's `listenTo` are cleaned up automatically.
|
||||
|
||||
To do additional cleanup when `dispose()` is called, the derived class can override `dispose()`, do its other cleanup, then call `Disposable.prototype.dispose.call(this)`.
|
||||
|
||||
For convenience, Disposable class provides a few other methods:
|
||||
|
||||
- `disposeRelease(part)`: releases an owned object, so that it doesn't get auto-disposed.
|
||||
- `disposeDiscard(part)`: disposes of an owned object early (rather than wait for `this.dispose`).
|
||||
- `isDisposed()`: returns whether `this.dispose()` has already been called.
|
||||
|
||||
### Destroying destroyed objects
|
||||
|
||||
There is one more thing that Disposable class's `dispose()` method will do: destroy the object, as in ruin, wreck, wipe out. Specifically, it will go through all properties of `this`, and set each to a junk value. This achieves two goals:
|
||||
|
||||
1. In any of the examples above, if you forgot to mark anything with `this.autoDispose()`, and some callback continues to be called after the object has been destroyed, you'll get errors. Not just silent waste of resources that slow down the site and are hard to detect.
|
||||
|
||||
2. It removes references, potentially breaking references. Imagine that something wrongly retains a reference to a destroyed object (which logically nothing should, but something might by mistake). If it tries to use the object, it will fail (see point 1). But even if it doesn't access the object, it's preventing the garbage collector from cleaning any of the object. If we break references, then in this situation the GC can still collect all the properties of the destroyed object.
|
||||
|
||||
## Conclusion
|
||||
|
||||
All JS client-side components that need cleanup (e.g. maintain DOM, observables, listen to events, or subscribe to anything), should inherit from `Disposable`. To destroy them, call their `.dispose()` method. Whenever they take responsibility for any piece that requires cleanup, they should wrap that piece in `this.autoDispose()`.
|
||||
|
||||
This should go a long way towards avoiding leaks and slowdowns.
|
@ -0,0 +1,42 @@
|
||||
# Migrations
|
||||
|
||||
If you change Grist schema, i.e. the schema of the Grist metadata tables (in `sandbox/grist/schema.py`), you'll have to increment the `SCHEMA_VERSION` (on top of that file) and create a migration. A migration is a set of actions that would get applied to a document at the previous version, to make it satisfy the new schema.
|
||||
|
||||
To add a migration, add a function to `sandbox/grist/migrations.py`, of this form (using the new version number):
|
||||
```lang=python
|
||||
@migration(schema_version=11)
|
||||
def migration11(tdset):
|
||||
return tdset.apply_doc_actions([
|
||||
add_column('_grist_Views_section', 'embedId', 'Text'),
|
||||
])
|
||||
```
|
||||
|
||||
Some migrations need to actually add or modify the data in a document. You can look at other migrations in that file for examples.
|
||||
|
||||
If you are doing anything other than adding a column or a table, you must read this document to the end.
|
||||
|
||||
## Philosophy of migrations
|
||||
|
||||
Migrations are tricky. Normally, we think about the software we are writing, but migrations work with documents that were created by an older version of the software, which may not have the logic you think our software has, and MAY have logic that the current version knows nothing about.
|
||||
|
||||
This is why migrations code uses its own "dumb" implementation for loading and examining data (see `sandbox/grist/table_data_set.py`), because trying to load an older document using our primary code base will usually fail, since the document will not satisfy our current assumptions.
|
||||
|
||||
## Restrictions
|
||||
|
||||
The rules below should make it at least barely possible to share documents by people who are not all on the same Grist version (even so, it will require more work). It should also make it somewhat safe to upgrade and then open the document with a previous version.
|
||||
|
||||
WARNING: Do not remove, modify, or rename metadata tables or columns.
|
||||
|
||||
Mark old columns and tables as deprecated using a comment. We may want to add a feature to mark them in code, to prevent their use in new versions. For now, it's enough to add a comment and remove references to the deprecated entities throughout code. An important goal is to prevent adding same-named entities in the future, or reusing the same column with a different meaning. So please add a comment of the form:
|
||||
|
||||
```lang=python
|
||||
# <columnName> is deprecated as of version XX. Do not remove or reuse.
|
||||
```
|
||||
|
||||
To justify keeping old columns around, consider what would happen if A (at version 10) communicates with B (at version 11). If column "foo" exists in v10, and is deleted in v11, then A may send actions that refer to "foo", and B would consider them invalid, since B's code has no idea what "foo" is. The solution is that B needs to still know about "foo", hence we don't remove old columns.
|
||||
|
||||
Similar justification applies to renaming columns, or modifying them (e.g. changing a type).
|
||||
|
||||
WARNING: If you change the meaning or type of a column, you have to create a new column with a new name.
|
||||
|
||||
You'll also need to write a migration to fill it from the old column, and would mark the old column as deprecated.
|
@ -0,0 +1,15 @@
|
||||
<!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">
|
||||
<!-- INSERT LOCALE -->
|
||||
<!-- INSERT CONFIG -->
|
||||
<title>Loading...<!-- INSERT TITLE SUFFIX --></title>
|
||||
</head>
|
||||
<body>
|
||||
<script crossorigin="anonymous" src="boot.bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
@ -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);
|
||||
|
||||
})();
|
||||
}
|
After Width: | Height: | Size: 21 KiB |
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 |
@ -0,0 +1,52 @@
|
||||
import {assert, driver} from 'mocha-webdriver';
|
||||
import * as gu from 'test/nbrowser/gristUtils';
|
||||
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
|
||||
import * as testUtils from 'test/server/testUtils';
|
||||
|
||||
describe('Boot', function() {
|
||||
this.timeout(30000);
|
||||
setupTestSuite();
|
||||
|
||||
let oldEnv: testUtils.EnvironmentSnapshot;
|
||||
|
||||
afterEach(() => gu.checkForErrors());
|
||||
|
||||
async function hasPrompt() {
|
||||
assert.include(
|
||||
await driver.findContentWait('p', /diagnostics page/, 2000).getText(),
|
||||
'A diagnostics page can be made available');
|
||||
}
|
||||
|
||||
it('gives prompt about how to enable boot page', async function() {
|
||||
await driver.get(`${server.getHost()}/boot`);
|
||||
await hasPrompt();
|
||||
});
|
||||
|
||||
describe('with a GRIST_BOOT_KEY', function() {
|
||||
before(async function() {
|
||||
oldEnv = new testUtils.EnvironmentSnapshot();
|
||||
process.env.GRIST_BOOT_KEY = 'lala';
|
||||
await server.restart();
|
||||
});
|
||||
|
||||
after(async function() {
|
||||
oldEnv.restore();
|
||||
await server.restart();
|
||||
});
|
||||
|
||||
it('gives prompt when key is missing', async function() {
|
||||
await driver.get(`${server.getHost()}/boot`);
|
||||
await hasPrompt();
|
||||
});
|
||||
|
||||
it('gives prompt when key is wrong', async function() {
|
||||
await driver.get(`${server.getHost()}/boot/bilbo`);
|
||||
await hasPrompt();
|
||||
});
|
||||
|
||||
it('gives page when key is right', async function() {
|
||||
await driver.get(`${server.getHost()}/boot/lala`);
|
||||
await driver.findContentWait('h2', /Grist is reachable/, 2000);
|
||||
});
|
||||
});
|
||||
});
|