Merge branch 'main' into custom_contact_support_url

pull/854/head
CamilleLegeron 3 months ago
commit 899e657f2d

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

@ -14,6 +14,19 @@ The `grist-core`, `grist-electron`, and `grist-static` repositories are all open
https://user-images.githubusercontent.com/118367/151245587-892e50a6-41f5-4b74-9786-fe3566f6b1fb.mp4
## 2024 - We're hiring a Systems Engineer!
We are looking for a friendly, capable engineer to join our small
team. You will have broad responsibility for the ease of installation
and maintenance of Grist as an application and service, by our
clients, by self-hosters, and by ourselves.
Read the [full job posting](https://www.getgrist.com/job-systems-engineer/)
or jump into the puzzle that comes with it by just running this:
```
docker run -it gristlabs/grist-twist
```
## Features
Grist is a hybrid database/spreadsheet, meaning that:

@ -11,6 +11,7 @@ import {GristDoc} from 'app/client/components/GristDoc';
import {logTelemetryEvent} from 'app/client/lib/telemetry';
import {reportError, UserError} from 'app/client/models/errors';
import {TableData} from 'app/client/models/TableData';
import {withInfoTooltip} from 'app/client/ui/tooltips';
import {shadowScroll} from 'app/client/ui/shadowScroll';
import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {squareCheckbox} from 'app/client/ui2018/checkbox';
@ -690,8 +691,7 @@ class TableRules extends Disposable {
cssIconButton(icon('Dots'), {style: 'margin-left: auto'},
menu(() => [
menuItemAsync(() => this._addColumnRuleSet(), t("Add Column Rule")),
menuItemAsync(() => this._addDefaultRuleSet(), t("Add Default Rule"),
dom.cls('disabled', use => Boolean(use(this._defaultRuleSet)))),
menuItemAsync(() => this._addDefaultRuleSet(), t("Add Table-wide Rule")),
menuItemAsync(() => this._accessRules.removeTableRules(this), t("Delete Table Rules")),
]),
testId('rule-table-menu-btn'),
@ -813,9 +813,13 @@ class TableRules extends Disposable {
}
private _addDefaultRuleSet() {
if (!this._defaultRuleSet.get()) {
const ruleSet = this._defaultRuleSet.get();
if (!ruleSet) {
DefaultObsRuleSet.create(this._defaultRuleSet, this._accessRules, this, this._haveColumnRules);
this.addDefaultRules(this._accessRules.getSeedRules());
} else {
const part = ruleSet.addRulePart(ruleSet.getDefaultCondition());
setTimeout(() => part.focusEditor?.(), 0);
}
}
}
@ -1034,6 +1038,12 @@ abstract class ObsRuleSet extends Disposable {
return body.length > 0 && body[body.length - 1].hasEmptyCondition(use);
}
public getDefaultCondition(): ObsRulePart|null {
const body = this._body.get();
const last = body.length > 0 ? body[body.length - 1] : null;
return last?.hasEmptyCondition(unwrap) ? last : null;
}
/**
* Which permission bits to allow the user to set.
*/
@ -1129,8 +1139,9 @@ class DefaultObsRuleSet extends ObsRuleSet {
return [
cssCenterContent.cls(''),
cssDefaultLabel(
dom.text(use => this._haveColumnRules && use(this._haveColumnRules) ? 'All Other' : 'All'),
)
dom.domComputed(use => this._haveColumnRules && use(this._haveColumnRules), (haveColRules) =>
haveColRules ? withInfoTooltip('All', 'accessRulesTableWide') : 'All')
),
];
}
}
@ -1544,6 +1555,8 @@ class ObsRulePart extends Disposable {
// Whether the rule part, and if it's valid or being checked.
public ruleStatus: Computed<RuleStatus>;
public focusEditor: (() => void)|undefined;
// Formula to show in the formula editor.
private _aclFormula = Observable.create<string>(this, this._rulePart?.aclFormula || "");
@ -1679,6 +1692,7 @@ class ObsRulePart extends Disposable {
);
}),
getSuggestions: (prefix) => this._completions.get(),
customiseEditor: (editor) => { this.focusEditor = () => editor.focus(); },
}),
testId('rule-acl-formula'),
),

@ -2,7 +2,7 @@ import {loadCssFile, loadScript} from 'app/client/lib/loadScript';
import type {AppModel} from 'app/client/models/AppModel';
import {urlState} from 'app/client/models/gristUrlState';
import {reportError} from 'app/client/models/errors';
import {setUpPage} from 'app/client/ui/setUpPage';
import {createAppPage} from 'app/client/ui/createAppPage';
import {DocAPIImpl} from 'app/common/UserAPI';
import type {RecordWithStringId} from 'app/plugin/DocApiTypes';
import {dom, styled} from 'grainjs';
@ -291,7 +291,7 @@ function requestInterceptor(request: SwaggerUI.Request) {
return request;
}
setUpPage((appModel) => {
createAppPage((appModel) => {
// Default Grist page prevents scrolling unnecessarily.
document.documentElement.style.overflow = 'initial';

@ -1,5 +1,5 @@
import {BillingPage} from 'app/client/ui/BillingPage';
import {setUpPage} from 'app/client/ui/setUpPage';
import {createAppPage} from 'app/client/ui/createAppPage';
import {dom} from 'grainjs';
setUpPage((appModel) => dom.create(BillingPage, appModel));
createAppPage((appModel) => dom.create(BillingPage, appModel));

@ -0,0 +1,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;
}

@ -8,3 +8,35 @@ iframe.custom_view {
padding: 15px;
margin: 15px;
}
.custom_view_no_mapping {
padding: 15px;
margin: 15px;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
color: var(--grist-theme-text, var(--grist-color-dark));
}
.custom_view_no_mapping h1 {
max-width: 310px;
margin-bottom: 24px;
margin-top: 56px;
font-style: normal;
font-weight: 600;
font-size: 22px;
line-height: 26px;
text-align: center;
text-wrap: balance;
}
.custom_view_no_mapping p {
max-width: 310px;
font-style: normal;
font-weight: 400;
font-size: 13px;
line-height: 16px;
text-align: center;
}

@ -16,8 +16,10 @@ import {
WidgetFrame
} from 'app/client/components/WidgetFrame';
import {CustomSectionElement, ViewProcess} from 'app/client/lib/CustomSectionElement';
import {makeT} from 'app/client/lib/localization';
import {Disposable} from 'app/client/lib/dispose';
import dom from 'app/client/lib/dom';
import {makeTestId} from 'app/client/lib/domUtils';
import * as kd from 'app/client/lib/koDom';
import DataTableModel from 'app/client/models/DataTableModel';
import {ViewSectionRec} from 'app/client/models/DocModel';
@ -28,12 +30,14 @@ import {closeRegisteredMenu} from 'app/client/ui2018/menus';
import {AccessLevel} from 'app/common/CustomWidget';
import {defaultLocale} from 'app/common/gutil';
import {PluginInstance} from 'app/common/PluginInstance';
import {getGristConfig} from 'app/common/urlUtils';
import {Events as BackboneEvents} from 'backbone';
import {dom as grains} from 'grainjs';
import * as ko from 'knockout';
import defaults = require('lodash/defaults');
const t = makeT('CustomView');
const testId = makeTestId('test-custom-widget-');
/**
*
* Built in settings for a custom widget. Used when the custom
@ -104,6 +108,7 @@ export class CustomView extends Disposable {
private _pluginInstance: PluginInstance|undefined;
private _frame: WidgetFrame; // plugin frame (holding external page)
private _hasUnmappedColumns: ko.Computed<boolean>;
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
BaseView.call(this as any, gristDoc, viewSectionModel, { 'addNewRow': true });
@ -124,6 +129,15 @@ export class CustomView extends Disposable {
this.autoDispose(this.customDef.sectionId.subscribe(this._updateCustomSection, this));
this.autoDispose(commands.createGroup(CustomView._commands, this, this.viewSection.hasFocus));
this._hasUnmappedColumns = this.autoDispose(ko.pureComputed(() => {
const columns = this.viewSection.columnsToMap();
if (!columns) { return false; }
const required = columns.filter(col => typeof col === 'string' || !(col.optional === true))
.map(col => typeof col === 'string' ? col : col.name);
const mapped = this.viewSection.mappedColumns() || {};
return required.some(col => !mapped[col]) && this.customDef.mode() === "url";
}));
this.viewPane = this.autoDispose(this._buildDom());
this._updatePluginInstance();
}
@ -138,10 +152,6 @@ export class CustomView extends Disposable {
return {};
}
protected getEmptyWidgetPage(): string {
return new URL("custom-widget.html", getGristConfig().homeUrl!).href;
}
/**
* Find a plugin instance that matches the plugin id, update the `found` observables, then tries to
* find a matching section.
@ -207,11 +217,21 @@ export class CustomView extends Disposable {
dom.autoDispose(showPluginNotification),
dom.autoDispose(showSectionNotification),
dom.autoDispose(showPluginContent),
kd.maybe(this._hasUnmappedColumns, () => dom('div.custom_view_no_mapping',
testId('not-mapped'),
dom('img', {src: 'img/empty-widget.svg'}),
dom('h1', kd.text(t("Some required columns aren't mapped"))),
dom('p',
t('To use this widget, please map all non-optional columns from the creator panel on the right.')
),
)),
// todo: should display content in webview when running electron
// prefer widgetId; spelunk in widgetDef for older docs
kd.scope(() => [mode(), url(), access(), widgetId() || widgetDef()?.widgetId || '', pluginId()],
([_mode, _url, _access, _widgetId, _pluginId]: string[]) =>
_mode === "url" ?
kd.scope(() => [
this._hasUnmappedColumns(), mode(), url(), access(), widgetId() || widgetDef()?.widgetId || '', pluginId()
], ([_hide, _mode, _url, _access, _widgetId, _pluginId]: string[]) =>
_mode === "url" && !_hide ?
this._buildIFrame({
baseUrl: _url,
access: builtInSettings.accessLevel || (_access as AccessLevel || AccessLevel.none),
@ -254,7 +274,7 @@ export class CustomView extends Disposable {
const documentSettings = this.gristDoc.docData.docSettings();
const readonly = this.gristDoc.isReadonly.get();
const widgetFrame = WidgetFrame.create(null, {
url: baseUrl || this.getEmptyWidgetPage(),
url: baseUrl,
widgetId,
pluginId,
access,

@ -0,0 +1,360 @@
import * as css from 'app/client/components/FormRendererCss';
import {FormField} from 'app/client/ui/FormAPI';
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
import {CellValue} from 'app/plugin/GristData';
import {Disposable, dom, DomContents, Observable} from 'grainjs';
import {marked} from 'marked';
export const CHOOSE_TEXT = '— Choose —';
/**
* A node in a recursive, tree-like hierarchy comprising the layout of a form.
*/
export interface FormLayoutNode {
type: FormLayoutNodeType;
children?: Array<FormLayoutNode>;
// Unique ID of the field. Used only in the Form widget.
id?: string;
// Used by Layout.
submitText?: string;
successURL?: string;
successText?: string;
anotherResponse?: boolean;
// Used by Field.
formRequired?: boolean;
leaf?: number;
// Used by Label and Paragraph.
text?: string;
// Used by Paragraph.
alignment?: string;
}
export type FormLayoutNodeType =
| 'Paragraph'
| 'Section'
| 'Columns'
| 'Submit'
| 'Placeholder'
| 'Layout'
| 'Field'
| 'Label'
| 'Separator'
| 'Header';
/**
* Context used by FormRenderer to build each node.
*/
export interface FormRendererContext {
/** Field metadata, keyed by field id. */
fields: Record<number, FormField>;
/** The root of the FormLayoutNode tree. */
rootLayoutNode: FormLayoutNode;
/** Disables the Submit node if true. */
disabled: Observable<boolean>;
/** Error to show above the Submit node. */
error: Observable<string|null>;
}
/**
* A renderer for a form layout.
*
* Takes the root FormLayoutNode and additional context for each node, and returns
* the DomContents of the rendered form.
*
* A closely related set of classes exist in `app/client/components/Forms/*`; those are
* specifically used to render a version of a form that is suitable for displaying within
* a Form widget, where submitting a form isn't possible.
*
* TODO: merge the two implementations or factor out what's common.
*/
export abstract class FormRenderer extends Disposable {
public static new(layoutNode: FormLayoutNode, context: FormRendererContext): FormRenderer {
const Renderer = FormRenderers[layoutNode.type] ?? ParagraphRenderer;
return new Renderer(layoutNode, context);
}
protected children: FormRenderer[];
constructor(protected layoutNode: FormLayoutNode, protected context: FormRendererContext) {
super();
this.children = (this.layoutNode.children ?? []).map((child) =>
this.autoDispose(FormRenderer.new(child, this.context)));
}
public abstract render(): DomContents;
}
class LabelRenderer extends FormRenderer {
public render() {
return css.label(this.layoutNode.text ?? '');
}
}
class ParagraphRenderer extends FormRenderer {
public render() {
return css.paragraph(
css.paragraph.cls(`-alignment-${this.layoutNode.alignment || 'left'}`),
el => {
el.innerHTML = sanitizeHTML(marked(this.layoutNode.text || '**Lorem** _ipsum_ dolor'));
},
);
}
}
class SectionRenderer extends FormRenderer {
public render() {
return css.section(
this.children.map((child) => child.render()),
);
}
}
class ColumnsRenderer extends FormRenderer {
public render() {
return css.columns(
{style: `--grist-columns-count: ${this.children.length || 1}`},
this.children.map((child) => child.render()),
);
}
}
class SubmitRenderer extends FormRenderer {
public render() {
return [
css.error(dom.text(use => use(this.context.error) ?? '')),
css.submit(
dom('input',
dom.boolAttr('disabled', this.context.disabled),
{
type: 'submit',
value: this.context.rootLayoutNode.submitText || 'Submit'
},
dom.on('click', () => {
// Make sure that all choice or reference lists that are required have at least one option selected.
const lists = document.querySelectorAll('.grist-checkbox-list.required:not(:has(input:checked))');
Array.from(lists).forEach(function(list) {
// If the form has at least one checkbox, make it required.
const firstCheckbox = list.querySelector('input[type="checkbox"]');
firstCheckbox?.setAttribute('required', 'required');
});
// All other required choice or reference lists with at least one option selected are no longer required.
const checkedLists = document.querySelectorAll('.grist-checkbox-list.required:has(input:checked)');
Array.from(checkedLists).forEach(function(list) {
const firstCheckbox = list.querySelector('input[type="checkbox"]');
firstCheckbox?.removeAttribute('required');
});
}),
)
),
];
}
}
class PlaceholderRenderer extends FormRenderer {
public render() {
return dom('div');
}
}
class LayoutRenderer extends FormRenderer {
public render() {
return this.children.map((child) => child.render());
}
}
class FieldRenderer extends FormRenderer {
public build(field: FormField) {
const Renderer = FieldRenderers[field.type as keyof typeof FieldRenderers] ?? TextRenderer;
return new Renderer();
}
public render() {
const field = this.layoutNode.leaf ? this.context.fields[this.layoutNode.leaf] : null;
if (!field) { return null; }
const renderer = this.build(field);
return css.field(renderer.render(field, this.context));
}
}
abstract class BaseFieldRenderer {
public render(field: FormField, context: FormRendererContext) {
return css.field(
this.label(field),
dom('div', this.input(field, context)),
);
}
public name(field: FormField) {
return field.colId;
}
public label(field: FormField) {
return dom('label',
css.label.cls(''),
css.label.cls('-required', Boolean(field.options.formRequired)),
{for: this.name(field)},
field.question,
);
}
public abstract input(field: FormField, context: FormRendererContext): DomContents;
}
class TextRenderer extends BaseFieldRenderer {
public input(field: FormField) {
return dom('input', {
type: 'text',
name: this.name(field),
required: field.options.formRequired,
});
}
}
class DateRenderer extends BaseFieldRenderer {
public input(field: FormField) {
return dom('input', {
type: 'date',
name: this.name(field),
required: field.options.formRequired,
});
}
}
class DateTimeRenderer extends BaseFieldRenderer {
public input(field: FormField) {
return dom('input', {
type: 'datetime-local',
name: this.name(field),
required: field.options.formRequired,
});
}
}
class ChoiceRenderer extends BaseFieldRenderer {
public input(field: FormField) {
const choices: Array<string|null> = field.options.choices || [];
// Insert empty option.
choices.unshift(null);
return css.select(
{name: this.name(field), required: field.options.formRequired},
choices.map((choice) => dom('option', {value: choice ?? ''}, choice ?? CHOOSE_TEXT))
);
}
}
class BoolRenderer extends BaseFieldRenderer {
public render(field: FormField) {
return css.field(
dom('div', this.input(field)),
);
}
public input(field: FormField) {
return css.toggle(
css.label.cls('-required', Boolean(field.options.formRequired)),
dom('input', {
type: 'checkbox',
name: this.name(field),
value: '1',
required: field.options.formRequired,
}),
css.gristSwitch(
css.gristSwitchSlider(),
css.gristSwitchCircle(),
),
dom('span', field.question || field.colId)
);
}
}
class ChoiceListRenderer extends BaseFieldRenderer {
public input(field: FormField) {
const choices: string[] = field.options.choices ?? [];
const required = field.options.formRequired;
return css.checkboxList(
dom.cls('grist-checkbox-list'),
dom.cls('required', Boolean(required)),
{name: this.name(field), required},
choices.map(choice => css.checkbox(
dom('input', {
type: 'checkbox',
name: `${this.name(field)}[]`,
value: choice,
}),
dom('span', choice),
)),
);
}
}
class RefListRenderer extends BaseFieldRenderer {
public input(field: FormField) {
const choices: [number, CellValue][] = field.refValues ?? [];
// Sort by the second value, which is the display value.
choices.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
// Support for 30 choices. TODO: make limit dynamic.
choices.splice(30);
const required = field.options.formRequired;
return css.checkboxList(
dom.cls('grist-checkbox-list'),
dom.cls('required', Boolean(required)),
{name: this.name(field), required},
choices.map(choice => css.checkbox(
dom('input', {
type: 'checkbox',
'data-grist-type': field.type,
name: `${this.name(field)}[]`,
value: String(choice[0]),
}),
dom('span', String(choice[1] ?? '')),
)),
);
}
}
class RefRenderer extends BaseFieldRenderer {
public input(field: FormField) {
const choices: [number|string, CellValue][] = field.refValues ?? [];
// Sort by the second value, which is the display value.
choices.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
// Support for 1000 choices. TODO: make limit dynamic.
choices.splice(1000);
// Insert empty option.
choices.unshift(['', CHOOSE_TEXT]);
return css.select(
{
name: this.name(field),
'data-grist-type': field.type,
required: field.options.formRequired,
},
choices.map((choice) => dom('option', {value: String(choice[0])}, String(choice[1] ?? ''))),
);
}
}
const FieldRenderers = {
'Text': TextRenderer,
'Choice': ChoiceRenderer,
'Bool': BoolRenderer,
'ChoiceList': ChoiceListRenderer,
'Date': DateRenderer,
'DateTime': DateTimeRenderer,
'Ref': RefRenderer,
'RefList': RefListRenderer,
};
const FormRenderers = {
'Paragraph': ParagraphRenderer,
'Section': SectionRenderer,
'Columns': ColumnsRenderer,
'Submit': SubmitRenderer,
'Placeholder': PlaceholderRenderer,
'Layout': LayoutRenderer,
'Field': FieldRenderer,
'Label': LabelRenderer,
// Aliases for Paragraph.
'Separator': ParagraphRenderer,
'Header': ParagraphRenderer,
};

@ -0,0 +1,254 @@
import {colors, vars} from 'app/client/ui2018/cssVars';
import {styled} from 'grainjs';
export const label = styled('div', `
&-required::after {
content: "*";
color: ${vars.primaryBg};
margin-left: 4px;
}
`);
export const paragraph = styled('div', `
&-alignment-left {
text-align: left;
}
&-alignment-center {
text-align: center;
}
&-alignment-right {
text-align: right;
}
`);
export const section = styled('div', `
border-radius: 3px;
border: 1px solid ${colors.darkGrey};
padding: 24px;
margin-top: 24px;
& > div + div {
margin-top: 16px;
}
`);
export const columns = styled('div', `
display: grid;
grid-template-columns: repeat(var(--grist-columns-count), 1fr);
gap: 4px;
`);
export const submit = styled('div', `
display: flex;
justify-content: center;
align-items: center;
& input[type="submit"] {
background-color: ${vars.primaryBg};
border: 1px solid ${vars.primaryBg};
color: white;
padding: 10px 24px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
line-height: inherit;
}
& input[type="submit"]:hover {
border-color: ${vars.primaryBgHover};
background-color: ${vars.primaryBgHover};
}
`);
// TODO: break up into multiple variables, one for each field type.
export const field = styled('div', `
display: flex;
flex-direction: column;
& input[type="text"],
& input[type="date"],
& input[type="datetime-local"],
& input[type="number"] {
height: 27px;
padding: 4px 8px;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
outline: none;
}
& input[type="text"] {
font-size: 13px;
outline-color: ${vars.primaryBg};
outline-width: 1px;
line-height: inherit;
width: 100%;
color: ${colors.dark};
background-color: ${colors.light};
}
& input[type="datetime-local"],
& input[type="date"] {
width: 100%;
line-height: inherit;
}
& input[type="checkbox"] {
-webkit-appearance: none;
-moz-appearance: none;
padding: 0;
flex-shrink: 0;
display: inline-block;
width: 16px;
height: 16px;
--radius: 3px;
position: relative;
margin-right: 8px;
vertical-align: baseline;
}
& input[type="checkbox"]:checked:enabled,
& input[type="checkbox"]:indeterminate:enabled {
--color: ${vars.primaryBg};
}
& input[type="checkbox"]:disabled {
--color: ${colors.darkGrey};
cursor: not-allowed;
}
& input[type="checkbox"]::before,
& input[type="checkbox"]::after {
content: '';
position: absolute;
top: 0;
left: 0;
height: 16px;
width: 16px;
box-sizing: border-box;
border: 1px solid var(--color, ${colors.darkGrey});
border-radius: var(--radius);
}
& input[type="checkbox"]:checked::before,
& input[type="checkbox"]:disabled::before,
& input[type="checkbox"]:indeterminate::before {
background-color: var(--color);
}
& input[type="checkbox"]:not(:checked):indeterminate::after {
-webkit-mask-image: var(--icon-Minus);
}
& input[type="checkbox"]:not(:disabled)::after {
background-color: ${colors.light};
}
& input[type="checkbox"]:checked::after,
& input[type="checkbox"]:indeterminate::after {
content: '';
position: absolute;
height: 16px;
width: 16px;
-webkit-mask-image: var(--icon-Tick);
-webkit-mask-size: contain;
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
background-color: ${colors.light};
}
& > .${label.className} {
color: ${colors.dark};
font-size: 13px;
font-style: normal;
font-weight: 700;
line-height: 16px; /* 145.455% */
margin-top: 8px;
margin-bottom: 8px;
display: block;
}
`);
export const error = styled('div', `
text-align: center;
color: ${colors.error};
min-height: 22px;
`);
export const toggle = styled('label', `
position: relative;
cursor: pointer;
display: inline-flex;
align-items: center;
& input[type='checkbox'] {
position: absolute;
}
& > span {
margin-left: 8px;
}
`);
export const gristSwitchSlider = styled('div', `
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
border-radius: 17px;
-webkit-transition: .4s;
transition: .4s;
&:hover {
box-shadow: 0 0 1px #2196F3;
}
`);
export const gristSwitchCircle = styled('div', `
position: absolute;
cursor: pointer;
content: "";
height: 13px;
width: 13px;
left: 2px;
bottom: 2px;
background-color: white;
border-radius: 17px;
-webkit-transition: .4s;
transition: .4s;
`);
export const gristSwitch = styled('div', `
position: relative;
width: 30px;
height: 17px;
display: inline-block;
flex: none;
input:checked + & > .${gristSwitchSlider.className} {
background-color: ${vars.primaryBg};
}
input:checked + & > .${gristSwitchCircle.className} {
-webkit-transform: translateX(13px);
-ms-transform: translateX(13px);
transform: translateX(13px);
}
`);
export const checkboxList = styled('div', `
display: flex;
flex-direction: column;
gap: 4px;
`);
export const checkbox = styled('label', `
display: flex;
&:hover {
--color: ${colors.hover};
}
`);
export const select = styled('select', `
padding: 4px 8px;
border-radius: 3px;
border: 1px solid ${colors.darkGrey};
font-size: 13px;
outline-color: ${vars.primaryBg};
outline-width: 1px;
background: white;
line-height: inherit;
height: 27px;
flex: auto;
width: 100%;
`);

@ -1,3 +1,4 @@
import {FormLayoutNode} from 'app/client/components/FormRenderer';
import {buildEditor} from 'app/client/components/Forms/Editor';
import {FieldModel} from 'app/client/components/Forms/Field';
import {buildMenu} from 'app/client/components/Forms/Menu';
@ -6,7 +7,6 @@ import * as style from 'app/client/components/Forms/styles';
import {makeTestId} from 'app/client/lib/domUtils';
import {icon} from 'app/client/ui2018/icons';
import * as menus from 'app/client/ui2018/menus';
import {Box} from 'app/common/Forms';
import {inlineStyle, not} from 'app/common/gutil';
import {bundleChanges, Computed, dom, IDomArgs, MultiHolder, Observable, styled} from 'grainjs';
@ -28,7 +28,7 @@ export class ColumnsModel extends BoxModel {
}
// Dropping a box on this component (Columns) directly will add it as a new column.
public accept(dropped: Box): BoxModel {
public accept(dropped: FormLayoutNode): BoxModel {
if (!this.parent) { throw new Error('No parent'); }
// We need to remove it from the parent, so find it first.
@ -206,7 +206,7 @@ export class PlaceholderModel extends BoxModel {
...args,
);
function insertBox(childBox: Box) {
function insertBox(childBox: FormLayoutNode) {
// Make sure we have at least as many columns as the index we are inserting at.
if (!box.parent) { throw new Error('No parent'); }
return box.parent.replace(box, childBox);
@ -218,15 +218,15 @@ export class PlaceholderModel extends BoxModel {
}
}
export function Paragraph(text: string, alignment?: 'left'|'right'|'center'): Box {
export function Paragraph(text: string, alignment?: 'left'|'right'|'center'): FormLayoutNode {
return {type: 'Paragraph', text, alignment};
}
export function Placeholder(): Box {
export function Placeholder(): FormLayoutNode {
return {type: 'Placeholder'};
}
export function Columns(): Box {
export function Columns(): FormLayoutNode {
return {type: 'Columns', children: [Placeholder(), Placeholder()]};
}

@ -1,3 +1,4 @@
import {CHOOSE_TEXT, FormLayoutNode} from 'app/client/components/FormRenderer';
import {buildEditor} from 'app/client/components/Forms/Editor';
import {FormView} from 'app/client/components/Forms/FormView';
import {BoxModel, ignoreClick} from 'app/client/components/Forms/Model';
@ -7,7 +8,6 @@ import {refRecord} from 'app/client/models/DocModel';
import {autoGrow} from 'app/client/ui/forms';
import {squareCheckbox} from 'app/client/ui2018/checkbox';
import {colors} from 'app/client/ui2018/cssVars';
import {Box, CHOOSE_TEXT} from 'app/common/Forms';
import {Constructor, not} from 'app/common/gutil';
import {
BindableValue,
@ -78,7 +78,7 @@ export class FieldModel extends BoxModel {
return instance;
});
constructor(box: Box, parent: BoxModel | null, view: FormView) {
constructor(box: FormLayoutNode, parent: BoxModel | null, view: FormView) {
super(box, parent, view);
this.required = Computed.create(this, (use) => {

@ -1,6 +1,7 @@
import BaseView from 'app/client/components/BaseView';
import * as commands from 'app/client/components/commands';
import {Cursor} from 'app/client/components/Cursor';
import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer';
import * as components from 'app/client/components/Forms/elements';
import {NewBox} from 'app/client/components/Forms/Menu';
import {BoxModel, LayoutModel, parseBox, Place} from 'app/client/components/Forms/Model';
@ -16,13 +17,13 @@ import DataTableModel from 'app/client/models/DataTableModel';
import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
import {ShareRec} from 'app/client/models/entities/ShareRec';
import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec';
import {urlState} from 'app/client/models/gristUrlState';
import {docUrl, urlState} from 'app/client/models/gristUrlState';
import {SortedRowSet} from 'app/client/models/rowset';
import {showTransientTooltip} from 'app/client/ui/tooltips';
import {cssButton} from 'app/client/ui2018/buttons';
import {icon} from 'app/client/ui2018/icons';
import {confirmModal} from 'app/client/ui2018/modals';
import {Box, BoxType, INITIAL_FIELDS_COUNT} from "app/common/Forms";
import {INITIAL_FIELDS_COUNT} from 'app/common/Forms';
import {Events as BackboneEvents} from 'backbone';
import {Computed, dom, Holder, IDomArgs, MultiHolder, Observable} from 'grainjs';
import defaults from 'lodash/defaults';
@ -47,7 +48,7 @@ export class FormView extends Disposable {
protected menuHolder: Holder<any>;
protected bundle: (clb: () => Promise<void>) => Promise<void>;
private _autoLayout: Computed<Box>;
private _autoLayout: Computed<FormLayoutNode>;
private _root: BoxModel;
private _savedLayout: any;
private _saving: boolean = false;
@ -290,14 +291,14 @@ export class FormView extends Disposable {
// Sanity check that type is correct.
if (!colIds.every(c => typeof c === 'string')) { throw new Error('Invalid column id'); }
this._root.save(async () => {
const boxes: Box[] = [];
const boxes: FormLayoutNode[] = [];
for (const colId of colIds) {
const fieldRef = await this.viewSection.showColumn(colId);
const field = this.viewSection.viewFields().all().find(f => f.getRowId() === fieldRef);
if (!field) { continue; }
const box = {
leaf: fieldRef,
type: 'Field' as BoxType,
type: 'Field' as FormLayoutNodeType,
};
boxes.push(box);
}
@ -333,8 +334,7 @@ export class FormView extends Disposable {
const doc = use(this.gristDoc.docPageModel.currentDoc);
if (!doc) { return ''; }
const url = urlState().makeUrl({
api: true,
doc: doc.id,
...docUrl(doc),
form: {
vsId: use(this.viewSection.id),
},
@ -723,11 +723,11 @@ export class FormView extends Disposable {
* Generates a form template based on the fields in the view section.
*/
private _formTemplate(fields: ViewFieldRec[]) {
const boxes: Box[] = fields.map(f => {
const boxes: FormLayoutNode[] = fields.map(f => {
return {
type: 'Field',
leaf: f.id()
} as Box;
} as FormLayoutNode;
});
const section = {
type: 'Section',

@ -1,4 +1,5 @@
import {allCommands} from 'app/client/components/commands';
import {FormLayoutNodeType} from 'app/client/components/FormRenderer';
import * as components from 'app/client/components/Forms/elements';
import {FormView} from 'app/client/components/Forms/FormView';
import {BoxModel, Place} from 'app/client/components/Forms/Model';
@ -7,14 +8,13 @@ import {FocusLayer} from 'app/client/lib/FocusLayer';
import {makeT} from 'app/client/lib/localization';
import {getColumnTypes as getNewColumnTypes} from 'app/client/ui/GridViewMenus';
import * as menus from 'app/client/ui2018/menus';
import {BoxType} from 'app/common/Forms';
import {Computed, dom, IDomArgs, MultiHolder} from 'grainjs';
const t = makeT('FormView');
const testId = makeTestId('test-forms-menu-');
// New box to add, either a new column of type, an existing column (by column id), or a structure.
export type NewBox = {add: string} | {show: string} | {structure: BoxType};
export type NewBox = {add: string} | {show: string} | {structure: FormLayoutNodeType};
interface Props {
/**
@ -77,7 +77,7 @@ export function buildMenu(props: Props, ...args: IDomArgs<HTMLElement>): IDomArg
box?.view.selectedBox.set(box);
// Same for structure.
const struct = (structure: BoxType) => ({structure});
const struct = (structure: FormLayoutNodeType) => ({structure});
// Actions:

@ -1,6 +1,6 @@
import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer';
import * as elements from 'app/client/components/Forms/elements';
import {FormView} from 'app/client/components/Forms/FormView';
import {Box, BoxType} from 'app/common/Forms';
import {bundleChanges, Computed, Disposable, dom, IDomArgs, MutableObsArray, obsArray, Observable} from 'grainjs';
import {v4 as uuidv4} from 'uuid';
@ -9,7 +9,7 @@ type Callback = () => Promise<void>;
/**
* A place where to insert a box.
*/
export type Place = (box: Box) => BoxModel;
export type Place = (box: FormLayoutNode) => BoxModel;
/**
* View model constructed from a box JSON structure.
@ -19,7 +19,7 @@ export abstract class BoxModel extends Disposable {
/**
* A factory method that creates a new BoxModel from a Box JSON by picking the right class based on the type.
*/
public static new(box: Box, parent: BoxModel | null, view: FormView | null = null): BoxModel {
public static new(box: FormLayoutNode, parent: BoxModel | null, view: FormView | null = null): BoxModel {
const subClassName = `${box.type.split(':')[0]}Model`;
const factories = elements as any;
const factory = factories[subClassName];
@ -42,7 +42,7 @@ export abstract class BoxModel extends Disposable {
* Type of the box. As the type is bounded to the class that is used to render the box, it is possible
* to change the type of the box just by changing this value. The box is then replaced in the parent.
*/
public type: BoxType;
public type: FormLayoutNodeType;
/**
* List of children boxes.
*/
@ -65,7 +65,7 @@ export abstract class BoxModel extends Disposable {
/**
* Don't use it directly, use the BoxModel.new factory method instead.
*/
constructor(box: Box, public parent: BoxModel | null, public view: FormView) {
constructor(box: FormLayoutNode, public parent: BoxModel | null, public view: FormView) {
super();
this.selected = Computed.create(this, (use) => use(view.selectedBox) === this && use(view.viewSection.hasFocus));
@ -149,7 +149,7 @@ export abstract class BoxModel extends Disposable {
* - child: it will add it as a child.
* - swap: swaps with the box
*/
public willAccept(box?: Box|BoxModel|null): 'sibling' | 'child' | 'swap' | null {
public willAccept(box?: FormLayoutNode|BoxModel|null): 'sibling' | 'child' | 'swap' | null {
// If myself and the dropped element share the same parent, and the parent is a column
// element, just swap us.
if (this.parent && box instanceof BoxModel && this.parent === box?.parent && box.parent?.type === 'Columns') {
@ -166,7 +166,7 @@ export abstract class BoxModel extends Disposable {
* Accepts box from clipboard and inserts it before this box or if this is a container box, then
* as a first child. Default implementation is to insert before self.
*/
public accept(dropped: Box, hint: 'above'|'below' = 'above') {
public accept(dropped: FormLayoutNode, hint: 'above'|'below' = 'above') {
// Get the box that was dropped.
if (!dropped) { return null; }
if (dropped.id === this.id) {
@ -200,7 +200,7 @@ export abstract class BoxModel extends Disposable {
/**
* Replaces children at index.
*/
public replaceAtIndex(box: Box, index: number) {
public replaceAtIndex(box: FormLayoutNode, index: number) {
const newOne = BoxModel.new(box, this);
this.children.splice(index, 1, newOne);
return newOne;
@ -216,13 +216,13 @@ export abstract class BoxModel extends Disposable {
this.replace(box2, box1JSON);
}
public append(box: Box) {
public append(box: FormLayoutNode) {
const newOne = BoxModel.new(box, this);
this.children.push(newOne);
return newOne;
}
public insert(box: Box, index: number) {
public insert(box: FormLayoutNode, index: number) {
const newOne = BoxModel.new(box, this);
this.children.splice(index, 0, newOne);
return newOne;
@ -232,7 +232,7 @@ export abstract class BoxModel extends Disposable {
/**
* Replaces existing box with a new one, whenever it is found.
*/
public replace(existing: BoxModel, newOne: Box|BoxModel) {
public replace(existing: BoxModel, newOne: FormLayoutNode|BoxModel) {
const index = this.children.get().indexOf(existing);
if (index < 0) { throw new Error('Cannot replace box that is not in parent'); }
const model = newOne instanceof BoxModel ? newOne : BoxModel.new(newOne, this);
@ -246,20 +246,20 @@ export abstract class BoxModel extends Disposable {
* Creates a place to insert a box before this box.
*/
public placeBeforeFirstChild() {
return (box: Box) => this.insert(box, 0);
return (box: FormLayoutNode) => this.insert(box, 0);
}
// Some other places.
public placeAfterListChild() {
return (box: Box) => this.insert(box, this.children.get().length);
return (box: FormLayoutNode) => this.insert(box, this.children.get().length);
}
public placeAt(index: number) {
return (box: Box) => this.insert(box, index);
return (box: FormLayoutNode) => this.insert(box, index);
}
public placeAfterChild(child: BoxModel) {
return (box: Box) => this.insert(box, this.children.get().indexOf(child) + 1);
return (box: FormLayoutNode) => this.insert(box, this.children.get().indexOf(child) + 1);
}
public placeAfterMe() {
@ -319,7 +319,7 @@ export abstract class BoxModel extends Disposable {
* The core responsibility of this method is to update this box and all children based on the box JSON.
* This is counterpart of the FloatingRowModel, that enables this instance to point to a different box.
*/
public update(boxDef: Box) {
public update(boxDef: FormLayoutNode) {
// If we have a type and the type is changed, then we need to replace the box.
if (this.type && boxDef.type !== this.type) {
if (!this.parent) { throw new Error('Cannot replace detached box'); }
@ -329,7 +329,7 @@ export abstract class BoxModel extends Disposable {
// Update all properties of self.
for (const someKey in boxDef) {
const key = someKey as keyof Box;
const key = someKey as keyof FormLayoutNode;
// Skip some keys.
if (key === 'id' || key === 'type' || key === 'children') { continue; }
// Skip any inherited properties.
@ -365,7 +365,7 @@ export abstract class BoxModel extends Disposable {
/**
* Serialize this box to JSON.
*/
public toJSON(): Box {
public toJSON(): FormLayoutNode {
return {
id: this.id,
type: this.type,
@ -388,7 +388,7 @@ export abstract class BoxModel extends Disposable {
export class LayoutModel extends BoxModel {
constructor(
box: Box,
box: FormLayoutNode,
public parent: BoxModel | null,
public _save: (clb?: Callback) => Promise<void>,
public view: FormView
@ -420,7 +420,7 @@ export function unwrap<T>(val: T | Computed<T>): T {
return val instanceof Computed ? val.get() : val;
}
export function parseBox(text: string): Box|null {
export function parseBox(text: string): FormLayoutNode|null {
try {
const json = JSON.parse(text);
return json && typeof json === 'object' && json.type ? json : null;

@ -1,10 +1,10 @@
import * as style from './styles';
import {FormLayoutNode} from 'app/client/components/FormRenderer';
import {buildEditor} from 'app/client/components/Forms/Editor';
import {FieldModel} from 'app/client/components/Forms/Field';
import {buildMenu} from 'app/client/components/Forms/Menu';
import {BoxModel} from 'app/client/components/Forms/Model';
import * as style from 'app/client/components/Forms/styles';
import {makeTestId} from 'app/client/lib/domUtils';
import {Box} from 'app/common/Forms';
import {dom, styled} from 'grainjs';
const testId = makeTestId('test-forms-');
@ -51,7 +51,7 @@ export class SectionModel extends BoxModel {
* Accepts box from clipboard and inserts it before this box or if this is a container box, then
* as a first child. Default implementation is to insert before self.
*/
public override accept(dropped: Box) {
public override accept(dropped: FormLayoutNode) {
// Get the box that was dropped.
if (!dropped) { return null; }
if (dropped.id === this.id) {

@ -1,5 +1,5 @@
import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer';
import {Columns, Paragraph, Placeholder} from 'app/client/components/Forms/Columns';
import {Box, BoxType} from 'app/common/Forms';
/**
* Add any other element you whish to use in the form here.
* FormView will look for any exported BoxModel derived class in format `type` + `Model`, and use It
@ -12,7 +12,7 @@ export * from './Columns';
export * from './Submit';
export * from './Label';
export function defaultElement(type: BoxType): Box {
export function defaultElement(type: FormLayoutNodeType): FormLayoutNode {
switch(type) {
case 'Columns': return Columns();
case 'Placeholder': return Placeholder();

@ -12,6 +12,7 @@ import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {BulkColValues, fromTableDataAction, RowRecord} from 'app/common/DocActions';
import {extractInfoFromColType, reencodeAsAny} from 'app/common/gristTypes';
import {Theme} from 'app/common/ThemePrefs';
import {getGristConfig} from 'app/common/urlUtils';
import {
AccessTokenOptions, CursorPos, CustomSectionAPI, FetchSelectedOptions, GristDocAPI, GristView,
InteractionOptionsRequest, WidgetAPI, WidgetColumnMap
@ -45,7 +46,7 @@ export interface WidgetFrameOptions {
/**
* Url of external page. Iframe is rebuild each time the URL changes.
*/
url: string;
url: string|null;
/**
* ID of widget, if known. When set, the url for the specified widget
* in the WidgetRepository, if found, will take precedence.
@ -102,6 +103,12 @@ export class WidgetFrame extends DisposableWithEvents {
private _visible = Observable.create(this, !this._options.showAfterReady);
private readonly _widget = Observable.create<ICustomWidget|null>(this, null);
private _url: Observable<string>;
/**
* If the widget URL is empty, it also means that we are showing the empty page.
*/
private _isEmpty: Observable<boolean>;
constructor(private _options: WidgetFrameOptions) {
super();
_options.access = _options.access || AccessLevel.none;
@ -129,6 +136,22 @@ export class WidgetFrame extends DisposableWithEvents {
_options.configure?.(this);
this._checkWidgetRepository().catch(reportError);
// Url if set.
const maybeUrl = Computed.create(this, use => use(this._widget)?.url || this._options.url);
// Url to widget or empty page with access level and preferences.
this._url = Computed.create(this, use => this._urlWithAccess(use(maybeUrl) || this._getEmptyWidgetPage()));
// Iframe is empty when url is not set.
this._isEmpty = Computed.create(this, use => !use(maybeUrl));
// When isEmpty is switched to true, reset the ready state.
this.autoDispose(this._isEmpty.addListener(isEmpty => {
if (isEmpty) {
this._readyCalled.set(false);
}
}));
}
/**
@ -190,30 +213,30 @@ export class WidgetFrame extends DisposableWithEvents {
dom.style('visibility', use => use(this._visible) ? 'visible' : 'hidden'),
dom.cls('clipboard_focus'),
dom.cls('custom_view'),
dom.attr('src', use => this._getUrl(use(this._widget))),
dom.attr('src', this._url),
hooks.iframeAttributes,
testId('ready', this._readyCalled),
testId('ready', use => use(this._readyCalled) && !use(this._isEmpty)),
self => void onElem(self),
);
return this._iframe;
}
private _getUrl(widget: ICustomWidget|null): string {
// Append access level to query string.
const urlWithAccess = (url: string) => {
if (!url) {
return url;
}
const urlObj = new URL(url);
urlObj.searchParams.append('access', this._options.access);
urlObj.searchParams.append('readonly', String(this._options.readonly));
// Append user and document preferences to query string.
const settingsParams = new URLSearchParams(this._options.preferences);
settingsParams.forEach((value, key) => urlObj.searchParams.append(key, value));
return urlObj.href;
};
const url = widget?.url || this._options.url || 'about:blank';
return urlWithAccess(url);
// Appends access level to query string.
private _urlWithAccess(url: string) {
if (!url) {
return url;
}
const urlObj = new URL(url);
urlObj.searchParams.append('access', this._options.access);
urlObj.searchParams.append('readonly', String(this._options.readonly));
// Append user and document preferences to query string.
const settingsParams = new URLSearchParams(this._options.preferences);
settingsParams.forEach((value, key) => urlObj.searchParams.append(key, value));
return urlObj.href;
}
private _getEmptyWidgetPage(): string {
return new URL("custom-widget.html", getGristConfig().homeUrl!).href;
}
private _onMessage(event: MessageEvent) {

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

@ -2,7 +2,7 @@
* dispose.js provides tools to components that needs to dispose of resources, such as
* destroy DOM, and unsubscribe from events. The motivation with examples is presented here:
*
* https://phab.getgrist.com/w/disposal/
* /documentation/disposal/disposal.md
*/
@ -191,7 +191,7 @@ Object.assign(Disposable.prototype, {
}
// Finish by wiping out the object, since nothing should use it after dispose().
// See https://phab.getgrist.com/w/disposal/ for more motivation.
// See /documentation/disposal.md for more motivation.
wipeOutObject(this);
}
});

@ -70,3 +70,57 @@ export function handleFormError(err: unknown, errObs: Observable<string|null>) {
reportError(err as Error|string);
}
}
/**
* A wrapper around FormData that provides type information for fields.
*/
export class TypedFormData {
private _formData: FormData = new FormData(this._formElement);
constructor(private _formElement: HTMLFormElement) {
}
public keys() {
const keys = Array.from(this._formData.keys());
// Don't return keys for scalar values that just return empty strings.
// Otherwise, Grist won't fire trigger formulas.
return keys.filter(key => {
// If there are multiple values, return the key as is.
if (this._formData.getAll(key).length !== 1) { return true; }
// If the value is an empty string or null, don't return the key.
const value = this._formData.get(key);
return value !== '' && value !== null;
});
}
public type(key: string) {
return this._formElement.querySelector(`[name="${key}"]`)?.getAttribute('data-grist-type');
}
public get(key: string) {
const value = this._formData.get(key);
if (value === null) { return null; }
const type = this.type(key);
return type === 'Ref' || type === 'RefList' ? Number(value) : value;
}
public getAll(key: string) {
const values = Array.from(this._formData.getAll(key));
if (['Ref', 'RefList'].includes(String(this.type(key)))) {
return values.map(v => Number(v));
} else {
return values;
}
}
}
/**
* Converts TypedFormData into a JSON mapping of Grist fields.
*/
export function typedFormDataToJson(formData: TypedFormData) {
return Object.fromEntries(Array.from(formData.keys()).map(k =>
k.endsWith('[]') ? [k.slice(0, -2), ['L', ...formData.getAll(k)]] : [k, formData.get(k)]));
}

@ -10,7 +10,7 @@ import {Notifier} from 'app/client/models/NotifyModel';
import {getFlavor, ProductFlavor} from 'app/client/ui/CustomThemes';
import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrades';
import {SupportGristNudge} from 'app/client/ui/SupportGristNudge';
import {attachCssThemeVars, prefersDarkModeObs} from 'app/client/ui2018/cssVars';
import {prefersDarkModeObs} from 'app/client/ui2018/cssVars';
import {AsyncCreate} from 'app/common/AsyncCreate';
import {ICustomWidget} from 'app/common/CustomWidget';
import {OrgUsageSummary} from 'app/common/DocUsage';
@ -28,7 +28,6 @@ import {getGristConfig} from 'app/common/urlUtils';
import {getOrgName, isTemplatesOrg, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
import {getUserPrefObs, getUserPrefsObs, markAsSeen, markAsUnSeen} from 'app/client/models/UserPrefs';
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
import isEqual from 'lodash/isEqual';
const t = makeT('AppModel');
@ -48,7 +47,6 @@ const G = getBrowserGlobals('document', 'window');
// TopAppModel is the part of the app model that persists across org and user switches.
export interface TopAppModel {
options: TopAppModelOptions;
api: UserAPI;
isSingleOrg: boolean;
productFlavor: ProductFlavor;
@ -150,7 +148,7 @@ export interface AppModel {
export interface TopAppModelOptions {
/** Defaults to true. */
attachTheme?: boolean;
useApi?: boolean;
}
export class TopAppModelImpl extends Disposable implements TopAppModel {
@ -170,18 +168,16 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
// up new widgets - that seems ok.
private readonly _widgets: AsyncCreate<ICustomWidget[]>;
constructor(
window: {gristConfig?: GristLoadConfig},
public readonly api: UserAPI = newUserAPIImpl(),
public readonly options: TopAppModelOptions = {}
) {
constructor(window: {gristConfig?: GristLoadConfig},
public readonly api: UserAPI = newUserAPIImpl(),
public readonly options: TopAppModelOptions = {}) {
super();
setErrorNotifier(this.notifier);
this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg);
this.productFlavor = getFlavor(window.gristConfig && window.gristConfig.org);
this._gristConfig = window.gristConfig;
this._widgets = new AsyncCreate<ICustomWidget[]>(async () => {
const widgets = await this.api.getWidgets();
const widgets = this.options.useApi === false ? [] : await this.api.getWidgets();
this.customWidgets.set(widgets);
return widgets;
});
@ -191,7 +187,9 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
this.autoDispose(subscribe(this.currentSubdomain, (use) => this.initialize()));
this.plugins = this._gristConfig?.plugins || [];
this.fetchUsersAndOrgs().catch(reportError);
if (this.options.useApi !== false) {
this.fetchUsersAndOrgs().catch(reportError);
}
}
public initialize(): void {
@ -248,6 +246,10 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
private async _doInitialize() {
this.appObs.set(null);
if (this.options.useApi === false) {
AppModelImpl.create(this.appObs, this, null, null, {error: 'no-api', status: 500});
return;
}
try {
const {user, org, orgError} = await this.api.getSessionActive();
if (this.isDisposed()) { return; }
@ -356,8 +358,6 @@ export class AppModelImpl extends Disposable implements AppModel {
public readonly orgError?: OrgError,
) {
super();
this._setUpTheme();
this._recordSignUpIfIsNewUser();
const state = urlState().state.get();
@ -531,23 +531,6 @@ export class AppModelImpl extends Disposable implements AppModel {
},
);
}
private _setUpTheme() {
if (
this.topAppModel.options.attachTheme === false ||
// Custom CSS is incompatible with custom themes.
getGristConfig().enableCustomCss
) {
return;
}
attachCssThemeVars(this.currentTheme.get());
this.autoDispose(this.currentTheme.addListener((newTheme, oldTheme) => {
if (isEqual(newTheme, oldTheme)) { return; }
attachCssThemeVars(newTheme);
}));
}
}
export function getHomeUrl(): string {

@ -0,0 +1,107 @@
import {FormLayoutNode} from 'app/client/components/FormRenderer';
import {TypedFormData, typedFormDataToJson} from 'app/client/lib/formUtils';
import {makeT} from 'app/client/lib/localization';
import {getHomeUrl} from 'app/client/models/AppModel';
import {urlState} from 'app/client/models/gristUrlState';
import {Form, FormAPI, FormAPIImpl} from 'app/client/ui/FormAPI';
import {ApiError} from 'app/common/ApiError';
import {safeJsonParse} from 'app/common/gutil';
import {bundleChanges, Computed, Disposable, Observable} from 'grainjs';
const t = makeT('FormModel');
export interface FormModel {
readonly form: Observable<Form|null>;
readonly formLayout: Computed<FormLayoutNode|null>;
readonly submitting: Observable<boolean>;
readonly submitted: Observable<boolean>;
readonly error: Observable<string|null>;
fetchForm(): Promise<void>;
submitForm(formData: TypedFormData): Promise<void>;
}
export class FormModelImpl extends Disposable implements FormModel {
public readonly form = Observable.create<Form|null>(this, null);
public readonly formLayout = Computed.create(this, this.form, (_use, form) => {
if (!form) { return null; }
return safeJsonParse(form.formLayoutSpec, null) as FormLayoutNode;
});
public readonly submitting = Observable.create<boolean>(this, false);
public readonly submitted = Observable.create<boolean>(this, false);
public readonly error = Observable.create<string|null>(this, null);
private readonly _formAPI: FormAPI = new FormAPIImpl(getHomeUrl());
constructor() {
super();
}
public async fetchForm(): Promise<void> {
try {
bundleChanges(() => {
this.form.set(null);
this.submitted.set(false);
this.error.set(null);
});
this.form.set(await this._formAPI.getForm(this._getFetchFormParams()));
} catch (e: unknown) {
let error: string | undefined;
if (e instanceof ApiError) {
const code = e.details?.code;
if (code === 'FormNotFound') {
error = t("Oops! The form you're looking for doesn't exist.");
} else if (code === 'FormNotPublished') {
error = t('Oops! This form is no longer published.');
} else if (e.status === 401 || e.status === 403) {
error = t("You don't have access to this form.");
} else if (e.status === 404) {
error = t("Oops! The form you're looking for doesn't exist.");
}
}
this.error.set(error || t('There was a problem loading the form.'));
if (!(e instanceof ApiError && (e.status >= 400 && e.status < 500))) {
// Re-throw if the error wasn't a user error (i.e. a 4XX HTTP response).
throw e;
}
}
}
public async submitForm(formData: TypedFormData): Promise<void> {
const form = this.form.get();
if (!form) { throw new Error('form is not defined'); }
const colValues = typedFormDataToJson(formData);
try {
this.submitting.set(true);
await this._formAPI.createRecord({
...this._getDocIdOrShareKeyParam(),
tableId: form.formTableId,
colValues,
});
} finally {
this.submitting.set(false);
}
}
private _getFetchFormParams() {
const {form} = urlState().state.get();
if (!form) { throw new Error('invalid urlState: undefined "form"'); }
return {...this._getDocIdOrShareKeyParam(), vsId: form.vsId};
}
private _getDocIdOrShareKeyParam() {
const {doc, form} = urlState().state.get();
if (!form) { throw new Error('invalid urlState: undefined "form"'); }
if (doc) {
return {docId: doc};
} else if (form.shareKey) {
return {shareKey: form.shareKey};
} else {
throw new Error('invalid urlState: undefined "doc" or "shareKey"');
}
}
}

@ -47,16 +47,12 @@ let _urlState: UrlState<IGristUrlState>|undefined;
* In addition to setting `doc` and `slug`, it sets additional parameters
* from `params` if any are supplied.
*/
export function docUrl(doc: Document, params: {org?: string} = {}): IGristUrlState {
export function docUrl(doc: Document): IGristUrlState {
const state: IGristUrlState = {
doc: doc.urlId || doc.id,
slug: getSlugIfNeeded(doc),
};
// TODO: Get non-sample documents with `org` set to fully work (a few tests fail).
if (params.org) {
state.org = params.org;
}
return state;
}

@ -17,7 +17,7 @@ import {pagePanels} from 'app/client/ui/PagePanels';
import {RightPanel} from 'app/client/ui/RightPanel';
import {createTopBarDoc, createTopBarHome} from 'app/client/ui/TopBar';
import {WelcomePage} from 'app/client/ui/WelcomePage';
import {testId} from 'app/client/ui2018/cssVars';
import {attachTheme, testId} from 'app/client/ui2018/cssVars';
import {getPageTitleSuffix} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
import {Computed, dom, IDisposable, IDisposableOwner, Observable, replaceContent, subscribe} from 'grainjs';
@ -27,10 +27,14 @@ import {Computed, dom, IDisposable, IDisposableOwner, Observable, replaceContent
// TODO once #newui is gone, we don't need to worry about this being disposable.
// appObj is the App object from app/client/ui/App.ts
export function createAppUI(topAppModel: TopAppModel, appObj: App): IDisposable {
const content = dom.maybe(topAppModel.appObs, (appModel) => [
createMainPage(appModel, appObj),
buildSnackbarDom(appModel.notifier, appModel),
]);
const content = dom.maybeOwned(topAppModel.appObs, (owner, appModel) => {
owner.autoDispose(attachTheme(appModel.currentTheme));
return [
createMainPage(appModel, appObj),
buildSnackbarDom(appModel.notifier, appModel),
];
});
dom.update(document.body, content, {
// Cancel out bootstrap's overrides.
style: 'font-family: inherit; font-size: inherit; line-height: inherit;'

@ -0,0 +1,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};
}
`);

@ -50,7 +50,8 @@ export type Tooltip =
| 'addColumnConditionalStyle'
| 'uuid'
| 'lookups'
| 'formulaColumn';
| 'formulaColumn'
| 'accessRulesTableWide';
export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents;
@ -135,6 +136,9 @@ see or edit which parts of your document.')
),
...args,
),
accessRulesTableWide: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('These rules are applied after all column rules have been processed, if applicable.'))
),
};
export interface BehavioralPromptContent {

@ -133,7 +133,8 @@ export const textButton = styled(gristTextButton, `
`);
export const pageContainer = styled('div', `
min-height: 100%;
height: 100%;
overflow: auto;
background-color: ${theme.loginPageBackdrop};
@media ${mediaXSmall} {

@ -37,7 +37,7 @@ export function buildPinnedDoc(home: HomeModel, doc: Document, workspace: Worksp
pinnedDoc(
isRenaming || doc.removedAt ?
null :
urlState().setLinkUrl(docUrl(doc, isExample ? {org: workspace.orgDomain} : undefined)),
urlState().setLinkUrl({...docUrl(doc), ...(isExample ? {org: workspace.orgDomain} : {})}),
pinnedDoc.cls('-no-access', !roles.canView(doc.access)),
pinnedDocPreview(
(doc.options?.icon ?

@ -40,7 +40,7 @@ function buildTemplateDoc(home: HomeModel, doc: Document, workspace: Workspace,
} else {
return css.docRowWrapper(
cssDocRowLink(
urlState().setLinkUrl(docUrl(doc, {org: workspace.orgDomain})),
urlState().setLinkUrl({...docUrl(doc), org: workspace.orgDomain}),
cssDocName(doc.name, testId('template-doc-title')),
doc.options?.description ? cssDocRowDetails(doc.options.description, testId('template-doc-description')) : null,
),

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

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

@ -4,12 +4,10 @@ import {getLoginUrl, getMainOrgUrl, getSignupUrl, urlState} from 'app/client/mod
import {AppHeader} from 'app/client/ui/AppHeader';
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
import {pagePanels} from 'app/client/ui/PagePanels';
import {setUpPage} from 'app/client/ui/setUpPage';
import {createTopBarHome} from 'app/client/ui/TopBar';
import {bigBasicButtonLink, bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
import {colors, mediaSmall, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {commonUrls, getPageTitleSuffix} from 'app/common/gristUrls';
import {theme, vars} from 'app/client/ui2018/cssVars';
import {getPageTitleSuffix} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
import {dom, DomElementArg, makeTestId, observable, styled} from 'grainjs';
@ -17,21 +15,12 @@ const testId = makeTestId('test-');
const t = makeT('errorPages');
export function setUpErrPage() {
const {errPage} = getGristConfig();
const attachTheme = errPage !== 'form-not-found';
setUpPage((appModel) => {
return createErrPage(appModel);
}, {attachTheme});
}
export function createErrPage(appModel: AppModel) {
const {errMessage, errPage} = getGristConfig();
return errPage === 'signed-out' ? createSignedOutPage(appModel) :
errPage === 'not-found' ? createNotFoundPage(appModel, errMessage) :
errPage === 'access-denied' ? createForbiddenPage(appModel, errMessage) :
errPage === 'account-deleted' ? createAccountDeletedPage(appModel) :
errPage === 'form-not-found' ? createFormNotFoundPage(errMessage) :
createOtherErrorPage(appModel, errMessage);
}
@ -109,43 +98,6 @@ export function createNotFoundPage(appModel: AppModel, message?: string) {
]);
}
/**
* Creates a form-specific "Not Found" page.
*/
export function createFormNotFoundPage(message?: string) {
document.title = t("Form not found");
return cssFormErrorPage(
cssFormErrorContainer(
cssFormError(
cssFormErrorBody(
cssFormErrorImage({src: 'forms/form-not-found.svg'}),
cssFormErrorText(
message ?? t('An unknown error occurred.'),
testId('error-text'),
),
),
cssFormErrorFooter(
cssFormPoweredByGrist(
cssFormPoweredByGristLink(
{href: commonUrls.forms, target: '_blank'},
t('Powered by'),
cssGristLogo(),
)
),
cssFormBuildForm(
cssFormBuildFormLink(
{href: commonUrls.forms, target: '_blank'},
t('Build your own form'),
icon('Expand'),
),
),
),
),
),
);
}
/**
* Creates a generic error page with the given message.
*/
@ -225,110 +177,3 @@ const cssErrorText = styled('div', `
const cssButtonWrap = styled('div', `
margin-bottom: 8px;
`);
const cssFormErrorPage = styled('div', `
background-color: ${colors.lightGrey};
height: 100%;
width: 100%;
padding: 52px 0px 52px 0px;
overflow: auto;
@media ${mediaSmall} {
& {
padding: 20px 0px 20px 0px;
}
}
`);
const cssFormErrorContainer = styled('div', `
padding-left: 16px;
padding-right: 16px;
`);
const cssFormError = styled('div', `
display: flex;
text-align: center;
flex-direction: column;
align-items: center;
background-color: white;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
max-width: 600px;
margin: 0px auto;
`);
const cssFormErrorBody = styled('div', `
padding: 48px 16px 0px 16px;
`);
const cssFormErrorImage = styled('img', `
width: 100%;
height: 100%;
max-width: 250px;
max-height: 281px;
`);
const cssFormErrorText = styled('div', `
font-weight: 600;
font-size: 16px;
line-height: 24px;
margin-top: 32px;
margin-bottom: 24px;
`);
const cssFormErrorFooter = styled('div', `
border-top: 1px solid ${colors.darkGrey};
padding: 8px 16px;
width: 100%;
`);
const cssFormPoweredByGrist = styled('div', `
color: ${colors.darkText};
font-size: 13px;
font-style: normal;
font-weight: 600;
line-height: 16px;
display: flex;
align-items: center;
justify-content: center;
padding: 0px 10px;
`);
const cssFormPoweredByGristLink = styled('a', `
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: ${colors.darkText};
text-decoration: none;
`);
const cssFormBuildForm = styled('div', `
display: flex;
align-items: center;
justify-content: center;
margin-top: 8px;
`);
const cssFormBuildFormLink = styled('a', `
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
line-height: 16px;
text-decoration-line: underline;
color: ${colors.darkGreen};
--icon-color: ${colors.darkGreen};
`);
const cssGristLogo = styled('div', `
width: 58px;
height: 20.416px;
flex-shrink: 0;
background: url(forms/logo.png);
background-position: 0 0;
background-size: contain;
background-color: transparent;
background-repeat: no-repeat;
margin-top: 3px;
`);

@ -1,43 +0,0 @@
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {setupLocale} from 'app/client/lib/localization';
import {AppModel, newUserAPIImpl, TopAppModelImpl} from 'app/client/models/AppModel';
import {setUpErrorHandling} from 'app/client/models/errors';
import {buildSnackbarDom} from 'app/client/ui/NotifyUI';
import {addViewportTag} from 'app/client/ui/viewport';
import {attachCssRootVars} from 'app/client/ui2018/cssVars';
import {BaseAPI} from 'app/common/BaseAPI';
import {dom, DomContents} from 'grainjs';
const G = getBrowserGlobals('document', 'window');
export interface SetUpPageOptions {
/** Defaults to true. */
attachTheme?: boolean;
}
/**
* Sets up error handling and global styles, and replaces the DOM body with
* the result of calling `buildPage`.
*/
export function setUpPage(
buildPage: (appModel: AppModel) => DomContents,
options: SetUpPageOptions = {}
) {
const {attachTheme = true} = options;
setUpErrorHandling();
const topAppModel = TopAppModelImpl.create(null, {}, newUserAPIImpl(), {attachTheme});
attachCssRootVars(topAppModel.productFlavor);
addViewportTag();
void setupLocale();
// Add globals needed by test utils.
G.window.gristApp = {
testNumPendingApiRequests: () => BaseAPI.numPendingRequests(),
};
dom.update(document.body, dom.maybe(topAppModel.appObs, (appModel) => [
buildPage(appModel),
buildSnackbarDom(appModel.notifier, appModel),
]));
}

@ -11,8 +11,11 @@ import {getStorage} from 'app/client/lib/storage';
import {urlState} from 'app/client/models/gristUrlState';
import {getTheme, ProductFlavor} from 'app/client/ui/CustomThemes';
import {Theme, ThemeAppearance} from 'app/common/ThemePrefs';
import {dom, DomElementMethod, makeTestId, Observable, styled, TestId} from 'grainjs';
import {getThemeColors} from 'app/common/Themes';
import {getGristConfig} from 'app/common/urlUtils';
import {Computed, dom, DomElementMethod, makeTestId, Observable, styled, TestId} from 'grainjs';
import debounce = require('lodash/debounce');
import isEqual = require('lodash/isEqual');
import values = require('lodash/values');
const VAR_PREFIX = 'grist';
@ -1021,6 +1024,32 @@ export function prefersDarkModeObs(): PausableObservable<boolean> {
return _prefersDarkModeObs;
}
let _prefersColorSchemeThemeObs: Computed<Theme>|undefined;
/**
* Returns a singleton observable for the Grist theme matching the current
* user agent color scheme preference ("light" or "dark").
*/
export function prefersColorSchemeThemeObs(): Computed<Theme> {
if (!_prefersColorSchemeThemeObs) {
const obs = Computed.create(null, prefersDarkModeObs(), (_use, prefersDarkTheme) => {
if (prefersDarkTheme) {
return {
appearance: 'dark',
colors: getThemeColors('GristDark'),
} as const;
} else {
return {
appearance: 'light',
colors: getThemeColors('GristLight'),
} as const;
}
});
_prefersColorSchemeThemeObs = obs;
}
return _prefersColorSchemeThemeObs;
}
/**
* Attaches the global css properties to the document's root to make them available in the page.
*/
@ -1036,10 +1065,25 @@ export function attachCssRootVars(productFlavor: ProductFlavor, varsOnly: boolea
document.body.classList.add(`interface-${interfaceStyle}`);
}
export function attachTheme(themeObs: Observable<Theme>) {
// Attach the current theme to the DOM.
attachCssThemeVars(themeObs.get());
// Whenever the theme changes, re-attach it to the DOM.
return themeObs.addListener((newTheme, oldTheme) => {
if (isEqual(newTheme, oldTheme)) { return; }
attachCssThemeVars(newTheme);
});
}
/**
* Attaches theme-related css properties to the theme style element.
*/
export function attachCssThemeVars({appearance, colors: themeColors}: Theme) {
function attachCssThemeVars({appearance, colors: themeColors}: Theme) {
// Custom CSS is incompatible with custom themes.
if (getGristConfig().enableCustomCss) { return; }
// Prepare the custom properties needed for applying the theme.
const properties = Object.entries(themeColors)
.map(([name, value]) => `--grist-theme-${name}: ${value};`);

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

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

@ -1,395 +1,4 @@
import {isHiddenCol} from 'app/common/gristTypes';
import {CellValue, GristType} from 'app/plugin/GristData';
import {MaybePromise} from 'app/plugin/gutil';
import _ from 'lodash';
import {marked} from 'marked';
/**
* This file is a part of the Forms project. It contains a logic to render an HTML form from a JSON definition.
* TODO: Client version has its own implementation, we should merge them but it is hard to tell currently
* what are the similarities and differences as a Client code should also support browsing.
*/
/**
* All allowed boxes.
*/
export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit'
| 'Placeholder' | 'Layout' | 'Field' | 'Label'
| 'Separator' | 'Header'
;
/**
* Number of fields to show in the form by default.
*/
export const INITIAL_FIELDS_COUNT = 9;
export const CHOOSE_TEXT = '— Choose —';
/**
* Box model is a JSON that represents a form element. Every element can be converted to this element and every
* ViewModel should be able to read it and built itself from it.
*/
export interface Box {
type: BoxType,
children?: Array<Box>,
// Some properties used by some boxes (like form itself)
submitText?: string,
successURL?: string,
successText?: string,
anotherResponse?: boolean,
// Unique ID of the field, used only in UI.
id?: string,
// Some properties used by fields and stored in the column/field.
formRequired?: boolean,
// Used by Label and Paragraph.
text?: string,
// Used by Paragraph.
alignment?: string,
// Used by Field.
leaf?: number,
}
/**
* When a form is rendered, it is given a context that can be used to access Grist data and sanitize HTML.
*/
export interface RenderContext {
root: Box;
field(id: number): FieldModel;
}
export interface FieldOptions {
formRequired?: boolean;
choices?: string[];
}
export interface FieldModel {
/**
* The question to ask. Fallbacks to column's label than column's id.
*/
question: string;
description: string;
colId: string;
type: string;
isFormula: boolean;
options: FieldOptions;
values(): MaybePromise<[number, CellValue][]>;
}
/**
* The RenderBox is the main building block for the form. Each main block has its own, and is responsible for
* rendering itself and its children.
*/
export class RenderBox {
public static new(box: Box, ctx: RenderContext): RenderBox {
const ctr = elements[box.type] ?? Paragraph;
return new ctr(box, ctx);
}
constructor(protected box: Box, protected ctx: RenderContext) {
}
public async toHTML(): Promise<string> {
const proms = (this.box.children || []).map((child) => RenderBox.new(child, this.ctx).toHTML());
const parts = await Promise.all(proms);
return parts.join('');
}
}
class Label extends RenderBox {
public override async toHTML() {
const text = this.box.text || '';
return `
<div class="grist-label">${text || ''}</div>
`;
}
}
class Paragraph extends RenderBox {
public override async toHTML() {
const text = this.box['text'] || '**Lorem** _ipsum_ dolor';
const alignment = this.box['alignment'] || 'left';
const html = marked(text);
return `
<div class="grist-paragraph grist-text-${alignment}">${html}</div>
`;
}
}
class Section extends RenderBox {
public override async toHTML() {
return `
<div class="grist-section">
${await super.toHTML()}
</div>
`;
}
}
class Columns extends RenderBox {
public override async toHTML() {
const size = this.box.children?.length || 1;
const content = await super.toHTML();
return `
<div class="grist-columns" style='--grist-columns-count: ${size}'>
${content}
</div>
`;
}
}
class Submit extends RenderBox {
public override async toHTML() {
const text = _.escape(this.ctx.root['submitText'] || 'Submit');
return `
<div class='grist-submit'>
<input type='submit' value='${text}' />
</div>
`;
}
}
class Placeholder extends RenderBox {
public override async toHTML() {
return `
<div>
</div>
`;
}
}
class Layout extends RenderBox {
/** Nothing, default is enough */
}
/**
* Field is a special kind of box, as it renders a Grist field (a Question). It provides a default frame, like label and
* description, and then renders the field itself in same way as the main Boxes where rendered.
*/
class Field extends RenderBox {
public build(field: FieldModel, context: RenderContext) {
const ctr = (questions as any)[field.type as any] as { new(): Question } || Text;
return new ctr();
}
public async toHTML() {
const field = this.box.leaf ? this.ctx.field(this.box.leaf) : null;
if (!field) {
return `<div class="grist-field">Field not found</div>`;
}
const renderer = this.build(field, this.ctx);
return `
<div class="grist-field">
${await renderer.toHTML(field, this.ctx)}
</div>
`;
}
}
interface Question {
toHTML(field: FieldModel, context: RenderContext): Promise<string>|string;
}
abstract class BaseQuestion implements Question {
public async toHTML(field: FieldModel, context: RenderContext): Promise<string> {
return `
<div class='grist-question'>
${this.label(field)}
<div class='grist-field-content'>
${await this.input(field, context)}
</div>
</div>
`;
}
public name(field: FieldModel): string {
const excludeFromFormData = (
field.isFormula ||
field.type === 'Attachments' ||
isHiddenCol(field.colId)
);
return `${excludeFromFormData ? '_' : ''}${field.colId}`;
}
public label(field: FieldModel): string {
// This might be HTML.
const label = field.question;
const name = this.name(field);
const required = field.options.formRequired ? 'grist-label-required' : '';
return `
<label class='grist-label ${required}' for='${name}'>${label}</label>
`;
}
public abstract input(field: FieldModel, context: RenderContext): string|Promise<string>;
}
class Text extends BaseQuestion {
public input(field: FieldModel, context: RenderContext): string {
const required = field.options.formRequired ? 'required' : '';
return `
<input type='text' name='${this.name(field)}' ${required}/>
`;
}
}
class Date extends BaseQuestion {
public input(field: FieldModel, context: RenderContext): string {
const required = field.options.formRequired ? 'required' : '';
return `
<input type='date' name='${this.name(field)}' ${required}/>
`;
}
}
class DateTime extends BaseQuestion {
public input(field: FieldModel, context: RenderContext): string {
const required = field.options.formRequired ? 'required' : '';
return `
<input type='datetime-local' name='${this.name(field)}' ${required}/>
`;
}
}
class Choice extends BaseQuestion {
public input(field: FieldModel, context: RenderContext): string {
const required = field.options.formRequired ? 'required' : '';
const choices: Array<string|null> = field.options.choices || [];
// Insert empty option.
choices.unshift(null);
return `
<select name='${this.name(field)}' ${required} >
${choices.map((choice) => `<option value='${choice ?? ''}'>${choice ?? CHOOSE_TEXT}</option>`).join('')}
</select>
`;
}
}
class Bool extends BaseQuestion {
public async toHTML(field: FieldModel, context: RenderContext) {
return `
<div class='grist-question'>
<div class='grist-field-content'>
${this.input(field, context)}
</div>
</div>
`;
}
public input(field: FieldModel, context: RenderContext): string {
const requiredLabel = field.options.formRequired ? 'grist-label-required' : '';
const required = field.options.formRequired ? 'required' : '';
const label = field.question ? field.question : field.colId;
return `
<label class='grist-switch ${requiredLabel}'>
<input type='checkbox' name='${this.name(field)}' value="1" ${required} />
<div class="grist-widget_switch grist-switch_transition">
<div class="grist-switch_slider"></div>
<div class="grist-switch_circle"></div>
</div>
<span>${label}</span>
</label>
`;
}
}
class ChoiceList extends BaseQuestion {
public input(field: FieldModel, context: RenderContext): string {
const required = field.options.formRequired ? 'required' : '';
const choices: string[] = field.options.choices || [];
return `
<div name='${this.name(field)}' class='grist-checkbox-list ${required}'>
${choices.map((choice) => `
<label class='grist-checkbox'>
<input type='checkbox' name='${this.name(field)}[]' value='${choice}' />
<span>
${choice}
</span>
</label>
`).join('')}
</div>
`;
}
}
class RefList extends BaseQuestion {
public async input(field: FieldModel, context: RenderContext) {
const required = field.options.formRequired ? 'required' : '';
const choices: [number, CellValue][] = (await field.values()) ?? [];
// Sort by the second value, which is the display value.
choices.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
// Support for 30 choices, TODO: make it dynamic.
choices.splice(30);
return `
<div name='${this.name(field)}' class='grist-checkbox-list ${required}'>
${choices.map((choice) => `
<label class='grist-checkbox'>
<input type='checkbox'
data-grist-type='${field.type}'
name='${this.name(field)}[]'
value='${String(choice[0])}' />
<span>
${String(choice[1] ?? '')}
</span>
</label>
`).join('')}
</div>
`;
}
}
class Ref extends BaseQuestion {
public async input(field: FieldModel) {
const choices: [number|string, CellValue][] = (await field.values()) ?? [];
// Sort by the second value, which is the display value.
choices.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
// Support for 1000 choices, TODO: make it dynamic.
choices.splice(1000);
// Insert empty option.
choices.unshift(['', CHOOSE_TEXT]);
// <option type='number' is not standard, we parse it ourselves.
const required = field.options.formRequired ? 'required' : '';
return `
<select name='${this.name(field)}' class='grist-ref' data-grist-type='${field.type}' ${required}>
${choices.map((choice) => `<option value='${String(choice[0])}'>${String(choice[1] ?? '')}</option>`).join('')}
</select>
`;
}
}
/**
* List of all available questions we will render of the form.
* TODO: add other renderers.
*/
const questions: Partial<Record<GristType, new () => Question>> = {
'Text': Text,
'Choice': Choice,
'Bool': Bool,
'ChoiceList': ChoiceList,
'Date': Date,
'DateTime': DateTime,
'Ref': Ref,
'RefList': RefList,
};
/**
* List of all available boxes we will render of the form.
*/
const elements = {
'Paragraph': Paragraph,
'Section': Section,
'Columns': Columns,
'Submit': Submit,
'Placeholder': Placeholder,
'Layout': Layout,
'Field': Field,
'Label': Label,
// Those are just aliases for Paragraph.
'Separator': Paragraph,
'Header': Paragraph,
};

@ -149,8 +149,6 @@ export interface IGristUrlState {
// But this barely works, and is suitable only for documents. For decoding it
// indicates that the URL probably points to an API endpoint.
viaShare?: boolean; // Accessing document via a special share.
// Form URLs can currently be encoded but not decoded.
form?: {
vsId: number; // a view section id of a form.
shareKey?: string; // only one of shareKey or doc should be set.
@ -286,31 +284,15 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
if (state.docPage) {
parts.push(`/p/${state.docPage}`);
}
if (state.form) {
parts.push(`/f/${state.form.vsId}`);
}
} else if (state.form?.shareKey) {
parts.push(`forms/${encodeURIComponent(state.form.shareKey)}/${encodeURIComponent(state.form.vsId)}`);
} else if (state.homePage === 'trash' || state.homePage === 'templates') {
parts.push(`p/${state.homePage}`);
}
/**
* Form URLS can take two forms. If a docId/urlId is set, rather than
* a share key, the returned form URL will only be accessible by users
* with access to the document. This is currently only used for the
* preview functionality in the widget, where document access is a
* pre-requisite.
*
* When a share key is set, the returned form URL will be accessible
* by anyone, so long as the form is published.
*
* Only one of `doc` (docId/urlId) or `shareKey` should be set.
*/
if (state.form) {
if (state.doc) { parts.push('/'); }
parts.push('forms/');
if (state.form.shareKey) {
parts.push(state.form.shareKey + '/');
}
parts.push(String(state.form.vsId));
}
if (state.account) {
parts.push(state.account === 'account' ? 'account' : `account/${state.account}`);
}
@ -394,13 +376,21 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
const parts = location.pathname.slice(1).split('/');
const state: IGristUrlState = {};
// Bare minimum we can do to detect API URLs.
if (parts[0] === 'api') { // When it starts with /api/...
parts.shift();
state.api = true;
} else if (parts[0] === 'o' && parts[2] === 'api') { // or with /o/{org}/api/...
parts.splice(2, 1);
// Bare minimum we can do to detect API URLs: if it starts with /api/ or /o/{org}/api/...
if (parts[0] === 'api' || (parts[0] === 'o' && parts[2] === 'api')) {
state.api = true;
parts.splice(parts[0] === 'api' ? 0 : 2, 1);
}
// Bare minimum we can do to detect form URLs with share keys: if it starts with /forms/ or /o/{org}/forms/...
if (parts[0] === 'forms' || (parts[0] === 'o' && parts[2] === 'forms')) {
const startIndex = parts[0] === 'forms' ? 0 : 2;
// Form URLs have two parts to extract: the share key and the view section id.
state.form = {
shareKey: parts[startIndex + 1],
vsId: parseInt(parts[startIndex + 2], 10),
};
parts.splice(startIndex, 3);
}
const map = new Map<string, string>();
@ -449,6 +439,7 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
if (fork.forkId) { state.fork = fork; }
if (map.has('slug')) { state.slug = map.get('slug'); }
if (map.has('p')) { state.docPage = parseDocPage(map.get('p')!); }
if (map.has('f')) { state.form = {vsId: parseInt(map.get('f')!, 10)}; }
} else {
if (map.has('p')) {
const p = map.get('p')!;
@ -964,7 +955,7 @@ export function extractOrgParts(reqHost: string|undefined, reqPath: string): Org
orgFromHost = getOrgFromHost(reqHost);
if (orgFromHost) {
// Some subdomains are shared, and do not reflect the name of an organization.
// See https://phab.getgrist.com/w/hosting/v1/urls/ for a list.
// See /documentation/urls.md for a list.
if (/^(api|v1-.*|doc-worker-.*)$/.test(orgFromHost)) {
orgFromHost = null;
}

@ -31,7 +31,7 @@ const BLACKLISTED_SUBDOMAINS = new Set([
/**
*
* Checks whether the subdomain is on the list of forbidden subdomains.
* See https://phab.getgrist.com/w/hosting/v1/urls/#organization-subdomains
* See /documentation/urls.md#organization-subdomains
*
* Also enforces various sanity checks.
*

@ -70,7 +70,7 @@ export class DocApiForwarder {
app.use('/api/docs/:docId/webhooks', withDoc);
app.use('/api/docs/:docId/assistant', withDoc);
app.use('/api/docs/:docId/sql', withDoc);
app.use('/api/docs/:docId/forms/:id', withDoc);
app.use('/api/docs/:docId/forms/:vsId', withDoc);
app.use('^/api/docs$', withoutDoc);
}

@ -9,7 +9,7 @@ export interface GristTable {
// This is documenting what is currently returned by the core plugins. Capitalization
// is python-style.
//
// TODO: could be worth reconciling with: https://phab.getgrist.com/w/grist_data_format/.
// TODO: could be worth reconciling with: /documentation/grist-data-format.md.
table_name: string | null; // currently allow names to be null
column_metadata: GristColumn[];
table_data: any[][];

@ -214,6 +214,17 @@ export function attachAppEndpoint(options: AttachOptions): void {
plugins
}});
});
// Handlers for form preview URLs: one with a slug and one without.
app.get('/doc/:urlId([^/]+)/f/:vsId', ...docMiddleware, expressWrap(async (req, res) => {
return sendAppPage(req, res, {path: 'form.html', status: 200, config: {}, googleTagManager: 'anon'});
}));
app.get('/:urlId([^-/]{12,})/:slug([^/]+)/f/:vsId', ...docMiddleware, expressWrap(async (req, res) => {
return sendAppPage(req, res, {path: 'form.html', status: 200, config: {}, googleTagManager: 'anon'});
}));
// Handler for form URLs that include a share key.
app.get('/forms/:shareKey([^/]+)/:vsId', ...formMiddleware, expressWrap(async (req, res) => {
return sendAppPage(req, res, {path: 'form.html', status: 200, config: {}, googleTagManager: 'anon'});
}));
// The * is a wildcard in express 4, rather than a regex symbol.
// See https://expressjs.com/en/guide/routing.html
app.get('/doc/:urlId([^/]+):remainder(*)', ...docMiddleware, docHandler);
@ -227,18 +238,4 @@ export function attachAppEndpoint(options: AttachOptions): void {
...docMiddleware, docHandler);
app.get('/:urlId([^-/]{12,})(/:slug([^/]+):remainder(*))?',
...docMiddleware, docHandler);
app.get('/forms/:urlId([^/]+)/:sectionId', ...formMiddleware, expressWrap(async (req, res) => {
const formUrl = gristServer.getHomeUrl(req,
`/api/s/${req.params.urlId}/forms/${req.params.sectionId}`);
const response = await fetch(formUrl, {
headers: getTransitiveHeaders(req),
});
if (response.ok) {
const html = await response.text();
res.send(html);
} else {
const error = await response.json();
throw new ApiError(error?.error ?? 'An unknown error occurred.', response.status, error?.details);
}
}));
}

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

@ -12,9 +12,9 @@ import {
UserAction
} from 'app/common/DocActions';
import {DocData} from 'app/common/DocData';
import {extractTypeFromColType, isRaisedException} from "app/common/gristTypes";
import {Box, BoxType, FieldModel, INITIAL_FIELDS_COUNT, RenderBox, RenderContext} from "app/common/Forms";
import {buildUrlId, commonUrls, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls";
import {extractTypeFromColType, isFullReferencingType, isRaisedException} from "app/common/gristTypes";
import {INITIAL_FIELDS_COUNT} from "app/common/Forms";
import {buildUrlId, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls";
import {isAffirmative, safeJsonParse, timeoutReached} from "app/common/gutil";
import {SchemaTypes} from "app/common/schema";
import {SortFunc} from 'app/common/SortFunc';
@ -64,7 +64,6 @@ import {GristServer} from 'app/server/lib/GristServer';
import {HashUtil} from 'app/server/lib/HashUtil';
import {makeForkIds} from "app/server/lib/idUtils";
import log from 'app/server/lib/log';
import {getAppPathTo} from 'app/server/lib/places';
import {
getDocId,
getDocScope,
@ -86,8 +85,6 @@ import {fetchDoc, globalUploadSet, handleOptionalUpload, handleUpload,
import * as assert from 'assert';
import contentDisposition from 'content-disposition';
import {Application, NextFunction, Request, RequestHandler, Response} from "express";
import * as fse from 'fs-extra';
import * as handlebars from 'handlebars';
import * as _ from "lodash";
import LRUCache from 'lru-cache';
import * as moment from 'moment';
@ -159,18 +156,6 @@ function validateCore(checker: Checker, req: Request, body: any) {
}
}
/**
* Helper used in forms rendering for purifying html.
*/
handlebars.registerHelper('dompurify', (html: string) => {
return new handlebars.SafeString(`
<script data-html="${handlebars.escapeExpression(html)}">
document.write(DOMPurify.sanitize(document.currentScript.getAttribute('data-html')));
document.currentScript.remove(); // remove the script tag so it is easier to inspect the DOM
</script>
`);
});
export class DocWorkerApi {
// Map from docId to number of requests currently being handled for that doc
private _currentUsage = new Map<string, number>();
@ -182,8 +167,7 @@ export class DocWorkerApi {
constructor(private _app: Application, private _docWorker: DocWorker,
private _docWorkerMap: IDocWorkerMap, private _docManager: DocManager,
private _dbManager: HomeDBManager, private _grist: GristServer,
private _staticPath: string) {}
private _dbManager: HomeDBManager, private _grist: GristServer) {}
/**
* Adds endpoints for the doc api.
@ -1388,49 +1372,48 @@ export class DocWorkerApi {
}));
/**
* Get the specified section's form as HTML.
*
* Forms are typically accessed via shares, with URLs like: https://docs.getgrist.com/forms/${shareKey}/${id}.
*
* AppEndpoint.ts handles forwarding of such URLs to this endpoint.
* Get the specified view section's form data.
*/
this._app.get('/api/docs/:docId/forms/:id', canView,
this._app.get('/api/docs/:docId/forms/:vsId', canView,
withDoc(async (activeDoc, req, res) => {
if (!activeDoc.docData) {
throw new ApiError('DocData not available', 500);
}
const sectionId = integerParam(req.params.vsId, 'vsId');
const docSession = docSessionFromRequest(req);
const linkId = getDocSessionShare(docSession);
const sectionId = integerParam(req.params.id, 'id');
if (linkId) {
/* If accessed via a share, the share's `linkId` will be present and
* we'll need to check that the form is in fact published, and that the
* share key is associated with the form, before granting access to the
* form. */
this._assertFormIsPublished({
this._assertIsPublishedForm({
docData: activeDoc.docData,
linkId,
sectionId,
});
}
const Views_section = activeDoc.docData!.getMetaTable('_grist_Views_section');
const Views_section = activeDoc.docData.getMetaTable('_grist_Views_section');
const section = Views_section.getRecord(sectionId);
if (!section) {
throw new ApiError('Form not found', 404);
throw new ApiError('Form not found', 404, {code: 'FormNotFound'});
}
const Tables = activeDoc.docData!.getMetaTable('_grist_Tables');
const tableRecord = Tables.getRecord(section.tableRef);
const Views_section_field = activeDoc.docData!.getMetaTable('_grist_Views_section_field');
const fields = Views_section_field.filterRecords({parentId: sectionId});
const Tables_column = activeDoc.docData!.getMetaTable('_grist_Tables_column');
// Read the box specs
const spec = section.layoutSpec;
let box: Box = safeJsonParse(spec ? String(spec) : '', null);
if (!box) {
const editable = fields.filter(f => {
const Views_section_field = activeDoc.docData.getMetaTable('_grist_Views_section_field');
const Tables_column = activeDoc.docData.getMetaTable('_grist_Tables_column');
const fields = Views_section_field
.filterRecords({parentId: sectionId})
.filter(f => {
const col = Tables_column.getRecord(f.colRef);
// Can't do attachments and formulas.
// Formulas and attachments are currently unsupported.
return col && !(col.isFormula && col.formula) && col.type !== 'Attachment';
});
box = {
let {layoutSpec: formLayoutSpec} = section;
if (!formLayoutSpec) {
formLayoutSpec = JSON.stringify({
type: 'Layout',
children: [
{type: 'Label'},
@ -1440,107 +1423,80 @@ export class DocWorkerApi {
children: [
{type: 'Label'},
{type: 'Label'},
...editable.slice(0, INITIAL_FIELDS_COUNT).map(f => ({
type: 'Field' as BoxType,
leaf: f.id
}))
]
}
...fields.slice(0, INITIAL_FIELDS_COUNT).map(f => ({
type: 'Field',
leaf: f.id,
})),
],
},
],
};
});
}
// Cache the table reads based on tableId. We are caching only the promise, not the result,
// Cache the table reads based on tableId. We are caching only the promise, not the result.
const table = _.memoize(
(tableId: string) => readTable(req, activeDoc, tableId, { }, { }).then(r => asRecords(r))
(tableId: string) => readTable(req, activeDoc, tableId, {}, {}).then(r => asRecords(r))
);
const readValues = async (tId: string, colId: string) => {
const records = await table(tId);
return records.map(r => [r.id as number, r.fields[colId]]);
const getTableValues = async (tableId: string, colId: string) => {
const records = await table(tableId);
return records.map(r => [r.id as number, r.fields[colId]] as const);
};
const refValues = (col: MetaRowRecord<'_grist_Tables_column'>) => {
return async () => {
const refId = col.visibleCol;
if (!refId) { return [] as any; }
const refCol = Tables_column.getRecord(refId);
if (!refCol) { return []; }
const refTable = Tables.getRecord(refCol.parentId);
if (!refTable) { return []; }
const refTableId = refTable.tableId as string;
const refColId = refCol.colId as string;
if (!refTableId || !refColId) { return () => []; }
if (typeof refTableId !== 'string' || typeof refColId !== 'string') { return []; }
return await readValues(refTableId, refColId);
};
};
const Tables = activeDoc.docData.getMetaTable('_grist_Tables');
const context: RenderContext = {
field(fieldRef: number): FieldModel {
const field = Views_section_field.getRecord(fieldRef);
if (!field) { throw new Error(`Field ${fieldRef} not found`); }
const col = Tables_column.getRecord(field.colRef);
if (!col) { throw new Error(`Column ${field.colRef} not found`); }
const fieldOptions = safeJsonParse(field.widgetOptions as string, {});
const colOptions = safeJsonParse(col.widgetOptions as string, {});
const options = {...colOptions, ...fieldOptions};
const type = extractTypeFromColType(col.type as string);
const colId = col.colId as string;
return {
colId,
description: fieldOptions.description || col.description,
question: options.question || col.label || colId,
options,
type,
isFormula: Boolean(col.isFormula && col.formula),
// If this is reference field, we will need to fetch the referenced table.
values: refValues(col)
};
},
root: box
const getRefTableValues = async (col: MetaRowRecord<'_grist_Tables_column'>) => {
const refId = col.visibleCol;
if (!refId) { return [] as any; }
const refCol = Tables_column.getRecord(refId);
if (!refCol) { return []; }
const refTable = Tables.getRecord(refCol.parentId);
if (!refTable) { return []; }
const refTableId = refTable.tableId as string;
const refColId = refCol.colId as string;
if (!refTableId || !refColId) { return () => []; }
if (typeof refTableId !== 'string' || typeof refColId !== 'string') { return []; }
return await getTableValues(refTableId, refColId);
};
// Now render the box to HTML.
const formFields = await Promise.all(fields.map(async (field) => {
const col = Tables_column.getRecord(field.colRef);
if (!col) { throw new Error(`Column ${field.colRef} not found`); }
const fieldOptions = safeJsonParse(field.widgetOptions as string, {});
const colOptions = safeJsonParse(col.widgetOptions as string, {});
const options = {...colOptions, ...fieldOptions};
const type = extractTypeFromColType(col.type as string);
const colId = col.colId as string;
return [field.id, {
colId,
description: fieldOptions.description || col.description,
question: options.question || col.label || colId,
options,
type,
refValues: isFullReferencingType(col.type) ? await getRefTableValues(col) : null,
}] as const;
}));
const formFieldsById = Object.fromEntries(formFields);
const getTableName = () => {
const rawSectionRef = Tables.getRecord(section.tableRef)?.rawViewSectionRef;
if (!rawSectionRef) { return null; }
const rawSection = activeDoc.docData!
.getMetaTable('_grist_Views_section')
.getRecord(rawSectionRef);
return rawSection?.title ?? null;
};
let redirectUrl = !box.successURL ? '' : box.successURL;
// Make sure it is a valid URL.
try {
new URL(redirectUrl);
} catch (e) {
redirectUrl = '';
}
const formTableId = await getRealTableId(String(section.tableRef), {activeDoc, req});
const formTitle = section.title || getTableName() || formTableId;
const html = await RenderBox.new(box, context).toHTML();
// And wrap it with the form template.
const form = await fse.readFile(path.join(getAppPathTo(this._staticPath, 'static'),
'forms/form.html'), 'utf8');
const staticOrigin = process.env.APP_STATIC_URL || "";
const staticBaseUrl = `${staticOrigin}/v/${this._grist.getTag()}/`;
// Fill out the blanks and send the result.
const doc = await this._dbManager.getDoc(req);
const tableId = await getRealTableId(String(section.tableRef), {activeDoc, req});
const rawSectionRef = tableRecord?.rawViewSectionRef;
const rawSection = !rawSectionRef ? null :
activeDoc.docData!.getMetaTable('_grist_Views_section').getRecord(rawSectionRef);
const tableName = rawSection?.title;
const template = handlebars.compile(form);
const renderedHtml = template({
// Trusted content generated by us.
BASE: staticBaseUrl,
DOC_URL: await this._grist.getResourceUrl(doc, 'html'),
TABLE_ID: tableId,
ANOTHER_RESPONSE: Boolean(box.anotherResponse),
// Not trusted content entered by user.
CONTENT: html,
SUCCESS_TEXT: box.successText || 'Thank you! Your response has been recorded.',
SUCCESS_URL: redirectUrl,
TITLE: `${section.title || tableName || tableId || 'Form'} - Grist`,
FORMS_LANDING_PAGE_URL: commonUrls.forms,
});
this._grist.getTelemetry().logEvent(req, 'visitedForm', {
full: {
docIdDigest: activeDoc.docName,
@ -1548,55 +1504,52 @@ export class DocWorkerApi {
altSessionId: req.altSessionId,
},
});
res.status(200).send(renderedHtml);
res.status(200).json({
formFieldsById,
formLayoutSpec,
formTableId,
formTitle,
});
})
);
}
/**
* Throws if the specified section is not of a published form.
* Throws if the specified section is not a published form.
*/
private _assertFormIsPublished(params: {
docData: DocData | null,
private _assertIsPublishedForm(params: {
docData: DocData,
linkId: string,
sectionId: number,
}) {
const {docData, linkId, sectionId} = params;
if (!docData) {
throw new ApiError('DocData not available', 500);
}
const notFoundError = () => {
throw new ApiError("Oops! The form you're looking for doesn't exist.", 404, {
code: 'FormNotFound',
});
};
// Check that the request is for a valid section in the document.
const sections = docData.getMetaTable('_grist_Views_section');
const section = sections.getRecord(sectionId);
if (!section) { return notFoundError(); }
if (!section) { throw new ApiError('Form not found', 404, {code: 'FormNotFound'}); }
// Check that the section is for a form.
const sectionShareOptions = safeJsonParse(section.shareOptions, {});
if (!sectionShareOptions.form) { return notFoundError(); }
if (!sectionShareOptions.form) { throw new ApiError('Form not found', 404, {code: 'FormNotFound'}); }
// Check that the form is associated with a share.
const viewId = section.parentId;
const pages = docData.getMetaTable('_grist_Pages');
const page = pages.getRecords().find(p => p.viewRef === viewId);
if (!page) { return notFoundError(); }
if (!page) { throw new ApiError('Form not found', 404, {code: 'FormNotFound'}); }
const shares = docData.getMetaTable('_grist_Shares');
const share = shares.getRecord(page.shareRef);
if (!share) { return notFoundError(); }
if (!share) { throw new ApiError('Form not found', 404, {code: 'FormNotFound'}); }
// Check that the share's link id matches the expected link id.
if (share.linkId !== linkId) { return notFoundError(); }
if (share.linkId !== linkId) { throw new ApiError('Form not found', 404, {code: 'FormNotFound'}); }
// Finally, check that both the section and share are published.
if (!sectionShareOptions.publish || !safeJsonParse(share.options, {})?.publish) {
throw new ApiError('Oops! This form is no longer published.', 404, {code: 'FormNotFound'});
throw new ApiError('Form not published', 404, {code: 'FormNotPublished'});
}
}
@ -2140,9 +2093,9 @@ export class DocWorkerApi {
export function addDocApiRoutes(
app: Application, docWorker: DocWorker, docWorkerMap: IDocWorkerMap, docManager: DocManager, dbManager: HomeDBManager,
grist: GristServer, staticPath: string
grist: GristServer
) {
const api = new DocWorkerApi(app, docWorker, docWorkerMap, docManager, dbManager, grist, staticPath);
const api = new DocWorkerApi(app, docWorker, docWorkerMap, docManager, dbManager, grist);
api.addEndpoints();
}

@ -28,6 +28,7 @@ import {appSettings} from 'app/server/lib/AppSettings';
import {addRequestUser, getTransitiveHeaders, getUser, getUserId, isAnonymousUser,
isSingleUserMode, redirectToLoginUnconditionally} from 'app/server/lib/Authorizer';
import {redirectToLogin, RequestWithLogin, signInStatusMiddleware} from 'app/server/lib/Authorizer';
import {BootProbes} from 'app/server/lib/BootProbes';
import {forceSessionChange} from 'app/server/lib/BrowserSession';
import {Comm} from 'app/server/lib/Comm';
import {create} from 'app/server/lib/create';
@ -175,6 +176,7 @@ export class FlexServer implements GristServer {
private _getLoginSystem?: () => Promise<GristLoginSystem>;
// Set once ready() is called
private _isReady: boolean = false;
private _probes: BootProbes;
constructor(public port: number, public name: string = 'flexServer',
public readonly options: FlexServerOptions = {}) {
@ -481,6 +483,57 @@ export class FlexServer implements GristServer {
});
}
/**
*
* Adds a /boot/$GRIST_BOOT_KEY page that shows diagnostics.
* Accepts any /boot/... URL in order to let the front end
* give some guidance if the user is stumbling around trying
* to find the boot page, but won't actually provide diagnostics
* unless GRIST_BOOT_KEY is set in the environment, and is present
* in the URL.
*
* We take some steps to make the boot page available even when
* things are going wrong, and should take more in future.
*
* When rendering the page a hardcoded 'boot' tag is used, which
* is used to ensure that static assets are served locally and
* we aren't relying on APP_STATIC_URL being set correctly.
*
* We use a boot key so that it is more acceptable to have this
* boot page living outside of the authentication system, which
* could be broken.
*
* TODO: there are some configuration problems that currently
* result in Grist not running at all. ideally they would result in
* Grist running in a limited mode that is enough to bring up the boot
* page.
*
*/
public addBootPage() {
if (this._check('boot')) { return; }
const bootKey = appSettings.section('boot').flag('key').readString({
envVar: 'GRIST_BOOT_KEY'
});
const base = `/boot/${bootKey}`;
this._probes = new BootProbes(this.app, this, base);
// Respond to /boot, /boot/, /boot/KEY, /boot/KEY/ to give
// a helpful message even if user gets KEY wrong or omits it.
this.app.get('/boot(/(:bootKey/?)?)?$', async (req, res) => {
const goodKey = bootKey && req.params.bootKey === bootKey;
return this._sendAppPage(req, res, {
path: 'boot.html', status: 200, config: goodKey ? {
} : {
errMessage: 'not-the-key',
}, tag: 'boot',
});
});
this._probes.addEndpoints();
}
public hasBoot(): boolean {
return Boolean(this._probes);
}
public denyRequestsIfNotReady() {
this.app.use((_req, res, next) => {
if (!this._isReady) {
@ -1284,7 +1337,7 @@ export class FlexServer implements GristServer {
this._addSupportPaths(docAccessMiddleware);
if (!isSingleUserMode()) {
addDocApiRoutes(this.app, docWorker, this._docWorkerMap, docManager, this._dbManager, this, this.appRoot);
addDocApiRoutes(this.app, docWorker, this._docWorkerMap, docManager, this._dbManager, this);
}
}
@ -1513,7 +1566,6 @@ export class FlexServer implements GristServer {
if (resp.headersSent || !this._sendAppPage) { return next(err); }
try {
const errPage = (
err.details?.code === 'FormNotFound' ? 'form-not-found' :
err.status === 403 ? 'access-denied' :
err.status === 404 ? 'not-found' :
'other-error'

@ -60,6 +60,7 @@ export interface GristServer {
getPlugins(): LocalPlugin[];
servesPlugins(): boolean;
getBundledWidgets(): ICustomWidget[];
hasBoot(): boolean;
}
export interface GristLoginSystem {
@ -147,6 +148,7 @@ export function createDummyGristServer(): GristServer {
servesPlugins() { return false; },
getPlugins() { return []; },
getBundledWidgets() { return []; },
hasBoot() { return false; },
};
}

@ -148,8 +148,11 @@ export function makeSendAppPage(opts: {
const needTagManager = (options.googleTagManager === 'anon' && isAnonymousUser(req)) ||
options.googleTagManager === true;
const tagManagerSnippet = needTagManager ? getTagManagerSnippet(process.env.GOOGLE_TAG_MANAGER_ID) : '';
const staticOrigin = process.env.APP_STATIC_URL || "";
const staticBaseUrl = `${staticOrigin}/v/${options.tag || tag}/`;
const staticTag = options.tag || tag;
// If boot tag is used, serve assets locally, otherwise respect
// APP_STATIC_URL.
const staticOrigin = staticTag === 'boot' ? '' : (process.env.APP_STATIC_URL || '');
const staticBaseUrl = `${staticOrigin}/v/${staticTag}/`;
const customHeadHtmlSnippet = server.create.getExtraHeadHtml?.() ?? "";
const warning = testLogin ? "<div class=\"dev_warning\">Authentication is not enforced</div>" : "";
// Preload all languages that will be used or are requested by client.

@ -104,6 +104,9 @@ export async function main(port: number, serverTypes: ServerType[],
}
server.addHealthCheck();
if (includeHome || includeApp) {
server.addBootPage();
}
server.denyRequestsIfNotReady();
if (includeHome || includeStatic || includeApp) {

@ -14,7 +14,9 @@ module.exports = {
main: "app/client/app",
errorPages: "app/client/errorMain",
apiconsole: "app/client/apiconsole",
boot: "app/client/boot",
billing: "app/client/billingMain",
form: "app/client/formMain",
// Include client test harness if it is present (it won't be in
// docker image).
...(fs.existsSync("test/client-harness/client.js") ? {

@ -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,218 @@
# Grist Data Format
Grist Data Format is used to send and receive data from a Grist document. For example, an implementer of an import module would need to translate data to Grist Data Format. A user of Grist Basket APIs would fetch and upload data in Grist Data Format.
The format is optimized for tabular data. A table consists of rows and columns, containing a single value for each row for each column. Various types are supported for the values.
Each column has a name and a type. The type is not strict: a column may contain values of other types. However, the type is the intended type of the value for that column, and allows those values to be represented more efficiently.
Grist Data Format is readily serialized to JSON. Other serializations are possible, for example, see below for a .proto file that allows to serialize Grist Data Format as a protocol buffer.
## Format Specification
### Document
At the top, Grist Data Format is a Document object with a single key “tables” mapping to an array of Tables:
```javascript
{
tables: [Tables…]
}
```
### Table
```javascript
{
name: "TableName",
colinfo: [ColInfo…],
columns: ColData
}
```
The `name` is the name of the table. The `colinfo` array has an item to describe each column, and `columns` is the actual table data in column-oriented layout.
### ColInfo
```javascript
{
name: "ColName",
type: "ColType",
options: <arbitrary options>
}
```
The `name` is the name of the column, and `type` is its type. The field `options` optionally specifies type-specific options that affect the column (e.g. the number of decimal places to display for a floating-point number).
### ColData
```javascript
{
<colName1>: ColValues,
<colName2>: ColValues,
...
}
```
The data in the table is represented as an object mapping a column name to an array of values for the column. This column-oriented representation allows for the representation of data to be more concise.
### ColValues
```javascript
[CellValue, CellValue, ...]
```
ColValues is an array of all values for the column. We'll refer to the type of each value as `CellValue`. ColValues has an entry for each row in the table. In particular, each ColValues array in a ColData object has the same number of entries.
### CellValue
CellValue represents the value in one cell. We support various types of values, documented below. When represented as JSON, CellValue is one of the following JSON types:
- string
- number
- bool
- null
- array of the form `[typeCode, args...]`
The interpretation of CellValue is affected by the columns type, and described in more detail below.
## JSON Schema
The description above can be summarized by this JSON Schema:
```json
{
"definitions": {
"Table": {
"type": "object",
"properties": {
"name": { "type": "string" },
"colinfo": { "type": "array", "items": { "$ref": "#/definitions/ColInfo" } }
"columns": { "$ref": "#/definitions/ColData" }
}
},
"ColInfo": {
"type": "object",
"properties": {
"name": { "type": "string" },
"type": { "type": "string" },
"options": { "type": "object" }
}
},
"ColData": {
"type": "object",
"additionalProperties": { "$ref": "#/definitions/ColValues" }
},
"ColValues": {
"type": "array",
"items": { "type": "CellValue" }
}
},
"type": "object",
"properties": {
"tables": { "type": "array", "items": { "$ref": "#/definitions/Table" } }
}
}
```
## Record identifiers
Each table should have a column named `id`, whose values should be unique across the table. It is used to identify records in queries and actions. Its details, including its type, are left for now outside the scope of this specification, because the format isn't affected by them.
## Naming
Names for tables and columns must consist of alphanumeric ASCII characters or underscore (i.e. `[0-9a-zA-Z_]`). They may not start with an underscore or a digit. Different tables and different columns within a table must have unique names case-insensitively (i.e. they cannot differ in case only).
Certain names (`id` being one of them) may be reserved, e.g. by Grist, for internal purposes, and would not be usable for user data. Such restrictions are outside the scope of this specification.
Note that this combination of rules allows tables and column names to be valid identifiers in pretty much every programming language (including Python and Javascript), as well as valid names of columns in databases.
## Value Types
The format supports a number of data types. Some types have a short representation (e.g. `Numeric` as a JSON `number`, and `Text` as a JSON `string`), but all types have an explicit representation as well.
The explicit representation of a value is an array `[typeCode, args...]`. The first member of the array is a string code that defines the type of the value. The rest of the elements are arguments used to construct the actual value.
The following table lists currently supported types and their short and explicit representations.
| **Type Name** | **Short Repr** | **[Type Code, Args...]** | **Description** |
| `Numeric` | `number`* | `['n',number]` | double-precision floating point number |
| `Text` | `string`* | `['s',string]` | Unicode string |
| `Bool` | `bool`* | `['b',bool]` | Boolean value (true or false) |
| `Null` | `null`* | `null` | Null value (no special explicit representation) |
| `Int` | `number` | `['i',number]` | 32-bit integer |
| `Date` | `number` | `['d',number]` | Calendar date, represented as seconds since Epoch to 00:00 UTC on that date. |
| `DateTime` | `number` | `['D',number]` | Instance in time, represented as seconds since Epoch |
| `Reference` | `number` | `['R',number]` | Identifier of a record in a table. |
| `ReferenceList` | | `['L',number,...]` | List of record identifiers |
| `Choice` | `string` | `['C',string]` | Unicode string selected from a list of choices. |
| `PositionNumber` | `number` | `['P',number]` | a double used to order records relative to each other. |
| `Image` | | `['I',string]` | Binary data representing an image, encoded as base64 |
| `List` | | `['l',values,...]` | List of values of any type. |
| `JSON` | | `['J',object]` | JSON-serializable object |
| `Error` | | `['E',string,string?,value?]` | Exception, with first argument exception type, second an optional message, and optionally a third containing additional info. |
An important goal is to represent data efficiently in the common case. When a value matches the column's type, the short representation is used. For example, in a Numeric column, a Numeric value is represented as a `number`, and in a Date column, a Date value is represented as a `number`.
If a value does not match the column's type, then the short representation is used when it's one of the starred types in the table AND the short type is different from the column's short type.
For example:
- In a Numeric column, Numeric is `number`, Text is `string` (being a starred type), but a Date is `['d',number]`.
- In a Date column, Date is `number`, and Numeric value is `['n',number]`, because even though it's starred, it conflicts with Date's own short type.
- In a Text column, Text is `string`, Numeric is `number` (starred), and Date is `['d',number]` (not starred).
Note how for the common case of a value matching the column's type, we can always use the short representation. But the format still allows values to have an explicit type that's different from the specified one.
Note also that columns of any of the starred types use the same interpretation for contained values.
The primary use case is to allow, for example, storing a value like "N/A" or "TBD" or "Ask Bob" in a column of type Numeric or Date. Another important case is to store errors produced by a computation.
Other complex types may be added in the future.
## Column Types
Any of the types listed in the table above may be specified as a column type.
In addition, a column type may specify type `Any`. For the purpose of type interpretations, it works the same as any of the starred types, but it does not convey anything about the expected type of value for the column.
## Other serializations
Grist Data Format is naturally serialized to JSON, which is fast and convenient to use in Javascript code. It is also possible to serialize it in other ways, e.g. as a Google protobuf.
Here is a `.proto` definition file that allows for efficient protobuf representation of data in Grist Data Format.
```proto
message Document {
repeated Table tables = 1;
}
message Table {
string name = 1;
repeated ColInfo colinfo = 2;
repeated ColData columns = 3;
}
message ColInfo {
string name = 1;
string type = 2;
string options = 3;
}
message ColData {
repeated Value value = 1;
}
message Value {
oneof value {
double vNumeric = 1;
string vText = 2;
bool vBool = 3;
// Absence of a set field represents a null
int32 vInt = 5;
double vDate = 6;
double vDateTime = 7;
int32 vReference = 8;
List vReferenceList = 9;
string vChoice = 10;
double vPositionNumber = 11;
bytes vImage = 12;
List vList = 13;
string vJSON = 14;
List vError = 15;
}
}
message ValueList {
repeated Value value = 1;
}
```

@ -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,53 @@
Document URLs
-----------------
Status: WIP
Options
* An id (e.g. google)
* Several ids (e.g. airtable)
* A text name
* Several text names (e.g. github)
* An id and friendly name (e.g. dropbox)
Leaning towards an id and friendly name. Only id is interpreted by router. Name is checked only to make sure it matches current name of document. If not, we redirect to revised url before proceeding.
Length of ids depends on whether we'll be using them for obscurity to enable anyone-who-has-link-can-view style security.
Possible URLs
---------------
* docs.getgrist.com/viwpHfmtMHmKBUSyh/Document+Name
* orgname.getgrist.com/viwpHfmtMHmKBUSyh/Document+Name
* getgrist.com/d/viwpHfmtMHmKBUSyh/Document+Name
* getgrist.com/d/tblWVZDtvlsIFsuOR/viwpHfmtMHmKBUSyh/Document+Name
* getgrist.com/d/dd5bf494e709246c7601e27722e3aee656b900082c3f5f1598ae1475c35c2c4b/Document+Name
* getgrist.com/doc/fTSIMrZT3fDTvW7XDBq1b7nhWa24Zl55EVpsaO3TBBE/Document%20Name
Organization subdomains
------------------------------
Organizations get to choose a subdomain, and will access their workspaces and documents at `orgname.getgrist.com`. In addition, personal workspaces need to be uniquely determined by a URL, using `docs-` followed by the numeric id of the "personal organization":
* docs-1234.getgrist.com/
* docs.getgrist.com/o/docs-1234/
Since subdomains need to play along with all the other subdomains we use for getgrist.com, the following is a list of names that may NOT be used by any organization:
* `docs-\d+` to identify personal workspaces
* Anything that starts with underscore (`_`) (this includes special subdomains like `_domainkey`)
* Subdomains used by us for various purposes. As of 2018-10-09, these include:
* aws
* gristlogin
* issues
* metrics
* phab
* releases
* test
* vpn
* www
Some more reserved subdomains:
* doc-worker-NN
* v1-* (this could be released eventually, but currently in our code and/or routing "v1-mock", "v1-docs", "v1-static", and any other "v1-*" are special
* docs
* api

@ -4,7 +4,7 @@ create tables, add and remove columns, etc, Grist stores various document metada
users' tables, views, etc.) also in tables.
Before changing this file, please review:
https://phab.getgrist.com/w/migrations/
/documentation/migrations.md
"""

@ -2165,11 +2165,7 @@ class UserActions(object):
title = ''
section = self._docmodel.add(view_sections, tableRef=tableRef, parentKey=section_type,
title=title, borderWidth=1, defaultWidth=100)[0]
# TODO: We should address the automatic selection of fields for charts
# and forms in a better way.
limit = 2 if section_type == 'chart' else 9 if section_type == 'form' else None
self._RebuildViewFields(tableId, section.id,
limit=limit)
self._RebuildViewFields(tableId, section.id)
return section
def _create_record_card_view_section(self, tableRef, tableId, view_sections):
@ -2277,8 +2273,7 @@ class UserActions(object):
parentKey=view_section_type, title=title,
borderWidth=1, defaultWidth=100,
sortColRefs='[]')[0]
self._RebuildViewFields(table_id, section.id,
limit=(2 if view_section_type == 'chart' else None))
self._RebuildViewFields(table_id, section.id)
return {"id": section.id}
# TODO: Deprecated; should just use RemoveRecord('_grist_Views_section', view_id)
@ -2294,7 +2289,7 @@ class UserActions(object):
# Methods for creating and maintaining default views. This is a work-in-progress.
#--------------------------------------------------------------------------------
def _RebuildViewFields(self, table_id, section_row_id, limit=None):
def _RebuildViewFields(self, table_id, section_row_id):
"""
Does the actual work of rebuilding ViewFields to correspond to the table's columns.
"""
@ -2305,7 +2300,8 @@ class UserActions(object):
if section_rec.fields:
self._docmodel.remove(section_rec.fields)
is_card = section_rec.parentKey in ('single', 'detail')
section_type = section_rec.parentKey
is_card = section_type in ('single', 'detail')
is_record_card = section_rec == table_rec.recordCardViewSectionRef
if is_card and not is_record_card:
# Copy settings from the table's record card section to the new section.
@ -2317,6 +2313,14 @@ class UserActions(object):
cols = [c for c in table_rec.columns if column.is_visible_column(c.colId)
# TODO: hack to avoid auto-adding the 'group' column when detaching summary tables.
and c.colId != 'group']
limit = None
if section_type == 'chart':
# TODO: We should address the automatic selection of fields for charts in a better way.
limit = 2
elif section_type == 'form':
# Attachments and formulas are currently unsupported in forms.
cols = [c for c in cols if not (c.type == 'Attachments' or (c.isFormula and c.formula))]
limit = 9
cols.sort(key=lambda c: c.parentPos)
if limit is not None:
cols = cols[:limit]

@ -0,0 +1,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);
})();
}

File diff suppressed because one or more lines are too long

@ -0,0 +1,128 @@
<svg width="225" height="138" viewBox="0 0 225 138" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1161_12522)">
<path
d="M175.131 97.5045C175.43 97.2931 175.675 97.0226 175.851 96.7122C176.027 96.4017 176.128 96.0588 176.147 95.7077C176.166 95.3565 176.104 95.0056 175.963 94.6797C175.823 94.3538 175.608 94.0608 175.335 93.8214L179 86L174.343 86.7421L171.577 94.0557C171.179 94.5149 170.975 95.0953 171.002 95.6867C171.03 96.2782 171.287 96.8396 171.725 97.2647C172.163 97.6898 172.752 97.9491 173.379 97.9932C174.007 98.0374 174.63 97.8635 175.131 97.5045Z"
fill="#FFF3DE" />
<path
d="M182 63.2566L180.701 63C180.701 63 178.883 63.7699 179.143 66.0796C179.403 68.3894 175.506 80.708 175.506 80.708L172 90.9734L176.935 92L180.182 80.1947L182 63.2566Z"
fill="#D9D9D9" />
<path
d="M187.924 102H160.076C159.526 101.999 158.998 101.775 158.609 101.377C158.22 100.979 158.001 100.438 158 99.875V97.125C158.001 96.5616 158.22 96.0215 158.609 95.6231C158.998 95.2247 159.526 95.0006 160.076 95H187.924C188.474 95.0007 189.002 95.2248 189.391 95.6231C189.78 96.0215 189.999 96.5616 190 97.125V99.875C189.999 100.438 189.78 100.978 189.391 101.377C189.002 101.775 188.474 101.999 187.924 102Z"
fill="#16B378" />
<path d="M186 130.952L183.241 132L178 121.547L182.072 120L186 130.952Z" fill="#FFF3DE" />
<path
d="M180.261 131.792L185.44 129.874L186.821 133.628L178.381 136.754C178.2 136.261 178.104 135.742 178.101 135.225C178.097 134.709 178.185 134.207 178.36 133.746C178.534 133.285 178.792 132.875 179.118 132.54C179.444 132.205 179.833 131.951 180.261 131.792Z"
fill="#494949" />
<path d="M190.526 136L187.815 135.861L187 124L191 124.205L190.526 136Z" fill="#FFF3DE" />
<path
d="M185.477 134H191V138H182C182 137.475 182.09 136.955 182.265 136.469C182.439 135.984 182.696 135.543 183.018 135.172C183.341 134.8 183.725 134.505 184.147 134.304C184.568 134.103 185.021 134 185.477 134Z"
fill="#494949" />
<path
d="M177.866 88.5146C175.543 91.1819 174.178 94.555 173.988 98.0942L173 116.561L181.707 132L186.573 129.427L180.683 117.848L184.268 106.784L194 99.0643L193.232 88L177.866 88.5146Z"
fill="#494949" />
<path d="M191.368 98L194 99.0142L191.895 134L185.842 133.24L184 106.62L191.368 98Z"
fill="#494949" />
<path
d="M186.041 94.2158C186.103 93.8788 186.24 93.5591 186.443 93.2793C186.646 92.9994 186.91 92.7662 187.215 92.5961C187.521 92.4259 187.862 92.323 188.213 92.2945C188.564 92.266 188.917 92.3127 189.248 92.4313L194.684 86L196 90.1311L190.636 95.605C190.394 96.1314 189.962 96.5533 189.422 96.7908C188.881 97.0283 188.27 97.0649 187.703 96.8937C187.137 96.7225 186.655 96.3553 186.348 95.8617C186.042 95.3682 185.932 94.7825 186.041 94.2158Z"
fill="#FFF3DE" />
<path
d="M188 59C191.314 59 194 56.3137 194 53C194 49.6863 191.314 47 188 47C184.686 47 182 49.6863 182 53C182 56.3137 184.686 59 188 59Z"
fill="#FFF3DE" />
<path
d="M196.721 64.3993C196.721 64.3993 190.023 57.9036 180.845 62.8709C180.845 62.8709 178.488 87.4527 177 90C177 90 189.899 88.2169 193.372 89.2358C193.372 89.2358 193.372 78.537 195.605 74.4613C197.837 70.3855 196.721 64.3993 196.721 64.3993Z"
fill="#D9D9D9" />
<path
d="M194.5 64.5242L197.243 64C198.612 64.8234 199.672 66.1017 200.25 67.627C201.25 70.2125 204 82.882 204 82.882L193.25 94L191 90.6387L197.75 81.3306L194.25 73.5738L194.5 64.5242Z"
fill="#D9D9D9" />
<path
d="M193.136 55.5336C193.161 55.4579 193.168 55.3772 193.159 55.2979C193.149 55.2185 193.122 55.1425 193.079 55.0757C193.037 55.0089 192.98 54.953 192.913 54.9124C192.847 54.8718 192.772 54.8475 192.695 54.8413C192.537 54.8502 192.383 54.8921 192.241 54.9645C192.099 55.0369 191.973 55.1382 191.871 55.2625C191.66 55.5141 191.414 55.7323 191.142 55.91C190.86 56.0571 190.478 56.0361 190.317 55.7557C190.166 55.4925 190.27 55.1399 190.361 54.8341C190.593 54.056 190.724 53.2491 190.748 52.435C190.774 51.5264 190.65 50.5782 190.164 49.8695C189.537 48.9548 188.416 48.6076 187.354 48.6328C186.293 48.658 185.249 48.9971 184.197 49.2115C183.835 49.2855 183.408 49.3233 183.165 49.0322C182.907 48.7226 183.002 48.2215 183.112 47.8035C183.397 46.7188 183.699 45.5973 184.361 44.7212C185.076 43.8109 186.099 43.2152 187.222 43.0539C188.3 42.9156 189.394 43.0443 190.415 43.4292C192.079 43.9718 193.542 45.0272 194.61 46.4541C195.711 47.9715 196.187 49.8785 195.933 51.758C195.686 53.395 194.848 54.874 193.588 55.8967"
fill="#494949" />
<path
d="M130.828 103H2.17241C1.59645 102.999 1.04427 102.768 0.637002 102.356C0.229736 101.944 0.000649097 101.386 0 100.803V2.19668C0.000668861 1.61429 0.229762 1.05595 0.637024 0.64414C1.04429 0.232329 1.59646 0.000676333 2.17241 0H130.828C131.404 0.000656348 131.956 0.232306 132.363 0.644122C132.77 1.05594 132.999 1.61429 133 2.19668V100.803C132.999 101.386 132.77 101.944 132.363 102.356C131.956 102.768 131.404 102.999 130.828 103ZM2.17241 0.488152C1.72444 0.488659 1.29497 0.668828 0.978202 0.98913C0.661439 1.30943 0.483261 1.74371 0.482759 2.19668V100.803C0.483281 101.256 0.661462 101.691 0.97822 102.011C1.29498 102.331 1.72445 102.511 2.17241 102.512H130.828C131.276 102.511 131.705 102.331 132.022 102.011C132.339 101.691 132.517 101.256 132.517 100.803V2.19668C132.517 1.74371 132.339 1.30945 132.022 0.989152C131.705 0.668855 131.276 0.48868 130.828 0.488152H2.17241Z"
fill="#D9D9D9" />
<path
d="M118.826 23H15.1745C14.598 22.9993 14.0453 22.7669 13.6376 22.3537C13.23 21.9405 13.0007 21.3803 13 20.7959V13.2041C13.0007 12.6197 13.23 12.0595 13.6376 11.6463C14.0453 11.2331 14.598 11.0007 15.1745 11H118.826C119.402 11.0007 119.955 11.2331 120.362 11.6463C120.77 12.0595 120.999 12.6197 121 13.2041V20.7959C120.999 21.3803 120.77 21.9405 120.362 22.3537C119.955 22.7669 119.402 22.9993 118.826 23ZM15.1745 11.4898C14.7261 11.4903 14.2962 11.6711 13.9791 11.9925C13.6621 12.3138 13.4837 12.7496 13.4832 13.2041V20.7959C13.4837 21.2504 13.6621 21.6862 13.9791 22.0075C14.2962 22.3289 14.7261 22.5097 15.1745 22.5102H118.826C119.274 22.5097 119.704 22.3289 120.021 22.0075C120.338 21.6861 120.516 21.2504 120.517 20.7959V13.2041C120.516 12.7496 120.338 12.3139 120.021 11.9925C119.704 11.6711 119.274 11.4903 118.826 11.4898H15.1745Z"
fill="#262633" />
<path
d="M56.7979 44H15.2021C14.6183 43.9993 14.0586 43.7669 13.6457 43.3537C13.2329 42.9405 13.0007 42.3803 13 41.7959V34.2041C13.0007 33.6197 13.2329 33.0595 13.6457 32.6463C14.0586 32.2331 14.6183 32.0007 15.2021 32H56.7979C57.3817 32.0007 57.9414 32.2331 58.3543 32.6463C58.7671 33.0595 58.9993 33.6197 59 34.2041V41.7959C58.9993 42.3803 58.7671 42.9405 58.3543 43.3537C57.9414 43.7669 57.3817 43.9993 56.7979 44ZM15.2021 32.4898C14.748 32.4903 14.3127 32.6711 13.9916 32.9925C13.6705 33.3138 13.4899 33.7496 13.4894 34.2041V41.7959C13.4899 42.2504 13.6705 42.6862 13.9916 43.0075C14.3127 43.3289 14.748 43.5097 15.2021 43.5102H56.7979C57.252 43.5097 57.6873 43.3289 58.0084 43.0075C58.3295 42.6862 58.5101 42.2504 58.5106 41.7959V34.2041C58.5101 33.7496 58.3295 33.3138 58.0084 32.9925C57.6873 32.6711 57.252 32.4903 56.7979 32.4898H15.2021Z"
fill="#262633" />
<path
d="M118.798 44H77.2021C76.6183 43.9993 76.0586 43.7669 75.6457 43.3537C75.2329 42.9405 75.0007 42.3803 75 41.7959V34.2041C75.0007 33.6197 75.2329 33.0595 75.6457 32.6463C76.0586 32.2331 76.6183 32.0007 77.2021 32H118.798C119.382 32.0007 119.941 32.2331 120.354 32.6463C120.767 33.0595 120.999 33.6197 121 34.2041V41.7959C120.999 42.3803 120.767 42.9405 120.354 43.3537C119.941 43.7669 119.382 43.9993 118.798 44ZM77.2021 32.4898C76.748 32.4903 76.3127 32.6711 75.9916 32.9925C75.6705 33.3138 75.4899 33.7496 75.4894 34.2041V41.7959C75.4899 42.2504 75.6705 42.6862 75.9916 43.0075C76.3127 43.3289 76.748 43.5097 77.2021 43.5102H118.798C119.252 43.5097 119.687 43.3289 120.008 43.0075C120.329 42.6861 120.51 42.2504 120.511 41.7959V34.2041C120.51 33.7496 120.329 33.3139 120.008 32.9925C119.687 32.6711 119.252 32.4903 118.798 32.4898H77.2021Z"
fill="#262633" />
<path
d="M118.924 91H91.0763C90.5259 90.9994 89.9981 90.7753 89.6088 90.3769C89.2196 89.9785 89.0006 89.4384 89 88.875V86.125C89.0006 85.5616 89.2196 85.0215 89.6088 84.6231C89.9981 84.2247 90.5259 84.0006 91.0763 84H118.924C119.474 84.0007 120.002 84.2248 120.391 84.6231C120.78 85.0215 120.999 85.5616 121 86.125V88.875C120.999 89.4384 120.78 89.9785 120.391 90.3769C120.002 90.7752 119.474 90.9993 118.924 91Z"
fill="#D9D9D9" />
<path
d="M19 56C18.6044 56 18.2178 55.8827 17.8889 55.6629C17.56 55.4432 17.3036 55.1308 17.1522 54.7654C17.0009 54.3999 16.9613 53.9978 17.0384 53.6098C17.1156 53.2219 17.3061 52.8655 17.5858 52.5858C17.8655 52.3061 18.2219 52.1156 18.6098 52.0384C18.9978 51.9613 19.3999 52.0009 19.7654 52.1522C20.1308 52.3036 20.4432 52.56 20.6629 52.8889C20.8827 53.2178 21 53.6044 21 54C20.9994 54.5302 20.7885 55.0386 20.4135 55.4135C20.0386 55.7885 19.5302 55.9994 19 56ZM19 52.5C18.7033 52.5 18.4133 52.588 18.1666 52.7528C17.92 52.9176 17.7277 53.1519 17.6142 53.426C17.5006 53.7001 17.4709 54.0017 17.5288 54.2926C17.5867 54.5836 17.7296 54.8509 17.9393 55.0607C18.1491 55.2704 18.4164 55.4133 18.7074 55.4712C18.9983 55.5291 19.2999 55.4993 19.574 55.3858C19.8481 55.2723 20.0824 55.08 20.2472 54.8334C20.412 54.5867 20.5 54.2967 20.5 54C20.4996 53.6023 20.3414 53.221 20.0602 52.9398C19.779 52.6586 19.3977 52.5004 19 52.5Z"
fill="#D9D9D9" />
<path
d="M19 63C18.6044 63 18.2178 62.8827 17.8889 62.6629C17.56 62.4432 17.3036 62.1308 17.1522 61.7654C17.0009 61.3999 16.9613 60.9978 17.0384 60.6098C17.1156 60.2219 17.3061 59.8655 17.5858 59.5858C17.8655 59.3061 18.2219 59.1156 18.6098 59.0384C18.9978 58.9613 19.3999 59.0009 19.7654 59.1522C20.1308 59.3036 20.4432 59.56 20.6629 59.8889C20.8827 60.2178 21 60.6044 21 61C20.9994 61.5302 20.7885 62.0386 20.4135 62.4135C20.0386 62.7885 19.5302 62.9994 19 63ZM19 59.5C18.7033 59.5 18.4133 59.588 18.1666 59.7528C17.92 59.9176 17.7277 60.1519 17.6142 60.426C17.5006 60.7001 17.4709 61.0017 17.5288 61.2926C17.5867 61.5836 17.7296 61.8509 17.9393 62.0607C18.1491 62.2704 18.4164 62.4133 18.7074 62.4712C18.9983 62.5291 19.2999 62.4993 19.574 62.3858C19.8481 62.2723 20.0824 62.08 20.2472 61.8334C20.412 61.5867 20.5 61.2967 20.5 61C20.4996 60.6023 20.3414 60.221 20.0602 59.9398C19.779 59.6586 19.3977 59.5004 19 59.5Z"
fill="#D9D9D9" />
<path
d="M19 69C18.6044 69 18.2178 68.8827 17.8889 68.6629C17.56 68.4432 17.3036 68.1308 17.1522 67.7654C17.0009 67.3999 16.9613 66.9978 17.0384 66.6098C17.1156 66.2219 17.3061 65.8655 17.5858 65.5858C17.8655 65.3061 18.2219 65.1156 18.6098 65.0384C18.9978 64.9613 19.3999 65.0009 19.7654 65.1522C20.1308 65.3036 20.4432 65.56 20.6629 65.8889C20.8827 66.2178 21 66.6044 21 67C20.9994 67.5302 20.7885 68.0386 20.4135 68.4135C20.0386 68.7885 19.5302 68.9994 19 69ZM19 65.5C18.7033 65.5 18.4133 65.588 18.1666 65.7528C17.92 65.9176 17.7277 66.1519 17.6142 66.426C17.5006 66.7001 17.4709 67.0017 17.5288 67.2926C17.5867 67.5836 17.7296 67.8509 17.9393 68.0607C18.1491 68.2704 18.4164 68.4133 18.7074 68.4712C18.9983 68.5291 19.2999 68.4993 19.574 68.3858C19.8481 68.2723 20.0824 68.08 20.2472 67.8334C20.412 67.5867 20.5 67.2967 20.5 67C20.4996 66.6023 20.3414 66.221 20.0602 65.9398C19.779 65.6586 19.3977 65.5004 19 65.5Z"
fill="#262633" />
<path
d="M28.5943 53C28.1715 53 27.766 53.158 27.467 53.4393C27.168 53.7206 27 54.1022 27 54.5C27 54.8978 27.168 55.2794 27.467 55.5607C27.766 55.842 28.1715 56 28.5943 56H51.4057C51.8285 56 52.234 55.842 52.533 55.5607C52.832 55.2794 53 54.8978 53 54.5C53 54.1022 52.832 53.7206 52.533 53.4393C52.234 53.158 51.8285 53 51.4057 53H28.5943Z"
fill="#D9D9D9" />
<path
d="M28.5943 59C28.1715 59 27.766 59.158 27.467 59.4393C27.168 59.7206 27 60.1022 27 60.5C27 60.8978 27.168 61.2794 27.467 61.5607C27.766 61.842 28.1715 62 28.5943 62H51.4057C51.8285 62 52.234 61.842 52.533 61.5607C52.832 61.2794 53 60.8978 53 60.5C53 60.1022 52.832 59.7206 52.533 59.4393C52.234 59.158 51.8285 59 51.4057 59H28.5943Z"
fill="#D9D9D9" />
<path
d="M28.5943 65C28.1715 65 27.766 65.158 27.467 65.4393C27.168 65.7206 27 66.1022 27 66.5C27 66.8978 27.168 67.2794 27.467 67.5607C27.766 67.842 28.1715 68 28.5943 68H51.4057C51.8285 68 52.234 67.842 52.533 67.5607C52.832 67.2794 53 66.8978 53 66.5C53 66.1022 52.832 65.7206 52.533 65.4393C52.234 65.158 51.8285 65 51.4057 65H28.5943Z"
fill="#D9D9D9" />
<path
d="M82 56C81.6044 56 81.2178 55.8827 80.8889 55.6629C80.56 55.4432 80.3036 55.1308 80.1522 54.7654C80.0009 54.3999 79.9613 53.9978 80.0384 53.6098C80.1156 53.2219 80.3061 52.8655 80.5858 52.5858C80.8655 52.3061 81.2219 52.1156 81.6098 52.0384C81.9978 51.9613 82.3999 52.0009 82.7654 52.1522C83.1308 52.3036 83.4432 52.56 83.6629 52.8889C83.8827 53.2178 84 53.6044 84 54C83.9994 54.5302 83.7885 55.0386 83.4135 55.4135C83.0386 55.7885 82.5302 55.9994 82 56ZM82 52.5C81.7033 52.5 81.4133 52.588 81.1666 52.7528C80.92 52.9176 80.7277 53.1519 80.6142 53.426C80.5007 53.7001 80.4709 54.0017 80.5288 54.2926C80.5867 54.5836 80.7296 54.8509 80.9393 55.0607C81.1491 55.2704 81.4164 55.4133 81.7074 55.4712C81.9983 55.5291 82.2999 55.4993 82.574 55.3858C82.8481 55.2723 83.0824 55.08 83.2472 54.8334C83.412 54.5867 83.5 54.2967 83.5 54C83.4996 53.6023 83.3414 53.221 83.0602 52.9398C82.779 52.6586 82.3977 52.5004 82 52.5Z"
fill="#D9D9D9" />
<path
d="M82 63C81.6044 63 81.2178 62.8827 80.8889 62.6629C80.56 62.4432 80.3036 62.1308 80.1522 61.7654C80.0009 61.3999 79.9613 60.9978 80.0384 60.6098C80.1156 60.2219 80.3061 59.8655 80.5858 59.5858C80.8655 59.3061 81.2219 59.1156 81.6098 59.0384C81.9978 58.9613 82.3999 59.0009 82.7654 59.1522C83.1308 59.3036 83.4432 59.56 83.6629 59.8889C83.8827 60.2178 84 60.6044 84 61C83.9994 61.5302 83.7885 62.0386 83.4135 62.4135C83.0386 62.7885 82.5302 62.9994 82 63ZM82 59.5C81.7033 59.5 81.4133 59.588 81.1666 59.7528C80.92 59.9176 80.7277 60.1519 80.6142 60.426C80.5007 60.7001 80.4709 61.0017 80.5288 61.2926C80.5867 61.5836 80.7296 61.8509 80.9393 62.0607C81.1491 62.2704 81.4164 62.4133 81.7074 62.4712C81.9983 62.5291 82.2999 62.4993 82.574 62.3858C82.8481 62.2723 83.0824 62.08 83.2472 61.8334C83.412 61.5867 83.5 61.2967 83.5 61C83.4996 60.6023 83.3414 60.221 83.0602 59.9398C82.779 59.6586 82.3977 59.5004 82 59.5Z"
fill="#262633" />
<path
d="M82 69C81.6044 69 81.2178 68.8827 80.8889 68.6629C80.56 68.4432 80.3036 68.1308 80.1522 67.7654C80.0009 67.3999 79.9613 66.9978 80.0384 66.6098C80.1156 66.2219 80.3061 65.8655 80.5858 65.5858C80.8655 65.3061 81.2219 65.1156 81.6098 65.0384C81.9978 64.9613 82.3999 65.0009 82.7654 65.1522C83.1308 65.3036 83.4432 65.56 83.6629 65.8889C83.8827 66.2178 84 66.6044 84 67C83.9994 67.5302 83.7885 68.0386 83.4135 68.4135C83.0386 68.7885 82.5302 68.9994 82 69ZM82 65.5C81.7033 65.5 81.4133 65.588 81.1666 65.7528C80.92 65.9176 80.7277 66.1519 80.6142 66.426C80.5007 66.7001 80.4709 67.0017 80.5288 67.2926C80.5867 67.5836 80.7296 67.8509 80.9393 68.0607C81.1491 68.2704 81.4164 68.4133 81.7074 68.4712C81.9983 68.5291 82.2999 68.4993 82.574 68.3858C82.8481 68.2723 83.0824 68.08 83.2472 67.8334C83.412 67.5867 83.5 67.2967 83.5 67C83.4996 66.6023 83.3414 66.221 83.0602 65.9398C82.779 65.6586 82.3977 65.5004 82 65.5Z"
fill="#D9D9D9" />
<path
d="M82 75C81.6044 75 81.2178 74.8827 80.8889 74.6629C80.56 74.4432 80.3036 74.1308 80.1522 73.7654C80.0009 73.3999 79.9613 72.9978 80.0384 72.6098C80.1156 72.2219 80.3061 71.8655 80.5858 71.5858C80.8655 71.3061 81.2219 71.1156 81.6098 71.0384C81.9978 70.9613 82.3999 71.0009 82.7654 71.1522C83.1308 71.3036 83.4432 71.56 83.6629 71.8889C83.8827 72.2178 84 72.6044 84 73C83.9994 73.5302 83.7885 74.0386 83.4135 74.4135C83.0386 74.7885 82.5302 74.9994 82 75ZM82 71.5C81.7033 71.5 81.4133 71.588 81.1666 71.7528C80.92 71.9176 80.7277 72.1519 80.6142 72.426C80.5007 72.7001 80.4709 73.0017 80.5288 73.2926C80.5867 73.5836 80.7296 73.8509 80.9393 74.0607C81.1491 74.2704 81.4164 74.4133 81.7074 74.4712C81.9983 74.5291 82.2999 74.4993 82.574 74.3858C82.8481 74.2723 83.0824 74.08 83.2472 73.8334C83.412 73.5867 83.5 73.2967 83.5 73C83.4996 72.6023 83.3414 72.221 83.0602 71.9398C82.779 71.6586 82.3977 71.5004 82 71.5Z"
fill="#D9D9D9" />
<path
d="M91.5943 53C91.1715 53 90.766 53.158 90.467 53.4393C90.168 53.7206 90 54.1022 90 54.5C90 54.8978 90.168 55.2794 90.467 55.5607C90.766 55.842 91.1715 56 91.5943 56H114.406C114.829 56 115.234 55.842 115.533 55.5607C115.832 55.2794 116 54.8978 116 54.5C116 54.1022 115.832 53.7206 115.533 53.4393C115.234 53.158 114.829 53 114.406 53H91.5943Z"
fill="#D9D9D9" />
<path
d="M91.5943 59C91.1715 59 90.766 59.158 90.467 59.4393C90.168 59.7206 90 60.1022 90 60.5C90 60.8978 90.168 61.2794 90.467 61.5607C90.766 61.842 91.1715 62 91.5943 62H114.406C114.829 62 115.234 61.842 115.533 61.5607C115.832 61.2794 116 60.8978 116 60.5C116 60.1022 115.832 59.7206 115.533 59.4393C115.234 59.158 114.829 59 114.406 59H91.5943Z"
fill="#D9D9D9" />
<path
d="M91.5943 65C91.1715 65 90.766 65.158 90.467 65.4393C90.168 65.7206 90 66.1022 90 66.5C90 66.8978 90.168 67.2794 90.467 67.5607C90.766 67.842 91.1715 68 91.5943 68H114.406C114.829 68 115.234 67.842 115.533 67.5607C115.832 67.2794 116 66.8978 116 66.5C116 66.1022 115.832 65.7206 115.533 65.4393C115.234 65.158 114.829 65 114.406 65H91.5943Z"
fill="#D9D9D9" />
<path
d="M91.5943 72C91.1715 72 90.766 72.158 90.467 72.4393C90.168 72.7206 90 73.1022 90 73.5C90 73.8978 90.168 74.2794 90.467 74.5607C90.766 74.842 91.1715 75 91.5943 75H114.406C114.829 75 115.234 74.842 115.533 74.5607C115.832 74.2794 116 73.8978 116 73.5C116 73.1022 115.832 72.7206 115.533 72.4393C115.234 72.158 114.829 72 114.406 72H91.5943Z"
fill="#D9D9D9" />
<path
d="M19.5726 36C19.1555 36 18.7555 36.158 18.4606 36.4393C18.1657 36.7206 18 37.1022 18 37.5C18 37.8978 18.1657 38.2794 18.4606 38.5607C18.7555 38.842 19.1555 39 19.5726 39H46.4274C46.8445 39 47.2445 38.842 47.5394 38.5607C47.8343 38.2794 48 37.8978 48 37.5C48 37.1022 47.8343 36.7206 47.5394 36.4393C47.2445 36.158 46.8445 36 46.4274 36H19.5726Z"
fill="#D9D9D9" />
<path
d="M19.5726 16C19.1555 16 18.7555 16.158 18.4606 16.4393C18.1657 16.7206 18 17.1022 18 17.5C18 17.8978 18.1657 18.2794 18.4606 18.5607C18.7555 18.842 19.1555 19 19.5726 19H46.4274C46.8445 19 47.2445 18.842 47.5394 18.5607C47.8343 18.2794 48 17.8978 48 17.5C48 17.1022 47.8343 16.7206 47.5394 16.4393C47.2445 16.158 46.8445 16 46.4274 16H19.5726Z"
fill="#16B378" />
<path
d="M51.1054 37C51.0869 37 51.0687 37.0055 51.0527 37.016C51.0367 37.0265 51.0234 37.0417 51.0141 37.0599C51.0049 37.0781 51 37.0987 51 37.1197C51 37.1408 51.0049 37.1614 51.0141 37.1796L51.9087 38.9401C51.918 38.9583 51.9313 38.9734 51.9473 38.984C51.9633 38.9945 51.9815 39 52 39C52.0185 39 52.0367 38.9945 52.0527 38.984C52.0687 38.9734 52.082 38.9583 52.0913 38.9401L52.9859 37.1796C52.9951 37.1614 53 37.1408 53 37.1197C53 37.0987 52.9951 37.0781 52.9859 37.0599C52.9766 37.0417 52.9633 37.0265 52.9473 37.016C52.9313 37.0055 52.9131 37 52.8946 37H51.1054Z"
fill="#16B378" />
<path
d="M82.5726 36C82.1555 36 81.7555 36.158 81.4606 36.4393C81.1657 36.7206 81 37.1022 81 37.5C81 37.8978 81.1657 38.2794 81.4606 38.5607C81.7555 38.842 82.1555 39 82.5726 39H109.427C109.844 39 110.244 38.842 110.539 38.5607C110.834 38.2794 111 37.8978 111 37.5C111 37.1022 110.834 36.7206 110.539 36.4393C110.244 36.158 109.844 36 109.427 36H82.5726Z"
fill="#D9D9D9" />
<path
d="M113.105 37C113.087 37 113.069 37.0055 113.053 37.016C113.037 37.0265 113.023 37.0417 113.014 37.0599C113.005 37.0781 113 37.0987 113 37.1197C113 37.1408 113.005 37.1614 113.014 37.1796L113.909 38.9401C113.918 38.9583 113.931 38.9734 113.947 38.984C113.963 38.9945 113.981 39 114 39C114.018 39 114.037 38.9945 114.053 38.984C114.069 38.9734 114.082 38.9583 114.091 38.9401L114.986 37.1796C114.995 37.1614 115 37.1408 115 37.1197C115 37.0987 114.995 37.0781 114.986 37.0599C114.977 37.0417 114.963 37.0265 114.947 37.016C114.931 37.0055 114.913 37 114.895 37H113.105Z"
fill="#16B378" />
<path
d="M82 62C82.5523 62 83 61.5523 83 61C83 60.4477 82.5523 60 82 60C81.4477 60 81 60.4477 81 61C81 61.5523 81.4477 62 82 62Z"
fill="#16B378" />
<path
d="M19 68C19.5523 68 20 67.5523 20 67C20 66.4477 19.5523 66 19 66C18.4477 66 18 66.4477 18 67C18 67.5523 18.4477 68 19 68Z"
fill="#16B378" />
<path
d="M224.712 138H147.288C147.211 138 147.138 137.947 147.084 137.854C147.03 137.76 147 137.633 147 137.5C147 137.367 147.03 137.24 147.084 137.146C147.138 137.053 147.211 137 147.288 137H224.712C224.789 137 224.862 137.053 224.916 137.146C224.97 137.24 225 137.367 225 137.5C225 137.633 224.97 137.76 224.916 137.854C224.862 137.947 224.789 138 224.712 138Z"
fill="#262633" />
</g>
<defs>
<clipPath id="clip0_1161_12522">
<rect width="225" height="138" fill="white" />
</clipPath>
</defs>
</svg>

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

@ -42,7 +42,8 @@
"When adding table rules, automatically add a rule to grant OWNER full access.": "Beim Hinzufügen von Tabellenregeln wird automatisch eine Regel hinzugefügt, um BESITZER vollen Zugriff zu gewähren.",
"Permission to edit document structure": "Berechtigung zur Bearbeitung der Dokumentenstruktur",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Erlauben Sie Editoren, Struktur zu bearbeiten (z.B. Tabellen, Spalten, Layouts zu ändern und zu löschen) und Formeln zu schreiben, die unabhängig von Leseeinschränkungen Zugriff auf alle Daten geben.",
"This default should be changed if editors' access is to be limited. ": "Diese Standardeinstellung sollte geändert werden, wenn der Zugriff von Editoren eingeschränkt werden soll. "
"This default should be changed if editors' access is to be limited. ": "Diese Standardeinstellung sollte geändert werden, wenn der Zugriff von Editoren eingeschränkt werden soll. ",
"Add Table-wide Rule": "Tabellenweite Regel hinzufügen"
},
"AccountPage": {
"API": "API",
@ -305,7 +306,8 @@
"Document ID copied to clipboard": "Dokument-ID in die Zwischenablage kopiert",
"Ok": "OK",
"Webhooks": "Webhaken",
"Manage Webhooks": "Webhaken verwalten"
"Manage Webhooks": "Webhaken verwalten",
"API Console": "API-Konsole"
},
"DocumentUsage": {
"Attachments Size": "Größe der Anhänge",
@ -464,7 +466,19 @@
"Add column": "Spalte hinzufügen",
"Last updated by": "Zuletzt aktualisiert von",
"Detect duplicates in...": "Erkennen Sie Duplikate in...",
"Last updated at": "Zuletzt aktualisiert am"
"Last updated at": "Zuletzt aktualisiert am",
"Reference List": "Referenzliste",
"Text": "Text",
"Date": "Datum",
"DateTime": "DatumUhrzeit",
"Choice": "Auswahl",
"Choice List": "Auswahlliste",
"Reference": "Referenz",
"Attachment": "Anhang",
"Any": "Jegliche",
"Numeric": "Numerisch",
"Integer": "Ganze Zahl",
"Toggle": "Umschalten"
},
"GristDoc": {
"Added new linked section to view {{viewName}}": "Neuer verlinkter Abschnitt zur Ansicht hinzugefügt {{viewName}}",
@ -667,7 +681,24 @@
"Fields_one": "Feld",
"Fields_other": "Felder",
"Add referenced columns": "Referenzspalten hinzufügen",
"Reset form": "Formular zurücksetzen"
"Reset form": "Formular zurücksetzen",
"Enter text": "Text eingeben",
"Layout": "Layout",
"Submission": "Einreichung",
"Redirect automatically after submission": "Nach Eingabe automatisch umleiten",
"Redirection": "Umleitung",
"Submit another response": "Eine weitere Antwort einreichen",
"Required field": "Erforderliches Feld",
"Table column name": "Name der Spalte in der Tabelle",
"Enter redirect URL": "Weiterleitungs-URL eingeben",
"Display button": "Anzeigetaste",
"Field rules": "Feldregeln",
"Success text": "Erfolgstext",
"Configuration": "Konfiguration",
"Default field value": "Standard-Feldwert",
"Field title": "Feldtitel",
"Hidden field": "Verborgenes Feld",
"Submit button label": "Beschriftung der Schaltfläche Senden"
},
"RowContextMenu": {
"Copy anchor link": "Ankerlink kopieren",
@ -813,7 +844,8 @@
"Show raw data": "Rohdaten anzeigen",
"Widget options": "Widget Optionen",
"Add to page": "Zur Seite hinzufügen",
"Collapse widget": "Widget einklappen"
"Collapse widget": "Widget einklappen",
"Create a form": "Formular erstellen"
},
"ViewSectionMenu": {
"(customized)": "(angepasst)",
@ -896,7 +928,11 @@
"You do not have access to this organization's documents.": "Sie haben keinen Zugriff auf die Dokumente dieser Organisation.",
"Account deleted{{suffix}}": "Konto gelöscht{{suffix}}",
"Your account has been deleted.": "Ihr Konto wurde gelöscht.",
"Sign up": "Anmelden"
"Sign up": "Anmelden",
"An unknown error occurred.": "Ein unbekannter Fehler ist aufgetreten.",
"Powered by": "Angetrieben durch",
"Build your own form": "Erstellen Sie Ihr eigenes Formular",
"Form not found": "Formular nicht gefunden"
},
"menus": {
"* Workspaces are available on team plans. ": "* Arbeitsbereiche sind in Teamplänen verfügbar. ",
@ -919,7 +955,17 @@
"modals": {
"Cancel": "Abbrechen",
"Ok": "OK",
"Save": "Speichern"
"Save": "Speichern",
"Delete": "Löschen",
"Are you sure you want to delete these records?": "Sind Sie sicher, dass Sie diese Datensätze löschen wollen?",
"Are you sure you want to delete this record?": "Sind Sie sicher, dass Sie diesen Datensatz löschen wollen?",
"Undo to restore": "Rückgängig machen zum Wiederherstellen",
"Don't show again": "Nicht mehr anzeigen",
"Got it": "Verstanden",
"Dismiss": "Ablehnen",
"Don't ask again.": "Frag nicht mehr.",
"Don't show again.": "Zeig nicht mehr.",
"Don't show tips": "Keine Tipps anzeigen"
},
"pages": {
"Duplicate Page": "Seite duplizieren",
@ -1138,7 +1184,11 @@
"Lookups return data from related tables.": "Lookups geben Daten aus Bezugstabellen zurück.",
"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Sie können aus Widgets wählen, die Ihnen im Dropdown zur Verfügung stehen, oder Sie selbst einbetten, indem Sie seine volle URL angeben.",
"Use reference columns to relate data in different tables.": "Verwenden Sie Referenzspalten, um Daten in verschiedenen Tabellen zu beziehen.",
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Die Formeln unterstützen viele Excel-Funktionen, die vollständige Python-Syntax und enthalten einen hilfreichen KI-Assistenten."
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Die Formeln unterstützen viele Excel-Funktionen, die vollständige Python-Syntax und enthalten einen hilfreichen KI-Assistenten.",
"Learn more": "Mehr erfahren",
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Erstellen Sie einfache Formulare direkt in Grist und teilen Sie sie mit einem Klick mit unserem neuen Widget. {{learnMoreButton}}",
"Forms are here!": "Die Formulare sind da!",
"These rules are applied after all column rules have been processed, if applicable.": "Diese Regeln werden angewendet, nachdem alle Spaltenregeln abgearbeitet wurden, falls zutreffend."
},
"DescriptionConfig": {
"DESCRIPTION": "BESCHREIBUNG"
@ -1324,5 +1374,70 @@
},
"HiddenQuestionConfig": {
"Hidden fields": "Ausgeblendete Felder"
},
"FormView": {
"Publish your form?": "Ihr Formular veröffentlichen?",
"Unpublish": "Unveröffentlichen",
"Unpublish your form?": "Ihr Formular unveröffentlichen?",
"Publish": "Veröffentlichen"
},
"Editor": {
"Delete": "Löschen"
},
"Menu": {
"Building blocks": "Bausteine",
"Unmapped fields": "Nicht zugeordnete Felder",
"Separator": "Abscheider",
"Header": "Kopfzeile",
"Cut": "Schneiden",
"Insert question above": "Frage oben einfügen",
"Insert question below": "Frage unten einfügen",
"Paragraph": "Absatz",
"Paste": "Einfügen",
"Columns": "Spalten",
"Copy": "Kopieren"
},
"FormContainer": {
"Build your own form": "Erstellen Sie Ihr eigenes Formular",
"Powered by": "Angetrieben durch"
},
"FormErrorPage": {
"Error": "Fehler"
},
"FormModel": {
"Oops! This form is no longer published.": "Huch! Dieses Formular wird nicht mehr veröffentlicht.",
"Oops! The form you're looking for doesn't exist.": "Huch! Das Formular, das Sie suchen, existiert nicht.",
"You don't have access to this form.": "Sie haben keinen Zugang zu diesem Formular.",
"There was a problem loading the form.": "Beim Laden des Formulars ist ein Problem aufgetreten."
},
"WelcomeCoachingCall": {
"free coaching call": "kostenloser Coaching-Anruf",
"Schedule Call": "Anruf planen",
"Schedule your {{freeCoachingCall}} with a member of our team.": "Planen Sie Ihre {{freeCoachingCall}} mit einem Mitglied unseres Teams.",
"Maybe Later": "Vielleicht später",
"On the call, we'll take the time to understand your needs and tailor the call to you. We can show you the Grist basics, or start working with your data right away to build the dashboards you need.": "Während des Gesprächs nehmen wir uns die Zeit, Ihre Bedürfnisse zu verstehen und das Gespräch auf Sie zuzuschneiden. Wir können Ihnen die Grundlagen von Grist zeigen oder sofort mit Ihren Daten arbeiten, um die von Ihnen benötigten Dashboards zu erstellen."
},
"UnmappedFieldsConfig": {
"Clear": "Leeren",
"Map fields": "Felder zuordnen",
"Unmap fields": "Felder freigeben",
"Unmapped": "Nicht zugeordnet",
"Mapped": "Zugeordnet",
"Select All": "Alle auswählen"
},
"FormConfig": {
"Field rules": "Feldregeln",
"Required field": "Erforderliches Feld"
},
"CustomView": {
"Some required columns aren't mapped": "Einige erforderliche Spalten sind nicht zugeordnet",
"To use this widget, please map all non-optional columns from the creator panel on the right.": "Um dieses Widget zu verwenden, ordnen Sie bitte alle nicht-optionalen Spalten im Ersteller-Panel auf der rechten Seite zu."
},
"FormSuccessPage": {
"Form Submitted": "Formular eingereicht",
"Thank you! Your response has been recorded.": "Danke! Ihre Antwort wurde aufgezeichnet."
},
"FormPage": {
"There was an error submitting your form. Please try again.": "Beim Absenden Ihres Formulars ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut."
}
}

@ -41,7 +41,8 @@
"When adding table rules, automatically add a rule to grant OWNER full access.": "When adding table rules, automatically add a rule to grant OWNER full access.",
"Permission to edit document structure": "Permission to edit document structure",
"This default should be changed if editors' access is to be limited. ": "This default should be changed if editors' access is to be limited. ",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions."
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.",
"Add Table-wide Rule": "Add Table-wide Rule"
},
"AccountPage": {
"API": "API",
@ -1122,7 +1123,8 @@
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.",
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}",
"Forms are here!": "Forms are here!",
"Learn more": "Learn more"
"Learn more": "Learn more",
"These rules are applied after all column rules have been processed, if applicable.": "These rules are applied after all column rules have been processed, if applicable."
},
"DescriptionConfig": {
"DESCRIPTION": "DESCRIPTION"
@ -1349,5 +1351,29 @@
"FormConfig": {
"Field rules": "Field rules",
"Required field": "Required field"
},
"CustomView": {
"Some required columns aren't mapped": "Some required columns aren't mapped",
"To use this widget, please map all non-optional columns from the creator panel on the right.": "To use this widget, please map all non-optional columns from the creator panel on the right."
},
"FormContainer": {
"Build your own form": "Build your own form",
"Powered by": "Powered by"
},
"FormErrorPage": {
"Error": "Error"
},
"FormModel": {
"Oops! The form you're looking for doesn't exist.": "Oops! The form you're looking for doesn't exist.",
"Oops! This form is no longer published.": "Oops! This form is no longer published.",
"There was a problem loading the form.": "There was a problem loading the form.",
"You don't have access to this form.": "You don't have access to this form."
},
"FormPage": {
"There was an error submitting your form. Please try again.": "There was an error submitting your form. Please try again."
},
"FormSuccessPage": {
"Form Submitted": "Form Submitted",
"Thank you! Your response has been recorded.": "Thank you! Your response has been recorded."
}
}

@ -37,7 +37,8 @@
"When adding table rules, automatically add a rule to grant OWNER full access.": "Al agregar reglas de tabla, agregue automáticamente una regla para otorgar acceso completo al PROPIETARIO.",
"Permission to edit document structure": "Permiso para editar la estructura del documento",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Permitir a los editores editar la estructura (por ejemplo, modificar y eliminar tablas, columnas, diseños), y escribir fórmulas, que dan acceso a todos los datos independientemente de las restricciones de lectura.",
"This default should be changed if editors' access is to be limited. ": "Este valor predeterminado debe cambiarse si se quiere limitar el acceso de los editores. "
"This default should be changed if editors' access is to be limited. ": "Este valor predeterminado debe cambiarse si se quiere limitar el acceso de los editores. ",
"Add Table-wide Rule": "Añadir regla a toda la tabla"
},
"AccountPage": {
"API": "API",
@ -1173,7 +1174,11 @@
"Lookups return data from related tables.": "Las búsquedas devuelven datos de tablas relacionadas.",
"Use reference columns to relate data in different tables.": "Utilizar las columnas de referencia para relacionar los datos de distintas tablas.",
"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Puedes elegir entre los widgets disponibles en el menú desplegable, o incrustar el suyo propio proporcionando su dirección URL completa.",
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Las fórmulas admiten muchas funciones de Excel, sintaxis completa de Python e incluyen un útil asistente de inteligencia artificial."
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Las fórmulas admiten muchas funciones de Excel, sintaxis completa de Python e incluyen un útil asistente de inteligencia artificial.",
"Forms are here!": "¡Los formularios están aquí!",
"Learn more": "Más información",
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Cree formularios sencillos directamente en Grist y compártalos en un clic con nuestro nuevo widget. {{learnMoreButton}}",
"These rules are applied after all column rules have been processed, if applicable.": "Estas reglas se aplican después de que se hayan procesado todas las reglas de columna, si procede."
},
"DescriptionConfig": {
"DESCRIPTION": "DESCRIPCIÓN"
@ -1396,5 +1401,33 @@
"Schedule your {{freeCoachingCall}} with a member of our team.": "Programe su {{freeCoachingCall}} con un miembro de nuestro equipo.",
"Maybe Later": "Quizás más tarde",
"On the call, we'll take the time to understand your needs and tailor the call to you. We can show you the Grist basics, or start working with your data right away to build the dashboards you need.": "En la llamada, nos tomaremos el tiempo necesario para entender sus necesidades y adaptar la llamada a usted. Podemos mostrarle los conceptos básicos de Grist o empezar a trabajar con sus datos de inmediato para crear los cuadros de mando que necesita."
},
"FormConfig": {
"Field rules": "Reglas del campo",
"Required field": "Campo obligatorio"
},
"CustomView": {
"Some required columns aren't mapped": "Algunas columnas obligatorias no están asignadas",
"To use this widget, please map all non-optional columns from the creator panel on the right.": "Para utilizar este widget, asigne todas las columnas no opcionales desde el panel del creador de la derecha."
},
"FormContainer": {
"Build your own form": "Cree su propio formulario",
"Powered by": "Desarrollado por"
},
"FormErrorPage": {
"Error": "Error"
},
"FormModel": {
"Oops! The form you're looking for doesn't exist.": "¡Vaya! El formulario que busca no existe.",
"Oops! This form is no longer published.": "¡Vaya! Este formulario ya no se publica.",
"There was a problem loading the form.": "Hubo un problema al cargar el formulario.",
"You don't have access to this form.": "No tiene acceso a este formulario."
},
"FormPage": {
"There was an error submitting your form. Please try again.": "Se ha producido un error al enviar el formulario. Por favor, inténtelo de nuevo."
},
"FormSuccessPage": {
"Form Submitted": "Formulario enviado",
"Thank you! Your response has been recorded.": "¡Muchas gracias! Su respuesta ha quedado registrada."
}
}

File diff suppressed because it is too large Load Diff

@ -39,10 +39,11 @@
"View As": "Voir en tant que",
"Remove column {{- colId }} from {{- tableId }} rules": "Supprimer la colonne {{-colId}} des règles de la table {{-tableId}}",
"Seed rules": "Règles par défaut",
"When adding table rules, automatically add a rule to grant OWNER full access.": "Ajouter automatiquement une règle donnant tous les droits au groupe OWNER.",
"When adding table rules, automatically add a rule to grant OWNER full access.": "Pour chaque ajout de règle pour une table, ajouter automatiquement une règle donnant tous les droits au groupe OWNER.",
"Permission to edit document structure": "Droits d'édition de la structure",
"This default should be changed if editors' access is to be limited. ": "Cette valeur par défaut doit être modifiée si l'on souhaite limiter l'accès des éditeurs. ",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Autorise les éditeurs à éditer la structure (modifier/supprimer des tables, colonnes, mises en page...) et à écrire des formules, ce qui donne accès à l'ensemble des données sans prendre en compte d'éventuelles restrictions de droits de lecture."
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Autorise les éditeurs à éditer la structure (modifier/supprimer des tables, colonnes, mises en page) et à écrire des formules, ce qui donne accès à l'ensemble des données sans prendre en compte d'éventuelles restrictions de droits de lecture.",
"Add Table-wide Rule": "Ajouter une règle pour l'ensemble du tableau"
},
"AccountPage": {
"Account settings": "Paramètres du compte",
@ -51,15 +52,15 @@
"Email": "E-mail",
"Name": "Nom",
"Save": "Enregistrer",
"Password & Security": "Mot de passe & Sécurité",
"Password & Security": "Mot de passe et sécurité",
"Login Method": "Mode de connexion",
"Change Password": "Modifier le mot de passe",
"Allow signing in to this account with Google": "Autoriser la connexion à ce compte avec Google",
"Two-factor authentication": "Authentification à deux facteurs",
"Two-factor authentication is an extra layer of security for your Grist account designed to ensure that you're the only person who can access your account, even if someone knows your password.": "L'authentification à double facteur est une couche additionnelle de sécurité pour votre compte Grist qui permet de s'assurer que vous êtes la seule personne qui peut accéder à votre compte, même si quelqu'un d'autre connaît votre mot de passe.",
"Two-factor authentication is an extra layer of security for your Grist account designed to ensure that you're the only person who can access your account, even if someone knows your password.": "L'authentification à double facteur est une couche supplémentaire de sécurité pour votre compte Grist qui permet de s'assurer que vous êtes la seule personne qui peut accéder à votre compte, même si quelqu'un d'autre connaît votre mot de passe.",
"Theme": "Thème",
"API Key": "Clé dAPI",
"Names only allow letters, numbers and certain special characters": "Les noms d'utilisateurs ne doivent contenir que des lettres, des chiffres, et certains caractères spéciaux",
"Names only allow letters, numbers and certain special characters": "Les noms d'utilisateurs ne doivent contenir que des lettres, des chiffres et certains caractères spéciaux",
"Language": "Langue"
},
"AccountWidget": {
@ -293,7 +294,8 @@
"API": "API",
"Ok": "OK",
"Manage Webhooks": "Gérer les points dancrage Web",
"Webhooks": "Points dancrage Web"
"Webhooks": "Points dancrage Web",
"API Console": "Console de l'API"
},
"DocumentUsage": {
"Usage statistics are only available to users with full access to the document data.": "Les statistiques d'utilisation ne sont disponibles qu'aux utilisateurs ayant un accès complet aux données du document.",
@ -413,11 +415,11 @@
"Hidden Columns": "Colonnes cachées",
"Lookups": "Champ rapporté",
"No reference columns.": "Pas de colonne de référence.",
"Apply on record changes": "Appliquer lors de changements d'enregistrements",
"Apply on record changes": "Appliquer lors des modifications de l'enregistrement",
"Duplicate in {{- label}}": "Duplica dans {{-label}}",
"Created By": "Créé(e) par",
"Last Updated At": "Dernière mise à jour le",
"Apply to new records": "Appliquer au nouveaux enregistrements",
"Apply to new records": "Appliquer aux nouvelles lignes",
"Search columns": "Chercher des colonnes",
"Timestamp": "Horodatage",
"no reference column": "pas de colonne de référence",
@ -431,7 +433,18 @@
"Last updated by": "Dernière mise à jour par",
"Detect duplicates in...": "Détecter les duplicats dans...",
"Last updated at": "Dernière mise à jour",
"Text": "Texte"
"Text": "Texte",
"DateTime": "Date et Heure",
"Choice": "Choix unique",
"Choice List": "Choix multiple",
"Date": "Date",
"Any": "Non défini",
"Numeric": "Numérique",
"Integer": "Entier",
"Toggle": "Booléen",
"Reference": "Référence",
"Reference List": "Référence multiple",
"Attachment": "Pièce jointe"
},
"GristDoc": {
"Import from file": "Importer depuis un fichier",
@ -616,10 +629,28 @@
"Detach": "Détacher",
"SELECT BY": "SÉLECTIONNER PAR",
"Select Widget": "Choisir la vue",
"SELECTOR FOR": "SÉLECTEUR",
"SELECTOR FOR": "SÉLECTEUR POUR",
"Save": "Enregistrer",
"You do not have edit access to this document": "Vous navez pas accès en écriture à ce document",
"Add referenced columns": "Ajouter une colonne référencée"
"Add referenced columns": "Ajouter une colonne référencée",
"Redirect automatically after submission": "Redirection automatique après soumission",
"Redirection": "Redirection",
"Configuration": "Configuration",
"Default field value": "Valeur par défaut du champ",
"Display button": "Afficher le bouton",
"Enter text": "Saisir du texte",
"Field rules": "Règles du champ",
"Field title": "Titre du champ",
"Hidden field": "Champ caché",
"Layout": "Mise en page",
"Submission": "Soumission",
"Submit button label": "Libellé du bouton de validation",
"Success text": "Message de succès",
"Table column name": "Nom de la colonne",
"Enter redirect URL": "Saisir l'URL de redirection",
"Reset form": "Réinitialiser le formulaire",
"Submit another response": "Soumettre une autre réponse",
"Required field": "Champ obligatoire"
},
"RowContextMenu": {
"Insert row": "Insérer une ligne",
@ -692,14 +723,15 @@
"Delete": "Supprimer",
"Return to viewing as yourself": "Revenir à une vue en propre",
"Raw Data": "Données source",
"Settings": "Paramètres"
"Settings": "Paramètres",
"API Console": "Console de l'API"
},
"TopBar": {
"Manage Team": "Gestion de l'équipe"
},
"TriggerFormulas": {
"Any field": "N'importe quel champ",
"Apply to new records": "Nouveaux enregistrements",
"Apply to new records": "Appliquer sur les nouvelles lignes uniquement",
"Apply on changes to:": "Appliquer sur les modifications à :",
"Apply on record changes": "Réappliquer en cas de modification de la ligne",
"Current field ": "Champ actif ",
@ -754,7 +786,8 @@
"Open configuration": "Ouvrir la configuration",
"Delete widget": "Supprimer la vue",
"Collapse widget": "Réduire la vue",
"Add to page": "Ajouter à la page"
"Add to page": "Ajouter à la page",
"Create a form": "Créer un formulaire"
},
"ViewSectionMenu": {
"Update Sort&Filter settings": "Mettre à jour le tri et le filtre",
@ -833,7 +866,11 @@
"There was an unknown error.": "Une erreur inconnue sest produite.",
"Account deleted{{suffix}}": "Compte supprimé {{suffix}}",
"Your account has been deleted.": "Votre compte a été supprimé.",
"Sign up": "S'inscrire"
"Sign up": "S'inscrire",
"Build your own form": "Créez votre propre formulaire",
"Form not found": "Formulaire non trouvé",
"Powered by": "Créé avec",
"An unknown error occurred.": "Une erreur inconnue s'est produite."
},
"menus": {
"Select fields": "Sélectionner les champs",
@ -856,7 +893,17 @@
"modals": {
"Save": "Enregistrer",
"Cancel": "Annuler",
"Ok": "OK"
"Ok": "OK",
"Don't show tips": "Masquer les astuces",
"Undo to restore": "Annuler et rétablir",
"Don't show again": "Ne plus montrer",
"Delete": "Supprimer",
"Dismiss": "Ignorer",
"Don't ask again.": "Ne plus demander.",
"Don't show again.": "Ne plus montrer.",
"Got it": "J'ai compris",
"Are you sure you want to delete these records?": "Êtes-vous sûr de vouloir supprimer ces enregistrements?",
"Are you sure you want to delete this record?": "Êtes-vous sûr de vouloir supprimer cet enregistrement?"
},
"pages": {
"Rename": "Renommer",
@ -1075,7 +1122,11 @@
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Les formules supportent beaucoup de fonctions Excel et la syntaxe Python complète. Un assistant IA est disponible sur certaines instances.",
"Lookups return data from related tables.": "Récupère les données d'une table liée.",
"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Vous pouvez choisir parmi les widgets disponibles dans le menu déroulant, ou utilisez le votre en fournissant son URL complète.",
"Use reference columns to relate data in different tables.": "Utilisez les colonnes de type Référence pour lier différentes tables entre elles."
"Use reference columns to relate data in different tables.": "Utilisez les colonnes de type Référence pour lier différentes tables entre elles.",
"Learn more": "En savoir plus",
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Créez des formulaires simples directement dans Grist et partagez-les en un clic avec notre nouveau widget. {{learnMoreButton}}",
"Forms are here!": "Les formulaires sont là!",
"These rules are applied after all column rules have been processed, if applicable.": "Ces règles sont appliquées après le traitement de toutes les règles de la colonne, le cas échéant."
},
"ColumnTitle": {
"Add description": "Ajouter une description",
@ -1258,5 +1309,73 @@
"Delete card": "Supprimer la carte",
"Copy anchor link": "Copier le lien d'ancrage",
"Insert card": "Insérer une carte"
},
"WelcomeCoachingCall": {
"Maybe Later": "Peut-être plus tard",
"free coaching call": "appel d'assistance gratuit",
"Schedule Call": "Planifier l'appel",
"On the call, we'll take the time to understand your needs and tailor the call to you. We can show you the Grist basics, or start working with your data right away to build the dashboards you need.": "Lors de l'appel, nous prendrons le temps de comprendre vos besoins et d'adapter l'appel à ces derniers. Nous pouvons vous montrer les bases de Grist, ou commencer tout de suite à travailler avec vos données pour construire les tableaux de bord dont vous avez besoin.",
"Schedule your {{freeCoachingCall}} with a member of our team.": "Planifiez votre {{freeCoachingCall}} avec un membre de notre équipe."
},
"FormView": {
"Publish": "Publier",
"Publish your form?": "Publier votre formulaire?",
"Unpublish": "Dépublier",
"Unpublish your form?": "Dépublier votre formulaire?"
},
"HiddenQuestionConfig": {
"Hidden fields": "Champs cachés"
},
"Menu": {
"Building blocks": "Blocs de construction",
"Columns": "Colonnes",
"Copy": "Copier",
"Cut": "Couper",
"Insert question above": "Insérer une question ci-dessus",
"Insert question below": "Insérer une question ci-dessous",
"Paragraph": "Paragraphe",
"Paste": "Coller",
"Separator": "Séparateur",
"Unmapped fields": "Champs non utilisés",
"Header": "Titre"
},
"UnmappedFieldsConfig": {
"Mapped": "Utilisé",
"Select All": "Tout sélectionner",
"Unmap fields": "Champs non utilisés",
"Unmapped": "Non utilisé",
"Clear": "Effacer",
"Map fields": "Champs utilisés"
},
"FormConfig": {
"Field rules": "Règles du champ",
"Required field": "Champ obligatoire"
},
"Editor": {
"Delete": "Supprimer"
},
"CustomView": {
"Some required columns aren't mapped": "Certaines colonnes obligatoires ne sont pas utilisées",
"To use this widget, please map all non-optional columns from the creator panel on the right.": "Pour utiliser cette vue, utilisez toutes les colonnes obligatoires à partir du panneau du créateur sur la droite."
},
"FormContainer": {
"Build your own form": "Créez votre propre formulaire",
"Powered by": "Créé avec"
},
"FormErrorPage": {
"Error": "Erreur"
},
"FormModel": {
"Oops! The form you're looking for doesn't exist.": "Oups! Le formulaire que vous recherchez n'existe pas.",
"Oops! This form is no longer published.": "Oups! Ce formulaire n'est plus publié.",
"There was a problem loading the form.": "Il y a eu un problème de chargement du formulaire.",
"You don't have access to this form.": "Vous n'avez pas accès à ce formulaire."
},
"FormPage": {
"There was an error submitting your form. Please try again.": "Une erreur s'est produite lors de l'envoi de votre formulaire. Veuillez réessayer."
},
"FormSuccessPage": {
"Form Submitted": "Formulaire envoyé",
"Thank you! Your response has been recorded.": "Nous vous remercions. Votre réponse a été enregistrée."
}
}

@ -105,7 +105,27 @@
"Freeze {{count}} more columns_other": "さらに {{count}} 列固定する",
"Hide {{count}} columns_one": "列非表示",
"Insert column to the left": "左側に列を挿入",
"Sorted (#{{count}})_other": "ソート (#{{count}})"
"Sorted (#{{count}})_other": "ソート (#{{count}})",
"Attachment": "添付ファイル",
"Add column": "列を追加",
"Adding UUID column": "UUID列を追加",
"Detect Duplicates in...": "重複を検出...",
"UUID": "UUID",
"Add column with type": "型を指定して列を追加",
"Add formula column": "数式列を追加",
"Apply to new records": "新しいレコードに適用",
"Apply on record changes": "レコードの変更に適用",
"Authorship": "作成者名",
"Timestamp": "タイムスタンプ",
"Detect duplicates in...": "重複を検出...",
"Numeric": "数値",
"Text": "テキスト",
"Integer": "整数",
"Toggle": "トグル",
"Date": "日付",
"DateTime": "日時",
"Choice": "選択",
"Choice List": "複数選択"
},
"DocMenu": {
"This service is not available right now": "このサービスは現在ご利用いただけません",
@ -899,7 +919,7 @@
"Close": "閉じる",
"Cancel": "キャンセル",
"Apply on changes to:": "変更を適用する:",
"Apply to new records": "新しいレコードに適用する",
"Apply to new records": "新しいレコードに適用",
"OK": "OK",
"Current field ": "現在のフィールド ",
"Any field": "任意のフィールド",
@ -913,7 +933,11 @@
"Click the Add New button to create new documents or workspaces, or import data.": "「新規追加」ボタンをクリックして、新しいドキュメントまたはワークスペースを作成するか、データをインポートします。",
"Apply conditional formatting to rows based on formulas.": "数式に基づいて条件付き書式を行に適用します。",
"Click on “Open row styles” to apply conditional formatting to rows.": "行に条件付き書式を適用するには、「行書式を開く」をクリックする。",
"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.": "参照列のセルは常にそのテーブル内の {{entire}} レコードを識別しますが、そのレコードからどの列を表示するかを選択することもできます。"
"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.": "参照列のセルは常にそのテーブル内の {{entire}} レコードを識別しますが、そのレコードからどの列を表示するかを選択することもできます。",
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "数式は多くの Excel 関数、完全な Python 構文をサポートし、便利な AI アシスタントが含まれています。",
"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.": "UUIDはランダムに生成される文字列で、一意の識別子やキーとして役立ちます。",
"The total size of all data in this document, excluding attachments.": "添付ファイルを除く、このドキュメント内のすべてのデータの合計サイズ。",
"Lookups return data from related tables.": "Lookupは関連テーブルからデータを返します。"
},
"AppHeader": {
"Personal Site": "個人サイト",
@ -928,7 +952,13 @@
"You do not have edit access to this document": "このドキュメントへのアクセス権がありません",
"Delete {{formattedTableName}} data, and remove it from all pages?": "{{formattedTableName}} データを削除し、すべてのページから削除しますか?",
"Click to copy": "クリックしてコピー",
"Table ID copied to clipboard": "クリップボードにテーブルIDをコピーしました"
"Table ID copied to clipboard": "クリップボードにテーブルIDをコピーしました",
"Edit Record Card": "レコードカードを編集",
"Record Card": "レコードカード",
"Record Card Disabled": "レコードカードを無効化",
"Remove Table": "テーブルを削除",
"Rename Table": "テーブル名を編集",
"{{action}} Record Card": "レコードカードを{{action}}"
},
"NTextBox": {
"false": "false",

@ -42,7 +42,8 @@
"When adding table rules, automatically add a rule to grant OWNER full access.": "Ao adicionar regras de tabela, adicione automaticamente uma regra para conceder ao PROPRIETÁRIO acesso total.",
"Permission to edit document structure": "Permissão para editar a estrutura do documento",
"This default should be changed if editors' access is to be limited. ": "Esse padrão deve ser alterado se o acesso dos editores for limitado. ",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Permita que os editores editem a estrutura (por exemplo, modifiquem e excluam tabelas, colunas, layouts) e escrevam fórmulas, que dão acesso a todos os dados, independentemente das restrições de leitura."
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Permita que os editores editem a estrutura (por exemplo, modifiquem e excluam tabelas, colunas, layouts) e escrevam fórmulas, que dão acesso a todos os dados, independentemente das restrições de leitura.",
"Add Table-wide Rule": "Adicionar regra para toda a tabela"
},
"AccountPage": {
"API": "API",
@ -305,7 +306,8 @@
"Document ID copied to clipboard": "ID do documento copiado para a área de transferência",
"API": "API",
"Manage Webhooks": "Gerenciar ganchos web",
"Webhooks": "Ganchos Web"
"Webhooks": "Ganchos Web",
"API Console": "Consola API"
},
"DocumentUsage": {
"Attachments Size": "Tamanho dos Anexos",
@ -464,7 +466,19 @@
"Add column": "Adicionar coluna",
"Last updated by": "Última atualização por",
"Detect duplicates in...": "Detectar duplicações em...",
"Last updated at": "Última atualização em"
"Last updated at": "Última atualização em",
"Any": "Qualquer",
"Numeric": "Numérico",
"Text": "Texto",
"Date": "Data",
"DateTime": "DataHora",
"Choice List": "Lista de opções",
"Reference": "Referência",
"Reference List": "Lista de referências",
"Attachment": "Anexo",
"Integer": "Inteiro",
"Toggle": "Alternar",
"Choice": "Opção"
},
"GristDoc": {
"Added new linked section to view {{viewName}}": "Adicionada nova seção vinculada para visualizar {{viewName}}}",
@ -667,7 +681,24 @@
"Fields_one": "Campo",
"Fields_other": "Campos",
"Add referenced columns": "Adicionar colunas referenciadas",
"Reset form": "Restaurar formulário"
"Reset form": "Restaurar formulário",
"Default field value": "Valor padrão do campo",
"Display button": "Botão de exibição",
"Enter text": "Digite texto",
"Field rules": "Regras de campo",
"Field title": "Título do campo",
"Hidden field": "Campo escondido",
"Redirection": "Redirecionamento",
"Submission": "Envio",
"Required field": "Campo necessário",
"Submit another response": "Enviar outra resposta",
"Submit button label": "Etiqueta do botão de envio",
"Table column name": "Nome da coluna da tabela",
"Enter redirect URL": "Insira URL de redirecionamento",
"Redirect automatically after submission": "Redirecionar automaticamente após o envio",
"Configuration": "Configuração",
"Success text": "Texto de sucesso",
"Layout": "Leiaute"
},
"RowContextMenu": {
"Copy anchor link": "Copiar o link de ancoragem",
@ -813,7 +844,8 @@
"Show raw data": "Mostrar dados primários",
"Widget options": "Opções do Widget",
"Collapse widget": "Colapsar widget",
"Add to page": "Adicionar à página"
"Add to page": "Adicionar à página",
"Create a form": "Criar um formulário"
},
"ViewSectionMenu": {
"(customized)": "(personalizado)",
@ -896,7 +928,11 @@
"You do not have access to this organization's documents.": "Você não tem acesso aos documentos desta organização.",
"Account deleted{{suffix}}": "Conta excluída{{suffix}}",
"Your account has been deleted.": "Sua conta foi excluída.",
"Sign up": "Cadastre-se"
"Sign up": "Cadastre-se",
"An unknown error occurred.": "Ocorreu um erro desconhecido.",
"Form not found": "Formulário não encontrado",
"Powered by": "Desenvolvido por",
"Build your own form": "Construa seu próprio formulário"
},
"menus": {
"* Workspaces are available on team plans. ": "* As áreas de trabalho estão disponíveis nos planos de equipe. ",
@ -919,7 +955,17 @@
"modals": {
"Cancel": "Cancelar",
"Ok": "OK",
"Save": "Salvar"
"Save": "Salvar",
"Are you sure you want to delete these records?": "Tem a certeza de que deseja apagar esses registros?",
"Undo to restore": "Desfazer para restaurar",
"Delete": "Eliminar",
"Are you sure you want to delete this record?": "Tem certeza de que deseja apagar este registro?",
"Dismiss": "Descartar",
"Don't ask again.": "Não perguntar novamente",
"Got it": "Entendido",
"Don't show again": "Não mostrar novamente",
"Don't show again.": "Não mostrar novamente.",
"Don't show tips": "Não mostrar dicas"
},
"pages": {
"Duplicate Page": "Duplicar a Página",
@ -1138,7 +1184,11 @@
"Lookups return data from related tables.": "As pesquisas retornam dados de tabelas relacionadas.",
"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Você pode escolher entre os widgets disponíveis no menu suspenso ou incorporar o seu próprio widget fornecendo o URL completo.",
"Use reference columns to relate data in different tables.": "Use colunas de referência para relacionar dados em diferentes tabelas.",
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "As fórmulas suportam muitas funções do Excel, sintaxe Python completa e incluem um assistente de IA útil."
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "As fórmulas suportam muitas funções do Excel, sintaxe Python completa e incluem um assistente de IA útil.",
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}",
"Forms are here!": "Os formulários chegaram!",
"Learn more": "Saiba mais",
"These rules are applied after all column rules have been processed, if applicable.": "Estas regras são aplicadas após todas as regras da coluna terem sido processadas, se aplicável."
},
"DescriptionConfig": {
"DESCRIPTION": "DESCRIÇÃO"
@ -1324,5 +1374,70 @@
},
"HiddenQuestionConfig": {
"Hidden fields": "Campos ocultos"
},
"FormView": {
"Publish": "Publicar",
"Publish your form?": "Publicar o seu formulário?",
"Unpublish your form?": "Despublicar seu formulário?",
"Unpublish": "Cancelar publicação"
},
"Menu": {
"Columns": "Colunas",
"Cut": "Cortar",
"Building blocks": "Blocos de construção",
"Unmapped fields": "Campos não mapeados",
"Insert question above": "Insira a questão acima",
"Insert question below": "Inserir questão abaixo",
"Paragraph": "Parágrafo",
"Paste": "Colar",
"Separator": "Separador",
"Copy": "Copiar",
"Header": "Cabeçalho"
},
"UnmappedFieldsConfig": {
"Unmap fields": "Desmapear campos",
"Unmapped": "Desmapeado",
"Mapped": "Mapeado",
"Select All": "Selecionar Todos",
"Map fields": "Mapear campos",
"Clear": "Limpar"
},
"Editor": {
"Delete": "Eliminar"
},
"WelcomeCoachingCall": {
"Schedule your {{freeCoachingCall}} with a member of our team.": "Programe seu {{freeCoachingCall}} com um membro da nossa equipe.",
"free coaching call": "chamada gratuita de treinamento",
"Maybe Later": "Talvez mais tarde",
"On the call, we'll take the time to understand your needs and tailor the call to you. We can show you the Grist basics, or start working with your data right away to build the dashboards you need.": "Na chamada, vamos ter tempo para entender suas necessidades e adaptar a chamada para você. Podemos mostrar-lhe os princípios básicos do Grist ou começar a trabalhar com os seus dados imediatamente para construir os painéis que você precisa.",
"Schedule Call": "Agendar chamada"
},
"FormConfig": {
"Field rules": "Regras de campo",
"Required field": "Campo obrigatório"
},
"CustomView": {
"Some required columns aren't mapped": "Algumas colunas obrigatórias não estão mapeadas",
"To use this widget, please map all non-optional columns from the creator panel on the right.": "Para usar este widget, mapeie todas as colunas não opcionais do painel criador à direita."
},
"FormContainer": {
"Build your own form": "Crie seu próprio formulário",
"Powered by": "Desenvolvido por"
},
"FormErrorPage": {
"Error": "Erro"
},
"FormModel": {
"Oops! The form you're looking for doesn't exist.": "Ops! O formulário que você está procurando não existe.",
"Oops! This form is no longer published.": "Ops! Este formulário não está mais publicado.",
"There was a problem loading the form.": "Houve um problema ao carregar o formulário.",
"You don't have access to this form.": "Você não tem acesso a este formulário."
},
"FormPage": {
"There was an error submitting your form. Please try again.": "Houve um erro ao enviar seu formulário. Por favor, tente novamente."
},
"FormSuccessPage": {
"Form Submitted": "Formulário enviado",
"Thank you! Your response has been recorded.": "Obrigado! Sua resposta foi registrada."
}
}

@ -36,7 +36,8 @@
"Permission to edit document structure": "Разрешение на редактирование структуры документа",
"When adding table rules, automatically add a rule to grant OWNER full access.": "При добавлении правил таблицы, автоматически добавить правило для предоставления ВЛАДЕЛЬЦУ полного доступа.",
"This default should be changed if editors' access is to be limited. ": "Это значение по умолчанию следует изменить, если требуется ограничить доступ редакторов. ",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Позволяет редакторам редактировать структуру (например, изменять и удалять таблицы, столбцы, макеты) и писать формулы, которые предоставляют доступ ко всем данным независимо от ограничений на чтение."
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Позволяет редакторам редактировать структуру (например, изменять и удалять таблицы, столбцы, макеты) и писать формулы, которые предоставляют доступ ко всем данным независимо от ограничений на чтение.",
"Add Table-wide Rule": "Добавить обще-табличное правило"
},
"ACUserManager": {
"Enter email address": "Введите адрес электронной почты",
@ -1119,7 +1120,11 @@
"Lookups return data from related tables.": "Lookups возвращают данные из связанных таблиц.",
"Use reference columns to relate data in different tables.": "Используйте ссылочные столбцы для сопоставления данных в разных таблицах.",
"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Вы можете выбрать виджеты, доступные вам в раскрывающемся списке, или встроить свой собственный, указав его полный URL-адрес.",
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Формулы поддерживают множество функций Excel, полный синтаксис Python и включает полезного помощника AI."
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Формулы поддерживают множество функций Excel, полный синтаксис Python и включает полезного помощника AI.",
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Создавайте простые формы прямо в Grist и делитесь ими одним щелчком мыши с помощью нашего нового виджета.. {{learnMoreButton}}",
"Forms are here!": "Формы уже здесь!",
"Learn more": "Узнать больше",
"These rules are applied after all column rules have been processed, if applicable.": "Эти правила применяются после обработки всех правил столбцов, если это применимо."
},
"DescriptionConfig": {
"DESCRIPTION": "ОПИСАНИЕ"
@ -1342,5 +1347,33 @@
},
"Editor": {
"Delete": "Удалить"
},
"FormConfig": {
"Field rules": "Правила полей",
"Required field": "Обязательное поле"
},
"CustomView": {
"Some required columns aren't mapped": "Некоторые обязательные столбцы не сопоставлены",
"To use this widget, please map all non-optional columns from the creator panel on the right.": "Чтобы использовать этот виджет, сопоставьте все необязательные столбцы с панели создателя справа."
},
"FormContainer": {
"Build your own form": "Создайте свою собственную форму",
"Powered by": "Разработано"
},
"FormModel": {
"There was a problem loading the form.": "Возникла проблема с загрузкой формы.",
"You don't have access to this form.": "У вас нет доступа к этой форме.",
"Oops! The form you're looking for doesn't exist.": "Ой! Форма, которую вы ищете, не существует.",
"Oops! This form is no longer published.": "Ой! Эта форма больше не публикуется."
},
"FormErrorPage": {
"Error": "Ошибка"
},
"FormPage": {
"There was an error submitting your form. Please try again.": "При отправке формы произошла ошибка. Пожалуйста, попробуйте еще раз."
},
"FormSuccessPage": {
"Form Submitted": "Форма отправлена",
"Thank you! Your response has been recorded.": "Спасибо! Ваш ответ учтен."
}
}

@ -36,7 +36,8 @@
"Attribute to Look Up": "Atribut za iskanje",
"Lookup Table": "Preglednica za iskanje",
"This default should be changed if editors' access is to be limited. ": "To privzeto nastavitev je treba spremeniti, če je treba omejiti dostop urednikov. ",
"Seed rules": "Privzete pravice"
"Seed rules": "Privzete pravice",
"Add Table-wide Rule": "Dodaj pravilo za celotno tabelo"
},
"ACUserManager": {
"We'll email an invite to {{email}}": "Vabilo bomo poslali po e-pošti {{email}}",
@ -727,7 +728,11 @@
"Lookups return data from related tables.": "Iskanje vrne podatke iz povezanih tabel.",
"Use reference columns to relate data in different tables.": "Uporabite referenčne stolpce za povezavo podatkov v različnih tabelah.",
"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Izbirate lahko med pripomočki, ki so vam na voljo v spustnem meniju, ali vdelate svojega tako, da navedete njegov polni URL.",
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Formule podpirajo številne Excelove funkcije, polno Pythonovo sintakso in vključujejo koristnega AI pomočnika."
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Formule podpirajo številne Excelove funkcije, polno Pythonovo sintakso in vključujejo koristnega AI pomočnika.",
"Forms are here!": "Obrazci so tukaj!",
"Learn more": "Nauči se več",
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Ustvari preproste obrazce neposredno v Gristu in jih deli z enim klikom z našim novim pripomočkom. {{learnMoreButton}}",
"These rules are applied after all column rules have been processed, if applicable.": "Ta pravila se uporabijo, ko so obdelana vsa pravila stolpcev, če so na voljo."
},
"UserManager": {
"Anyone with link ": "Vsakdo s povezavo ",
@ -1342,5 +1347,33 @@
"Schedule your {{freeCoachingCall}} with a member of our team.": "Dogovori se za {{freeCoachingCall}} s članom naše ekipe.",
"Maybe Later": "Mogoče kasneje",
"On the call, we'll take the time to understand your needs and tailor the call to you. We can show you the Grist basics, or start working with your data right away to build the dashboards you need.": "Med klicem si bomo vzeli čas, da bomo razumeli vaše potrebe in vam klic prilagodili. Lahko vam pokažemo osnove Grista ali pa takoj začnemo delati z vašimi podatki, da zgradimo nadzorne plošče, ki jih potrebujete."
},
"FormConfig": {
"Required field": "Obvezno polje",
"Field rules": "Pravila polj"
},
"CustomView": {
"To use this widget, please map all non-optional columns from the creator panel on the right.": "Če želite uporabiti ta pripomoček, preslikajte vse neobvezne stolpce na plošči za ustvarjanje na desni.",
"Some required columns aren't mapped": "Nekateri zahtevani stolpci niso preslikani"
},
"FormContainer": {
"Build your own form": "Ustvari svoj obrazec",
"Powered by": "Poganja ga"
},
"FormErrorPage": {
"Error": "Napaka"
},
"FormModel": {
"Oops! The form you're looking for doesn't exist.": "Ups! Obrazec, ki ga iščeš, ne obstaja.",
"Oops! This form is no longer published.": "Ups! Ta obrazec ni več objavljen.",
"There was a problem loading the form.": "Pri nalaganju obrazca je prišlo do težave.",
"You don't have access to this form.": "Nimaš dostopa do tega obrazca."
},
"FormPage": {
"There was an error submitting your form. Please try again.": "Pri pošiljanju obrazca je prišlo do napake. Prosim poskusi ponovno."
},
"FormSuccessPage": {
"Form Submitted": "Obrazec oddan",
"Thank you! Your response has been recorded.": "Hvala ti! Tvoj odgovor je bil zabeležen."
}
}

@ -240,12 +240,18 @@ describe('gristUrlState', function() {
// Check form URLs in prod setup. They are produced on document pages.
await state.pushUrl({org: 'foo', doc: 'abc'});
state.loadState();
assert.equal(state.makeUrl({doc: undefined, form: { vsId: 4, shareKey: 'key' }}),
'https://foo.example.com/forms/key/4');
assert.equal(state.makeUrl({api: true, doc: 'abc', form: { vsId: 4 }}),
'https://foo.example.com/api/docs/abc/forms/4');
assert.equal(state.makeUrl({api: true, form: { vsId: 4 }}),
'https://foo.example.com/api/docs/abc/forms/4');
assert.equal(
state.makeUrl({doc: undefined, form: {vsId: 4, shareKey: 'key'}}),
'https://foo.example.com/forms/key/4'
);
assert.equal(
state.makeUrl({doc: 'abc', form: {vsId: 4}}),
'https://foo.example.com/doc/abc/f/4'
);
assert.equal(
state.makeUrl({doc: 'abc', slug: '123', form: {vsId: 4}}),
'https://foo.example.com/abc/123/f/4'
);
});
it('should produce correct results with single-org config', async function() {
@ -279,12 +285,18 @@ describe('gristUrlState', function() {
// Check form URLs in single org setup from document pages.
await state.pushUrl({org: 'foo', doc: 'abc'});
state.loadState();
assert.equal(state.makeUrl({doc: undefined, form: { vsId: 4, shareKey: 'key' }}),
'https://example.com/o/foo/forms/key/4');
assert.equal(state.makeUrl({api: true, doc: 'abc', form: { vsId: 4 }}),
'https://example.com/o/foo/api/docs/abc/forms/4');
assert.equal(state.makeUrl({api: true, form: { vsId: 4 }}),
'https://example.com/o/foo/api/docs/abc/forms/4');
assert.equal(
state.makeUrl({doc: undefined, form: {vsId: 4, shareKey: 'key'}}),
'https://example.com/o/foo/forms/key/4'
);
assert.equal(
state.makeUrl({doc: 'abc', form: {vsId: 4}}),
'https://example.com/o/foo/doc/abc/f/4'
);
assert.equal(
state.makeUrl({doc: 'abc', slug: '123', form: {vsId: 4}}),
'https://example.com/o/foo/abc/123/f/4'
);
});
it('should produce correct results with custom config', async function() {

@ -79,6 +79,8 @@ describe('ActionLog', function() {
await gu.undo(2);
await driver.navigate().refresh();
await gu.waitForDocToLoad();
// Dismiss forms announcement popup, if present.
await gu.dismissBehavioralPrompts();
// refreshing browser will restore position on last cell
// switch active cell to the first cell in the first row
await gu.getCell(0, 1).click();

@ -40,9 +40,9 @@ describe('AttachedCustomWidget', function () {
.header('Content-Type', 'text/html')
.send('<html><head><script src="/grist-plugin-api.js"></script></head><body>\n' +
(req.query.name || req.query.access) + // send back widget name from query string or access level
'</body>'+
"<script>grist.ready({requiredAccess: 'full', columns: [{name: 'Content', type: 'Text'}],"+
" onEditOptions(){}})</script>"+
'</body>' +
"<script>grist.ready({requiredAccess: 'full', columns: [{name: 'Content', type: 'Text', optional: true}]," +
" onEditOptions(){}})</script>" +
'</html>\n')
.end()
);

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

@ -1,4 +1,4 @@
import {assert, driver, Key} from 'mocha-webdriver';
import {addToRepl, assert, driver, Key} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
import {addStatic, serveSomething} from 'test/server/customUtil';
@ -15,6 +15,7 @@ const NORMAL_WIDGET = 'Normal';
const READ_WIDGET = 'Read';
const FULL_WIDGET = 'Full';
const COLUMN_WIDGET = 'COLUMN_WIDGET';
const REQUIRED_WIDGET = 'REQUIRED_WIDGET';
// Custom URL label in selectbox.
const CUSTOM_URL = 'Custom URL';
// Holds url for sample widget server.
@ -129,6 +130,9 @@ describe('CustomWidgetsConfig', function () {
let mainSession: gu.Session;
gu.bigScreen();
addToRepl('getOptions', getOptions);
before(async function () {
if (server.isExternalServer()) {
this.skip();
@ -164,9 +168,15 @@ describe('CustomWidgetsConfig', function () {
{
// Widget with column mapping
name: COLUMN_WIDGET,
url: createConfigUrl({requiredAccess: AccessLevel.read_table, columns: ['Column']}),
url: createConfigUrl({requiredAccess: AccessLevel.read_table, columns: [{name:'Column', optional: true}]}),
widgetId: 'tester5',
},
{
// Widget with required column mapping
name: REQUIRED_WIDGET,
url: createConfigUrl({requiredAccess: AccessLevel.read_table, columns: [{name:'Column', optional: false}]}),
widgetId: 'tester6',
},
]);
});
addStatic(app);
@ -188,146 +198,6 @@ describe('CustomWidgetsConfig', function () {
await server.testingHooks.setWidgetRepositoryUrl('');
});
// Poor man widget rpc. Class that invokes various parts in the tester widget.
class Widget {
constructor() {}
// Wait for a frame.
public async waitForFrame() {
await driver.findWait(`iframe.test-custom-widget-ready`, 1000);
await driver.wait(async () => await driver.find('iframe').isDisplayed(), 1000);
await widget.waitForPendingRequests();
}
public async waitForPendingRequests() {
await this._inWidgetIframe(async () => {
await driver.executeScript('grist.testWaitForPendingRequests();');
});
}
public async content() {
return await this._read('body');
}
public async readonly() {
const text = await this._read('#readonly');
return text === 'true';
}
public async access() {
const text = await this._read('#access');
return text as AccessLevel;
}
public async onRecordMappings() {
const text = await this._read('#onRecordMappings');
return JSON.parse(text || 'null');
}
public async onRecords() {
const text = await this._read('#onRecords');
return JSON.parse(text || 'null');
}
public async onRecord() {
const text = await this._read('#onRecord');
return JSON.parse(text || 'null');
}
public async onRecordsMappings() {
const text = await this._read('#onRecordsMappings');
return JSON.parse(text || 'null');
}
public async log() {
const text = await this._read('#log');
return text || '';
}
// Wait for frame to close.
public async waitForClose() {
await driver.wait(async () => !(await driver.find('iframe').isPresent()), 3000);
}
// Wait for the onOptions event, and return its value.
public async onOptions() {
const text = await this._inWidgetIframe(async () => {
// Wait for options to get filled, initially this div is empty,
// as first message it should get at least null as an options.
await driver.wait(async () => await driver.find('#onOptions').getText(), 3000);
return await driver.find('#onOptions').getText();
});
return JSON.parse(text);
}
public async wasConfigureCalled() {
const text = await this._read('#configure');
return text === 'called';
}
public async setOptions(options: any) {
return await this.invokeOnWidget('setOptions', [options]);
}
public async setOption(key: string, value: any) {
return await this.invokeOnWidget('setOption', [key, value]);
}
public async getOption(key: string) {
return await this.invokeOnWidget('getOption', [key]);
}
public async clearOptions() {
return await this.invokeOnWidget('clearOptions');
}
public async getOptions() {
return await this.invokeOnWidget('getOptions');
}
public async mappings() {
return await this.invokeOnWidget('mappings');
}
public async clearLog() {
return await this.invokeOnWidget('clearLog');
}
// Invoke method on a Custom Widget.
// Each method is available as a button with content that is equal to the method name.
// It accepts single argument, that we pass by serializing it to #input textbox. Widget invokes
// the method and serializes its return value to #output div. When there is an error, it is also
// serialized to the #output div.
public async invokeOnWidget(name: string, input?: any[]) {
// Switch to frame.
const iframe = driver.find('iframe');
await driver.switchTo().frame(iframe);
// Clear input box that holds arguments.
await driver.find('#input').click();
await gu.clearInput();
// Serialize argument to the textbox (or leave empty).
if (input !== undefined) {
await driver.sendKeys(JSON.stringify(input));
}
// Find button that is responsible for invoking method.
await driver.findContent('button', gu.exactMatch(name)).click();
// Wait for the #output div to be filled with a result. Custom Widget will set it to
// "waiting..." before invoking the method.
await driver.wait(async () => (await driver.find('#output').value()) !== 'waiting...');
// Read the result.
const text = await driver.find('#output').getText();
// Switch back to main window.
await driver.switchTo().defaultContent();
// If the method was a void method, the output will be "undefined".
if (text === 'undefined') {
return; // Simulate void method.
}
// Result will always be parsed json.
const parsed = JSON.parse(text);
// All exceptions will be serialized to { error : <<Error.message>> }
if (parsed?.error) {
// Rethrow the error.
throw new Error(parsed.error);
} else {
// Or return result.
return parsed;
}
}
private async _read(selector: string) {
return this._inWidgetIframe(() => driver.find(selector).getText());
}
private async _inWidgetIframe<T>(callback: () => Promise<T>) {
const iframe = driver.find('iframe');
await driver.switchTo().frame(iframe);
const retVal = await callback();
await driver.switchTo().defaultContent();
return retVal;
}
}
// Rpc for main widget (Custom Widget).
const widget = new Widget();
beforeEach(async () => {
// Before each test, we will switch to Custom Url (to cleanup the widget)
// and then back to the Tester widget.
@ -337,6 +207,47 @@ describe('CustomWidgetsConfig', function () {
}
await toggleWidgetMenu();
await clickOption(TESTER_WIDGET);
await widget.waitForFrame();
});
it('should hide widget when some columns are not mapped', async () => {
// Reset the widget to the one that has a column mapping requirements.
await widget.resetWidget();
// Since the widget was reset, we don't have .test-custom-widget-ready element.
assert.isFalse(await driver.find('.test-custom-widget-ready').isPresent());
// Now select the widget that requires a column.
await toggleWidgetMenu();
await clickOption(REQUIRED_WIDGET);
await gu.acceptAccessRequest();
// The widget iframe should be covered with a text explaining that the widget is not configured.
assert.isTrue(await driver.findWait('.test-custom-widget-not-mapped', 1000).isDisplayed());
// The content should at least have those words:
assert.include(await driver.find('.test-custom-widget-not-mapped').getText(),
"Some required columns aren't mapped");
// Make sure that the iframe is not displayed.
assert.isFalse(await driver.find('.test-custom-widget-ready').isPresent());
// Now map the column.
await toggleDrop(pickerDrop('Column'));
// Map it to A.
await clickOption('A');
// Make sure that the text is gone.
await gu.waitToPass(async () => {
assert.isFalse(await driver.find('.test-config-widget-not-mapped').isPresent());
});
// Make sure the widget is now visible.
assert.isTrue(await driver.find('.test-custom-widget-ready').isDisplayed());
// And we see widget with info about mapped columns, Column to A.
assert.deepEqual(await widget.onRecordsMappings(), {Column: 'A'});
});
it('should hide mappings when there is no good column', async () => {
@ -346,7 +257,7 @@ describe('CustomWidgetsConfig', function () {
}
await gu.setWidgetUrl(
createConfigUrl({
columns: [{name: 'M2', type: 'Date'}],
columns: [{name: 'M2', type: 'Date', optional: true}],
requiredAccess: 'read table',
})
);
@ -382,7 +293,7 @@ describe('CustomWidgetsConfig', function () {
// Now expand the drop again and make sure we can't clear it.
await toggleDrop(pickerDrop('M2'));
assert.deepEqual(await getOptions(), ['NewCol']);
assert.deepEqual(await getOptions(), ['NewCol', 'Clear selection']);
// Now remove the column, and make sure that the drop is disabled again.
await driver.sendKeys(Key.ESCAPE);
@ -485,11 +396,8 @@ describe('CustomWidgetsConfig', function () {
requiredAccess: 'read table',
})
);
await widget.waitForFrame();
await gu.acceptAccessRequest();
await widget.waitForPendingRequests();
// Mappings should be empty
assert.isNull(await widget.onRecordsMappings());
await widget.waitForPlaceholder();
// We should see 4 pickers
assert.isTrue(await driver.find(pickerLabel('M1')).isPresent());
assert.isTrue(await driver.find(pickerLabel('M2')).isPresent());
@ -508,27 +416,20 @@ describe('CustomWidgetsConfig', function () {
// Should be able to select column A for all options
await toggleDrop(pickerDrop('M1'));
await clickOption('A');
await widget.waitForPendingRequests();
const empty = {M1: null, M2: null, M3: null, M4: null};
assert.deepEqual(await widget.onRecordsMappings(), {... empty, M1: 'A'});
await toggleDrop(pickerDrop('M2'));
await clickOption('A');
await widget.waitForPendingRequests();
assert.deepEqual(await widget.onRecordsMappings(), {... empty, M1: 'A', M2: 'A'});
await toggleDrop(pickerDrop('M3'));
await clickOption('A');
await widget.waitForPendingRequests();
assert.deepEqual(await widget.onRecordsMappings(), {... empty, M1: 'A', M2: 'A', M3: 'A'});
await toggleDrop(pickerDrop('M4'));
await clickOption('A');
await widget.waitForFrame();
await widget.waitForPendingRequests();
assert.deepEqual(await widget.onRecordsMappings(), {M1: 'A', M2: 'A', M3: 'A', M4: 'A'});
// Single record should also receive update.
assert.deepEqual(await widget.onRecordMappings(), {M1: 'A', M2: 'A', M3: 'A', M4: 'A'});
// Undo should revert mappings - there should be only 3 operations to revert to first mapping.
await gu.undo(3);
await widget.waitForPendingRequests();
assert.deepEqual(await widget.onRecordsMappings(), {... empty, M1: 'A'});
await widget.waitForPlaceholder();
// Add another columns, numeric B and any C.
await gu.selectSectionByTitle('Table');
await gu.addColumn('B');
@ -541,10 +442,6 @@ describe('CustomWidgetsConfig', function () {
assert.deepEqual(await getOptions(), ['A', 'B', 'C']);
await toggleDrop(pickerDrop('M4'));
assert.deepEqual(await getOptions(), ['A', 'C']);
await toggleDrop(pickerDrop('M1'));
await clickOption('B');
await widget.waitForPendingRequests();
assert.deepEqual(await widget.onRecordsMappings(), {...empty, M1: 'B'});
await revert();
});
@ -602,8 +499,8 @@ describe('CustomWidgetsConfig', function () {
await gu.setWidgetUrl(
createConfigUrl({
columns: [
{name: 'M1', allowMultiple: true},
{name: 'M2', type: 'Text', allowMultiple: true},
{name: 'M1', allowMultiple: true, optional: true},
{name: 'M2', type: 'Text', allowMultiple: true, optional: true},
],
requiredAccess: 'read table',
})
@ -686,8 +583,8 @@ describe('CustomWidgetsConfig', function () {
await gu.setWidgetUrl(
createConfigUrl({
columns: [
{name: 'M1', type: 'Date,DateTime'},
{name: 'M2', type: 'Date, DateTime ', allowMultiple: true},
{name: 'M1', type: 'Date,DateTime', optional: true},
{name: 'M2', type: 'Date, DateTime ', allowMultiple: true, optional: true},
],
requiredAccess: 'read table',
})
@ -747,10 +644,10 @@ describe('CustomWidgetsConfig', function () {
await gu.setWidgetUrl(
createConfigUrl({
columns: [
{name: 'Any', type: 'Any', strictType: true},
{name: 'Date_Numeric', type: 'Date, Numeric', strictType: true},
{name: 'Date_Any', type: 'Date, Any', strictType: true},
{name: 'Date', type: 'Date', strictType: true},
{name: 'Any', type: 'Any', strictType: true, optional: true},
{name: 'Date_Numeric', type: 'Date, Numeric', strictType: true, optional: true},
{name: 'Date_Any', type: 'Date, Any', strictType: true, optional: true},
{name: 'Date', type: 'Date', strictType: true, optional: true},
],
requiredAccess: 'read table',
})
@ -791,7 +688,7 @@ describe('CustomWidgetsConfig', function () {
await gu.setWidgetUrl(
createConfigUrl({
columns: [
{name: 'Choice', type: 'Choice', strictType: true},
{name: 'Choice', type: 'Choice', strictType: true, optional: true},
],
requiredAccess: 'read table',
})
@ -839,7 +736,7 @@ describe('CustomWidgetsConfig', function () {
await clickOption(CUSTOM_URL);
await gu.setWidgetUrl(
createConfigUrl({
columns: [{name: 'M1'}, {name: 'M2', allowMultiple: true}],
columns: [{name: 'M1', optional: true}, {name: 'M2', allowMultiple: true, optional: true}],
requiredAccess: 'read table',
})
);
@ -905,7 +802,7 @@ describe('CustomWidgetsConfig', function () {
// Add B column as a new one.
await toggleDrop(pickerDrop('M1'));
// Make sure it is there to select.
assert.deepEqual(await getOptions(), ['A', 'C', 'B']);
assert.deepEqual(await getOptions(), ['A', 'C', 'B', 'Clear selection']);
await clickOption('B');
await widget.waitForPendingRequests();
await click(pickerAdd('M2'));
@ -928,7 +825,10 @@ describe('CustomWidgetsConfig', function () {
await clickOption(CUSTOM_URL);
await gu.setWidgetUrl(
createConfigUrl({
columns: [{name: 'M1', type: 'Text'}, {name: 'M2', type: 'Text', allowMultiple: true}],
columns: [
{name: 'M1', type: 'Text', optional: true},
{name: 'M2', type: 'Text', allowMultiple: true, optional: true}
],
requiredAccess: 'read table',
})
);
@ -1220,3 +1120,152 @@ describe('CustomWidgetsConfig', function () {
await refresh();
});
});
// Poor man widget rpc. Class that invokes various parts in the tester widget.
const widget = {
async waitForPlaceholder() {
assert.isTrue(await driver.findWait('.test-custom-widget-not-mapped', 1000).isDisplayed());
},
// Wait for a frame.
async waitForFrame() {
await driver.findWait(`iframe.test-custom-widget-ready`, 1000);
await driver.wait(async () => await driver.find('iframe').isDisplayed(), 1000);
await widget.waitForPendingRequests();
},
async waitForPendingRequests() {
await this._inWidgetIframe(async () => {
await driver.executeScript('grist.testWaitForPendingRequests();');
});
},
async content() {
return await this._read('body');
},
async readonly() {
const text = await this._read('#readonly');
return text === 'true';
},
async access() {
const text = await this._read('#access');
return text as AccessLevel;
},
async onRecordMappings() {
const text = await this._read('#onRecordMappings');
return JSON.parse(text || 'null');
},
async onRecords() {
const text = await this._read('#onRecords');
return JSON.parse(text || 'null');
},
async onRecord() {
const text = await this._read('#onRecord');
return JSON.parse(text || 'null');
},
/**
* Reads last mapping parameter received by the widget as part of onRecords call.
*/
async onRecordsMappings() {
const text = await this._read('#onRecordsMappings');
return JSON.parse(text || 'null');
},
async log() {
const text = await this._read('#log');
return text || '';
},
// Wait for frame to close.
async waitForClose() {
await driver.wait(async () => !(await driver.find('iframe').isPresent()), 3000);
},
// Wait for the onOptions event, and return its value.
async onOptions() {
const text = await this._inWidgetIframe(async () => {
// Wait for options to get filled, initially this div is empty,
// as first message it should get at least null as an options.
await driver.wait(async () => await driver.find('#onOptions').getText(), 3000);
return await driver.find('#onOptions').getText();
});
return JSON.parse(text);
},
async wasConfigureCalled() {
const text = await this._read('#configure');
return text === 'called';
},
async setOptions(options: any) {
return await this.invokeOnWidget('setOptions', [options]);
},
async setOption(key: string, value: any) {
return await this.invokeOnWidget('setOption', [key, value]);
},
async getOption(key: string) {
return await this.invokeOnWidget('getOption', [key]);
},
async clearOptions() {
return await this.invokeOnWidget('clearOptions');
},
async getOptions() {
return await this.invokeOnWidget('getOptions');
},
async mappings() {
return await this.invokeOnWidget('mappings');
},
async clearLog() {
return await this.invokeOnWidget('clearLog');
},
// Invoke method on a Custom Widget.
// Each method is available as a button with content that is equal to the method name.
// It accepts single argument, that we pass by serializing it to #input textbox. Widget invokes
// the method and serializes its return value to #output div. When there is an error, it is also
// serialized to the #output div.
async invokeOnWidget(name: string, input?: any[]) {
// Switch to frame.
const iframe = driver.find('iframe');
await driver.switchTo().frame(iframe);
// Clear input box that holds arguments.
await driver.find('#input').click();
await gu.clearInput();
// Serialize argument to the textbox (or leave empty).
if (input !== undefined) {
await driver.sendKeys(JSON.stringify(input));
}
// Find button that is responsible for invoking method.
await driver.findContent('button', gu.exactMatch(name)).click();
// Wait for the #output div to be filled with a result. Custom Widget will set it to
// "waiting..." before invoking the method.
await driver.wait(async () => (await driver.find('#output').value()) !== 'waiting...');
// Read the result.
const text = await driver.find('#output').getText();
// Switch back to main window.
await driver.switchTo().defaultContent();
// If the method was a void method, the output will be "undefined".
if (text === 'undefined') {
return; // Simulate void method.
}
// Result will always be parsed json.
const parsed = JSON.parse(text);
// All exceptions will be serialized to { error : <<Error.message>> }
if (parsed?.error) {
// Rethrow the error.
throw new Error(parsed.error);
} else {
// Or return result.
return parsed;
}
},
async _read(selector: string) {
return this._inWidgetIframe(() => driver.find(selector).getText());
},
async _inWidgetIframe<T>(callback: () => Promise<T>) {
const iframe = driver.find('iframe');
await driver.switchTo().frame(iframe);
const retVal = await callback();
await driver.switchTo().defaultContent();
return retVal;
},
/**
* Resets the widget by first selecting Custom URL option from the menu, which clearOptions
* any existing widget state (even if the Custom URL was already selected).
*/
async resetWidget() {
await toggleWidgetMenu();
await clickOption(CUSTOM_URL);
}
};

@ -246,6 +246,8 @@ describe("Fork", function() {
await userSession.loadDoc(`/doc/${doc.id}/m/fork`);
assert.equal(await gu.getEmail(), userSession.email);
assert.equal(await driver.find('.test-unsaved-tag').isPresent(), false);
// Dismiss forms announcement popup, if present.
await gu.dismissBehavioralPrompts();
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('123');
await gu.waitForServer();

@ -1,4 +1,3 @@
import {CHOOSE_TEXT} from 'app/common/Forms';
import {UserAPI} from 'app/common/UserAPI';
import {escapeRegExp} from 'lodash';
import {addToRepl, assert, driver, Key, WebElement, WebElementPromise} from 'mocha-webdriver';
@ -99,8 +98,13 @@ describe('FormView', function() {
}
async function waitForConfirm() {
await gu.waitForServer();
await gu.waitToPass(async () => {
assert.isTrue(await driver.findWait('.grist-form-confirm', 1000).isDisplayed());
assert.isTrue(await driver.findWait('.test-form-container', 2000).isDisplayed());
assert.equal(
await driver.find('.test-form-success-text').getText(),
'Thank you! Your response has been recorded.'
);
});
}
@ -169,7 +173,7 @@ describe('FormView', function() {
await gu.closeRawTable();
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.find('input[type="submit"]').click();
await driver.findWait('input[type="submit"]', 2000).click();
await waitForConfirm();
});
await expectSingle('Hello from trigger');
@ -181,7 +185,7 @@ describe('FormView', function() {
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 1000).click();
await driver.findWait('input[name="D"]', 2000).click();
await gu.sendKeys('Hello World');
await driver.find('input[type="submit"]').click();
await waitForConfirm();
@ -196,7 +200,7 @@ describe('FormView', function() {
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 1000).click();
await driver.findWait('input[name="D"]', 2000).click();
await gu.sendKeys('1984');
await driver.find('input[type="submit"]').click();
await waitForConfirm();
@ -211,7 +215,7 @@ describe('FormView', function() {
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 1000).click();
await driver.findWait('input[name="D"]', 2000).click();
await driver.executeScript(
() => (document.querySelector('input[name="D"]') as HTMLInputElement).value = '2000-01-01'
);
@ -243,11 +247,12 @@ describe('FormView', function() {
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
const select = await driver.findWait('select[name="D"]', 2000);
// Make sure options are there.
assert.deepEqual(
await driver.findAll('select[name="D"] option', e => e.getText()), [CHOOSE_TEXT, 'Foo', 'Bar', 'Baz']
await driver.findAll('select[name="D"] option', e => e.getText()), ['— Choose —', 'Foo', 'Bar', 'Baz']
);
await driver.findWait('select[name="D"]', 1000).click();
await select.click();
await driver.find("option[value='Bar']").click();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
@ -261,7 +266,7 @@ describe('FormView', function() {
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 1000).click();
await driver.findWait('input[name="D"]', 2000).click();
await gu.sendKeys('1984');
await driver.find('input[type="submit"]').click();
await waitForConfirm();
@ -276,14 +281,14 @@ describe('FormView', function() {
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 1000).findClosest("label").click();
await driver.findWait('input[name="D"]', 2000).findClosest("label").click();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
await expectSingle(true);
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.find('input[type="submit"]').click();
await driver.findWait('input[type="submit"]', 2000).click();
await waitForConfirm();
});
await expectInD([true, false]);
@ -309,8 +314,8 @@ describe('FormView', function() {
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D[]"][value="Foo"]', 1000).click();
await driver.findWait('input[name="D[]"][value="Baz"]', 1000).click();
await driver.findWait('input[name="D[]"][value="Foo"]', 2000).click();
await driver.find('input[name="D[]"][value="Baz"]').click();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
@ -334,15 +339,16 @@ describe('FormView', function() {
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
const select = await driver.findWait('select[name="D"]', 2000);
assert.deepEqual(
await driver.findAll('select[name="D"] option', e => e.getText()),
[CHOOSE_TEXT, ...['Bar', 'Baz', 'Foo']]
['— Choose —', ...['Bar', 'Baz', 'Foo']]
);
assert.deepEqual(
await driver.findAll('select[name="D"] option', e => e.value()),
['', ...['2', '3', '1']]
);
await driver.findWait('select[name="D"]', 1000).click();
await select.click();
await driver.find('option[value="2"]').click();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
@ -373,11 +379,11 @@ describe('FormView', function() {
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D[]"][value="1"]', 1000).click();
await driver.findWait('input[name="D[]"][value="2"]', 1000).click();
assert.equal(await driver.find('.grist-checkbox:has(input[name="D[]"][value="1"])').getText(), 'Foo');
assert.equal(await driver.find('.grist-checkbox:has(input[name="D[]"][value="2"])').getText(), 'Bar');
assert.equal(await driver.find('.grist-checkbox:has(input[name="D[]"][value="3"])').getText(), 'Baz');
await driver.findWait('input[name="D[]"][value="1"]', 2000).click();
await driver.find('input[name="D[]"][value="2"]').click();
assert.equal(await driver.find('label:has(input[name="D[]"][value="1"])').getText(), 'Foo');
assert.equal(await driver.find('label:has(input[name="D[]"][value="2"])').getText(), 'Bar');
assert.equal(await driver.find('label:has(input[name="D[]"][value="3"])').getText(), 'Baz');
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
@ -391,7 +397,7 @@ describe('FormView', function() {
await removeForm();
});
it('can submit a form with a formula field', async function() {
it('excludes formula fields from forms', async function() {
const formUrl = await createFormWith('Text');
// Temporarily make A a formula column.
@ -401,12 +407,12 @@ describe('FormView', function() {
]);
assert.deepEqual(await api.getTable(docId, 'Table1').then(t => t.A), ['hello']);
// Check that A is excluded from the form, and we can still submit it.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 1000).click();
await driver.findWait('input[name="D"]', 2000).click();
await gu.sendKeys('Hello World');
await driver.find('input[name="_A"]').click();
await gu.sendKeys('goodbye');
assert.isFalse(await driver.find('input[name="A"]').isPresent());
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
@ -431,9 +437,10 @@ describe('FormView', function() {
await gu.waitForServer();
await gu.onNewTab(async () => {
await driver.get(formUrl);
assert.match(
await driver.findWait('.test-error-text', 2000).getText(),
/Oops! This form is no longer published\./
assert.isTrue(await driver.findWait('.test-form-container', 2000).isDisplayed());
assert.equal(
await driver.find('.test-form-error-text').getText(),
'Oops! This form is no longer published.'
);
});
@ -443,7 +450,7 @@ describe('FormView', function() {
await gu.waitForServer();
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 1000);
await driver.findWait('input[name="D"]', 2000);
});
});
@ -1250,7 +1257,7 @@ describe('FormView', function() {
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 1000).click();
await driver.findWait('input[name="D"]', 2000).click();
await gu.sendKeys('Hello World');
await driver.find('input[type="submit"]').click();
await waitForConfirm();

@ -259,6 +259,8 @@ describe('GridViewNewColumnMenu', function () {
await gu.waitForServer();
//discard rename menu
await driver.findWait('.test-column-title-close', STANDARD_WAITING_TIME).click();
// Wait for the sidepanel animation.
await gu.waitForSidePanel();
//check if right menu is opened on column section
assert.isTrue(await driver.findWait('.test-right-tab-field', 1000).isDisplayed());
await gu.toggleSidePanel("right", "close");

@ -735,6 +735,7 @@ describe('RawData', function () {
await gu.openPage('CountryLanguage');
await gu.getCell(0, 1).find('.test-ref-link-icon').click();
assert.isFalse(await driver.find('.test-record-card-popup-overlay').isPresent());
await gu.wipeToasts(); // notification build-up can cover setType button.
await gu.setType('Reference List', {apply: true});
await gu.getCell(0, 1).find('.test-ref-list-link-icon').click();
assert.isFalse(await driver.find('.test-record-card-popup-overlay').isPresent());

@ -4,7 +4,7 @@ import * as gu from 'test/nbrowser/gristUtils';
import {server, setupTestSuite} from "test/nbrowser/testUtils";
describe('RemoveTransformColumns', function () {
this.timeout(4000);
this.timeout(10000);
setupTestSuite();
it('should remove transform columns when the doc shuts down', async function () {

@ -3393,7 +3393,7 @@ export async function hasAccessPrompt() {
* Accepts new access level.
*/
export async function acceptAccessRequest() {
await driver.find('.test-config-widget-access-accept').click();
await driver.findWait('.test-config-widget-access-accept', 1000).click();
}
/**

Loading…
Cancel
Save