Merge branch 'main' into header-from-row

pull/848/head
CamilleLegeron 3 months ago
commit 28db292f96

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

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

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

@ -10,7 +10,7 @@ import {Notifier} from 'app/client/models/NotifyModel';
import {getFlavor, ProductFlavor} from 'app/client/ui/CustomThemes';
import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrades';
import {SupportGristNudge} from 'app/client/ui/SupportGristNudge';
import {attachCssThemeVars, prefersDarkModeObs} from 'app/client/ui2018/cssVars';
import {prefersDarkModeObs} from 'app/client/ui2018/cssVars';
import {AsyncCreate} from 'app/common/AsyncCreate';
import {ICustomWidget} from 'app/common/CustomWidget';
import {OrgUsageSummary} from 'app/common/DocUsage';
@ -28,7 +28,6 @@ import {getGristConfig} from 'app/common/urlUtils';
import {getOrgName, isTemplatesOrg, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
import {getUserPrefObs, getUserPrefsObs, markAsSeen, markAsUnSeen} from 'app/client/models/UserPrefs';
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
import isEqual from 'lodash/isEqual';
const t = makeT('AppModel');
@ -48,7 +47,6 @@ const G = getBrowserGlobals('document', 'window');
// TopAppModel is the part of the app model that persists across org and user switches.
export interface TopAppModel {
options: TopAppModelOptions;
api: UserAPI;
isSingleOrg: boolean;
productFlavor: ProductFlavor;
@ -148,11 +146,6 @@ export interface AppModel {
switchUser(user: FullUser, org?: string): Promise<void>;
}
export interface TopAppModelOptions {
/** Defaults to true. */
attachTheme?: boolean;
}
export class TopAppModelImpl extends Disposable implements TopAppModel {
public readonly isSingleOrg: boolean;
public readonly productFlavor: ProductFlavor;
@ -170,11 +163,7 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
// up new widgets - that seems ok.
private readonly _widgets: AsyncCreate<ICustomWidget[]>;
constructor(
window: {gristConfig?: GristLoadConfig},
public readonly api: UserAPI = newUserAPIImpl(),
public readonly options: TopAppModelOptions = {}
) {
constructor(window: {gristConfig?: GristLoadConfig}, public readonly api: UserAPI = newUserAPIImpl()) {
super();
setErrorNotifier(this.notifier);
this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg);
@ -356,8 +345,6 @@ export class AppModelImpl extends Disposable implements AppModel {
public readonly orgError?: OrgError,
) {
super();
this._setUpTheme();
this._recordSignUpIfIsNewUser();
const state = urlState().state.get();
@ -531,23 +518,6 @@ export class AppModelImpl extends Disposable implements AppModel {
},
);
}
private _setUpTheme() {
if (
this.topAppModel.options.attachTheme === false ||
// Custom CSS is incompatible with custom themes.
getGristConfig().enableCustomCss
) {
return;
}
attachCssThemeVars(this.currentTheme.get());
this.autoDispose(this.currentTheme.addListener((newTheme, oldTheme) => {
if (isEqual(newTheme, oldTheme)) { return; }
attachCssThemeVars(newTheme);
}));
}
}
export function getHomeUrl(): string {

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

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

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

@ -0,0 +1,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,38 @@
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {setupLocale} from 'app/client/lib/localization';
import {AppModel, TopAppModelImpl} from 'app/client/models/AppModel';
import {reportError, setUpErrorHandling} from 'app/client/models/errors';
import {buildSnackbarDom} from 'app/client/ui/NotifyUI';
import {addViewportTag} from 'app/client/ui/viewport';
import {attachCssRootVars, attachTheme} from 'app/client/ui2018/cssVars';
import {BaseAPI} from 'app/common/BaseAPI';
import {dom, DomContents} from 'grainjs';
const G = getBrowserGlobals('document', 'window');
/**
* Sets up the application model, error handling, and global styles, and replaces
* the DOM body with the result of calling `buildAppPage`.
*/
export function createAppPage(buildAppPage: (appModel: AppModel) => DomContents) {
setUpErrorHandling();
const topAppModel = TopAppModelImpl.create(null, {});
addViewportTag();
attachCssRootVars(topAppModel.productFlavor);
setupLocale().catch(reportError);
// Add globals needed by test utils.
G.window.gristApp = {
testNumPendingApiRequests: () => BaseAPI.numPendingRequests(),
};
dom.update(document.body, dom.maybeOwned(topAppModel.appObs, (owner, appModel) => {
owner.autoDispose(attachTheme(appModel.currentTheme));
return [
buildAppPage(appModel),
buildSnackbarDom(appModel.notifier, appModel),
];
}));
}

@ -0,0 +1,39 @@
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {setupLocale} from 'app/client/lib/localization';
import {reportError, setErrorNotifier, setUpErrorHandling} from 'app/client/models/errors';
import {Notifier} from 'app/client/models/NotifyModel';
import {buildSnackbarDom} from 'app/client/ui/NotifyUI';
import {addViewportTag} from 'app/client/ui/viewport';
import {attachCssRootVars, attachTheme, prefersColorSchemeThemeObs} from 'app/client/ui2018/cssVars';
import {BaseAPI} from 'app/common/BaseAPI';
import {dom, DomContents} from 'grainjs';
const G = getBrowserGlobals('document', 'window');
/**
* Sets up error handling and global styles, and replaces the DOM body with the
* result of calling `buildPage`.
*/
export function createPage(buildPage: () => DomContents, options: {disableTheme?: boolean} = {}) {
const {disableTheme} = options;
setUpErrorHandling();
addViewportTag();
attachCssRootVars('grist');
setupLocale().catch(reportError);
// Add globals needed by test utils.
G.window.gristApp = {
testNumPendingApiRequests: () => BaseAPI.numPendingRequests(),
};
const notifier = Notifier.create(null);
setErrorNotifier(notifier);
dom.update(document.body, () => [
disableTheme ? null : dom.autoDispose(attachTheme(prefersColorSchemeThemeObs())),
buildPage(),
buildSnackbarDom(notifier, null),
]);
}

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

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

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

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

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

@ -147,8 +147,6 @@ export interface IGristUrlState {
// But this barely works, and is suitable only for documents. For decoding it
// indicates that the URL probably points to an API endpoint.
viaShare?: boolean; // Accessing document via a special share.
// Form URLs can currently be encoded but not decoded.
form?: {
vsId: number; // a view section id of a form.
shareKey?: string; // only one of shareKey or doc should be set.
@ -284,31 +282,15 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
if (state.docPage) {
parts.push(`/p/${state.docPage}`);
}
if (state.form) {
parts.push(`/f/${state.form.vsId}`);
}
} else if (state.form?.shareKey) {
parts.push(`forms/${encodeURIComponent(state.form.shareKey)}/${encodeURIComponent(state.form.vsId)}`);
} else if (state.homePage === 'trash' || state.homePage === 'templates') {
parts.push(`p/${state.homePage}`);
}
/**
* Form URLS can take two forms. If a docId/urlId is set, rather than
* a share key, the returned form URL will only be accessible by users
* with access to the document. This is currently only used for the
* preview functionality in the widget, where document access is a
* pre-requisite.
*
* When a share key is set, the returned form URL will be accessible
* by anyone, so long as the form is published.
*
* Only one of `doc` (docId/urlId) or `shareKey` should be set.
*/
if (state.form) {
if (state.doc) { parts.push('/'); }
parts.push('forms/');
if (state.form.shareKey) {
parts.push(state.form.shareKey + '/');
}
parts.push(String(state.form.vsId));
}
if (state.account) {
parts.push(state.account === 'account' ? 'account' : `account/${state.account}`);
}
@ -392,13 +374,21 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
const parts = location.pathname.slice(1).split('/');
const state: IGristUrlState = {};
// Bare minimum we can do to detect API URLs.
if (parts[0] === 'api') { // When it starts with /api/...
parts.shift();
state.api = true;
} else if (parts[0] === 'o' && parts[2] === 'api') { // or with /o/{org}/api/...
parts.splice(2, 1);
// Bare minimum we can do to detect API URLs: if it starts with /api/ or /o/{org}/api/...
if (parts[0] === 'api' || (parts[0] === 'o' && parts[2] === 'api')) {
state.api = true;
parts.splice(parts[0] === 'api' ? 0 : 2, 1);
}
// Bare minimum we can do to detect form URLs with share keys: if it starts with /forms/ or /o/{org}/forms/...
if (parts[0] === 'forms' || (parts[0] === 'o' && parts[2] === 'forms')) {
const startIndex = parts[0] === 'forms' ? 0 : 2;
// Form URLs have two parts to extract: the share key and the view section id.
state.form = {
shareKey: parts[startIndex + 1],
vsId: parseInt(parts[startIndex + 2], 10),
};
parts.splice(startIndex, 3);
}
const map = new Map<string, string>();
@ -447,6 +437,7 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
if (fork.forkId) { state.fork = fork; }
if (map.has('slug')) { state.slug = map.get('slug'); }
if (map.has('p')) { state.docPage = parseDocPage(map.get('p')!); }
if (map.has('f')) { state.form = {vsId: parseInt(map.get('f')!, 10)}; }
} else {
if (map.has('p')) {
const p = map.get('p')!;
@ -1038,7 +1029,7 @@ export function buildUrlId(parts: UrlIdParts): string {
// may be in a docId (leaving just the hyphen, which is permitted). The limits
// could be loosened, but without much benefit.
const codedSnapshotId = encodeURIComponent(parts.snapshotId)
.replace(/[_.!~*'()]/g, ch => `_${ch.charCodeAt(0).toString(16).toUpperCase()}`)
.replace(/[_.!~*'()-]/g, ch => `_${ch.charCodeAt(0).toString(16).toUpperCase()}`)
.replace(/%/g, '_');
token = `${token}~v=${codedSnapshotId}`;
}

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

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

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

@ -1284,7 +1284,7 @@ export class FlexServer implements GristServer {
this._addSupportPaths(docAccessMiddleware);
if (!isSingleUserMode()) {
addDocApiRoutes(this.app, docWorker, this._docWorkerMap, docManager, this._dbManager, this, this.appRoot);
addDocApiRoutes(this.app, docWorker, this._docWorkerMap, docManager, this._dbManager, this);
}
}
@ -1513,7 +1513,6 @@ export class FlexServer implements GristServer {
if (resp.headersSent || !this._sendAppPage) { return next(err); }
try {
const errPage = (
err.details?.code === 'FormNotFound' ? 'form-not-found' :
err.status === 403 ? 'access-denied' :
err.status === 404 ? 'not-found' :
'other-error'

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

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

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

@ -1,19 +0,0 @@
## grist-form-submit.js
File is taken from https://github.com/gristlabs/grist-form-submit. But it is modified to work with
forms, especially for:
- Ref and RefList columns, as by default it sends numbers as strings (FormData issue), and Grist
doesn't know how to convert them back to numbers.
- Empty strings are not sent at all - otherwise Grist won't be able to fire trigger formulas
correctly and provide default values for columns.
- By default it requires a redirect URL, now it is optional.
## purify.min.js
File taken from https://www.npmjs.com/package/dompurify. It is used to sanitize HTML. It wasn't
modified at all.
## form.html
This is handlebars template filled by DocApi.ts

@ -1,533 +0,0 @@
html,
body {
background-color: #f7f7f7;
padding: 0px;
margin: 0px;
line-height: 1.42857143;
}
* {
box-sizing: border-box;
}
.grist-form-container {
--icon-Tick: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjYxODMwNjksNC42NzcwMjg0NyBDMTEuNzk2Njc4OSw0LjQ2NjIyNTE3IDEyLjExMjE2NzgsNC40Mzk5MzQ0MyAxMi4zMjI5NzExLDQuNjE4MzA2NDUgQzEyLjUzMzc3NDQsNC43OTY2Nzg0OCAxMi41NjAwNjUyLDUuMTEyMTY3NDEgMTIuMzgxNjkzMSw1LjMyMjk3MDcxIEw2LjUzMDY4ODI3LDEyLjIzNzc5NDYgTDMuNjQ2NDQ2NjEsOS4zNTM1NTI5OCBDMy40NTExODQ0Niw5LjE1ODI5MDg0IDMuNDUxMTg0NDYsOC44NDE3MDgzNSAzLjY0NjQ0NjYxLDguNjQ2NDQ2MiBDMy44NDE3MDg3Niw4LjQ1MTE4NDA2IDQuMTU4MjkxMjQsOC40NTExODQwNiA0LjM1MzU1MzM5LDguNjQ2NDQ2MiBMNi40NjkzMTE3MywxMC43NjIyMDQ1IEwxMS42MTgzMDY5LDQuNjc3MDI4NDcgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+);
--icon-Minus: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB4PSIyIiB5PSI3LjUiIHdpZHRoPSIxMiIgaGVpZ2h0PSIxIiByeD0iLjUiLz48L3N2Zz4=);
--icon-Expand: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsOS4xNzQ2MzA1MiBMMTAuOTIxODI3Myw2LjE4OTAyMzIgQzExLjE2ODQ3NDIsNS45MzY5OTIyNyAxMS41NjgzNjc5LDUuOTM2OTkyMjcgMTEuODE1MDE0OCw2LjE4OTAyMzIgQzEyLjA2MTY2MTcsNi40NDEwNTQxMyAxMi4wNjE2NjE3LDYuODQ5Njc3MDEgMTEuODE1MDE0OCw3LjEwMTcwNzk0IEw4LDExIEw0LjE4NDk4NTE5LDcuMTAxNzA3OTQgQzMuOTM4MzM4MjcsNi44NDk2NzcwMSAzLjkzODMzODI3LDYuNDQxMDU0MTMgNC4xODQ5ODUxOSw2LjE4OTAyMzIgQzQuNDMxNjMyMTEsNS45MzY5OTIyNyA0LjgzMTUyNTc4LDUuOTM2OTkyMjcgNS4wNzgxNzI3LDYuMTg5MDIzMiBMOCw5LjE3NDYzMDUyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIgdHJhbnNmb3JtPSJyb3RhdGUoLTkwIDggOC41KSIvPjwvc3ZnPg==');
--primary: #16b378;
--primary-dark: #009058;
--dark-gray: #D9D9D9;
--light-gray: #bfbfbf;
--light: white;
color: #262633;
background-color: #f7f7f7;
min-height: 100%;
width: 100%;
padding: 52px 0px 52px 0px;
font-size: 15px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Liberation Sans", Helvetica, Arial, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
}
.grist-form-container .grist-form-confirm {
background-color: white;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
border: 1px solid var(--dark-gray);
border-radius: 3px;
max-width: 600px;
margin: 0px auto;
}
.grist-form {
margin: 0px auto;
background-color: white;
border: 1px solid var(--dark-gray);
width: 600px;
border-radius: 8px;
display: flex;
flex-direction: column;
max-width: calc(100% - 32px);
margin-bottom: 16px;
padding-top: 20px;
--grist-form-padding: 48px;
padding-left: var(--grist-form-padding);
padding-right: var(--grist-form-padding);
}
@media screen and (max-width: 600px) {
.grist-form-container {
padding: 20px 0px 20px 0px;
}
.grist-form {
--grist-form-padding: 20px;
}
}
.grist-form > div + div {
margin-top: 16px;
}
.grist-form .grist-section {
border-radius: 3px;
border: 1px solid var(--dark-gray);
padding: 16px 24px;
padding: 24px;
margin-top: 24px;
}
.grist-form .grist-section > div + div {
margin-top: 16px;
}
.grist-form input[type="text"],
.grist-form input[type="date"],
.grist-form input[type="datetime-local"],
.grist-form input[type="number"] {
height: 27px;
padding: 4px 8px;
border: 1px solid var(--dark-gray);
border-radius: 3px;
outline: none;
}
.grist-form .grist-field {
display: flex;
flex-direction: column;
}
.grist-form .grist-field .grist-field-description {
color: #222;
font-size: 12px;
font-weight: 400;
margin-top: 4px;
white-space: pre-wrap;
font-style: italic;
font-weight: 400;
line-height: 1.6;
}
.grist-form .grist-field input[type="text"] {
padding: 4px 8px;
border-radius: 3px;
border: 1px solid var(--dark-gray);
font-size: 13px;
outline-color: var(--primary);
outline-width: 1px;
line-height: inherit;
width: 100%;
}
.grist-form .grist-submit, .grist-form-container button {
display: flex;
justify-content: center;
align-items: center;
}
.grist-form input[type="submit"], .grist-form-container button {
background-color: var(--primary);
border: 1px solid var(--primary);
color: white;
padding: 10px 24px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
line-height: inherit;
}
.grist-form input[type="datetime-local"] {
width: 100%;
line-height: inherit;
}
.grist-form input[type="date"] {
width: 100%;
line-height: inherit;
}
.grist-form .grist-columns {
display: grid;
grid-template-columns: repeat(var(--grist-columns-count), 1fr);
gap: 4px;
}
.grist-form select {
padding: 4px 8px;
border-radius: 3px;
border: 1px solid var(--dark-gray);
font-size: 13px;
outline-color: var(--primary);
outline-width: 1px;
background: white;
line-height: inherit;
height: 27px;
flex: auto;
width: 100%;
}
.grist-form .grist-checkbox-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.grist-form .grist-checkbox {
display: flex;
}
.grist-form .grist-checkbox:hover {
--color: var(--light-gray);
}
.grist-form input[type="checkbox"] {
-webkit-appearance: none;
-moz-appearance: none;
padding: 0;
flex-shrink: 0;
display: inline-block;
width: 16px;
height: 16px;
--radius: 3px;
position: relative;
margin-right: 8px;
vertical-align: baseline;
}
.grist-form input[type="checkbox"]:checked:enabled, .grist-form input[type="checkbox"]:indeterminate:enabled {
--color: var(--primary);
}
.grist-form input[type="checkbox"]:disabled {
--color: var(--dark-gray);
cursor: not-allowed;
}
.grist-form input[type="checkbox"]::before, .grist-form input[type="checkbox"]::after {
content: '';
position: absolute;
top: 0;
left: 0;
height: 16px;
width: 16px;
box-sizing: border-box;
border: 1px solid var(--color, var(--dark-gray));
border-radius: var(--radius);
}
.grist-form input[type="checkbox"]:checked::before, .grist-form input[type="checkbox"]:disabled::before, .grist-form input[type="checkbox"]:indeterminate::before {
background-color: var(--color);
}
.grist-form input[type="checkbox"]:not(:checked):indeterminate::after {
-webkit-mask-image: var(--icon-Minus);
}
.grist-form input[type="checkbox"]:not(:disabled)::after {
background-color: var(--light);
}
.grist-form input[type="checkbox"]:checked::after, .grist-form input[type="checkbox"]:indeterminate::after {
content: '';
position: absolute;
height: 16px;
width: 16px;
-webkit-mask-image: var(--icon-Tick);
-webkit-mask-size: contain;
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
background-color: var(--light);
}
.grist-form .grist-submit input[type="submit"]:hover, .grist-form-container button:hover {
border-color: var(--primary-dark);
background-color: var(--primary-dark);
}
.grist-power-by {
color: #494949;
font-size: 13px;
font-style: normal;
font-weight: 600;
line-height: 16px;
display: flex;
align-items: center;
justify-content: center;
padding-left: 10px;
padding-right: 10px;
}
.grist-power-by a {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #494949;
text-decoration: none;
}
.grist-logo {
width: 58px;
height: 20.416px;
flex-shrink: 0;
background: url(logo.png);
background-position: 0 0;
background-size: contain;
background-color: transparent;
background-repeat: no-repeat;
margin-top: 3px;
}
.grist-question > .grist-label {
color: var(--dark, #262633);
font-size: 13px;
font-style: normal;
font-weight: 700;
line-height: 16px; /* 145.455% */
margin-top: 8px;
margin-bottom: 8px;
display: block;
}
.grist-label-required::after {
content: "*";
color: var(--primary, #16b378);
margin-left: 4px;
}
/* Markdown reset */
.grist-form h1,
.grist-form h2,
.grist-form h3,
.grist-form h4,
.grist-form h5,
.grist-form h6 {
margin: 4px 0px;
font-weight: normal;
}
.grist-form h1 {
font-size: 24px;
}
.grist-form h2 {
font-size: 22px;
}
.grist-form h3 {
font-size: 16px;
}
.grist-form h4 {
font-size: 13px;
}
.grist-form h5 {
font-size: 11px;
}
.grist-form h6 {
font-size: 10px;
}
.grist-form p {
margin: 0px;
}
.grist-form strong {
font-weight: 600;
}
.grist-form hr {
border: 0px;
border-top: 1px solid var(--dark-gray);
margin: 4px 0px;
}
.grist-text-left {
text-align: left;
}
.grist-text-right {
text-align: right;
}
.grist-text-center {
text-align: center;
}
.grist-switch {
cursor: pointer;
display: inline-flex;
align-items: center;
}
.grist-switch input[type='checkbox']::after {
content: none;
}
.grist-switch input[type='checkbox']::before {
content: none;
}
.grist-switch input[type='checkbox'] {
position: absolute;
}
.grist-switch > span {
margin-left: 8px;
}
/* Slider component */
.grist-widget_switch {
position: relative;
width: 30px;
height: 17px;
display: inline-block;
flex: none;
}
.grist-switch_slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--grist-theme-switch-slider-fg, #ccc);
border-radius: 17px;
}
.grist-switch_slider:hover {
box-shadow: 0 0 1px #2196F3;
}
.grist-switch_circle {
position: absolute;
cursor: pointer;
content: "";
height: 13px;
width: 13px;
left: 2px;
bottom: 2px;
background-color: var(--grist-theme-switch-circle-fg, white);
border-radius: 17px;
}
input:checked + .grist-switch_transition > .grist-switch_slider {
background-color: var(--primary, #16b378);
}
input:checked + .grist-switch_transition > .grist-switch_circle {
-webkit-transform: translateX(13px);
-ms-transform: translateX(13px);
transform: translateX(13px);
}
.grist-switch_on > .grist-switch_slider {
background-color: var(--grist-actual-cell-color, #2CB0AF);
}
.grist-switch_on > .grist-switch_circle {
-webkit-transform: translateX(13px);
-ms-transform: translateX(13px);
transform: translateX(13px);
}
.grist-switch_transition > .grist-switch_slider, .grist-switch_transition > .grist-switch_circle {
-webkit-transition: .4s;
transition: .4s;
}
.grist-form-confirm-container {
padding-left: 16px;
padding-right: 16px;
}
.grist-form-confirm-body {
padding: 48px 16px 16px 16px;
}
.grist-form-confirm-image {
width: 100%;
height: 100%;
max-width: 250px;
max-height: 215px;
}
.grist-form-confirm-text {
font-weight: 600;
font-size: 16px;
line-height: 24px;
margin-top: 32px;
white-space: prewrap;
}
.grist-form-confirm-buttons {
display: flex;
justify-content: center;
align-items: center;
margin-top: 24px;
}
.grist-form-confirm-new-response-button {
position: relative;
outline: none;
border-style: none;
line-height: normal;
user-select: none;
display: flex;
justify-content: center;
align-items: center;
padding: 12px 24px;
min-height: 40px;
background: var(--primary, #16B378);
border-radius: 3px;
color: #FFFFFF;
}
.grist-form-confirm-new-response-button:hover {
background: var(--primary-dark);
cursor: pointer;
}
.grist-form-footer,
.grist-form-confirm-footer {
border-top: 1px solid var(--dark-gray);
padding: 8px 16px;
}
.grist-form-footer {
margin-left: calc(-1 * var(--grist-form-padding));
margin-right: calc(-1 * var(--grist-form-padding));
}
.grist-form-confirm-footer {
width: 100%;
}
.grist-form-build-form-link-container {
display: flex;
align-items: center;
justify-content: center;
margin-top: 8px;
}
.grist-form-build-form-link {
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
line-height: 16px;
text-decoration-line: underline;
color: var(--primary-dark);
}
.grist-form-icon {
position: relative;
display: inline-block;
vertical-align: middle;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: contain;
width: 16px;
height: 16px;
background-color: black;
}
.grist-form-icon-expand {
-webkit-mask-image: var(--icon-Expand);
background-color: var(--primary-dark);
}

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

@ -1,211 +0,0 @@
// If the script is loaded multiple times, only register the handlers once.
if (!window.gristFormSubmit) {
(function() {
/**
* gristFormSubmit(gristDocUrl, gristTableId, formData)
* - `gristDocUrl` should be the URL of the Grist document, from step 1 of the setup instructions.
* - `gristTableId` should be the table ID from step 2.
* - `formData` should be a [FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
* object, typically obtained as `new FormData(formElement)`. Inside the `submit` event handler, it
* can be convenient to use `new FormData(event.target)`.
* - formElement is the form element that was submitted.
*
* This function sends values from `formData` to add a new record in the specified Grist table. It
* returns a promise for the result of the add-record API call. In case of an error, the promise
* will be rejected with an error message.
*/
async function gristFormSubmit(docUrl, tableId, formData, formElement) {
// Pick out the server and docId from the docUrl.
const match = /^(https?:\/\/[^\/]+(?:\/o\/[^\/]+)?)\/(?:doc\/([^\/?#]+)|([^\/?#]{12,})\/)/.exec(docUrl);
if (!match) { throw new Error("Invalid Grist doc URL " + docUrl); }
const server = match[1];
const docId = match[2] || match[3];
// Construct the URL to use for the add-record API endpoint.
const destUrl = server + "/api/docs/" + docId + "/tables/" + tableId + "/records";
const payload = {records: [{fields: formDataToJson(formData, formElement)}]};
const options = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
};
const resp = await window.fetch(destUrl, options);
if (resp.status !== 200) {
// Try to report a helpful error.
let body = '', error, match;
try { body = await resp.json(); } catch (e) {}
if (typeof body.error === 'string' && (match = /KeyError '(.*)'/.exec(body.error))) {
error = 'No column "' + match[1] + '" in table "' + tableId + '". ' +
'Be sure to use column ID rather than column label';
} else {
error = body.error || String(body);
}
throw new Error('Failed to add record: ' + error);
}
return await resp.json();
}
// Convert FormData into a mapping of Grist fields. Skips any keys starting with underscore.
// For fields with multiple values (such as to populate ChoiceList), use field names like `foo[]`
// (with the name ending in a pair of empty square brackets).
function formDataToJson(f) {
const keys = Array.from(f.keys()).filter(k => !k.startsWith("_"));
return Object.fromEntries(keys.map(k =>
k.endsWith('[]') ? [k.slice(0, -2), ['L', ...f.getAll(k)]] : [k, f.get(k)]));
}
/**
* TypedFormData is a wrapper around FormData that provides type information for the fields.
*/
class TypedFormData {
constructor(formElement, formData) {
if (!(formElement instanceof HTMLFormElement)) throw new Error("formElement must be a form");
if (formData && !(formData instanceof FormData)) throw new Error("formData must be a FormData");
this._formData = formData ?? new FormData(formElement);
this._formElement = formElement;
}
keys() {
const keys = Array.from(this._formData.keys());
// Don't return keys for scalar values which just return empty string.
// Otherwise Grist won't fire trigger formulas.
return keys.filter(key => {
// If there are multiple values, return this key as it is.
if (this._formData.getAll(key).length !== 1) { return true; }
// If the value is empty string or null, don't return the key.
const value = this._formData.get(key);
return value !== '' && value !== null;
});
}
type(key) {
return this._formElement?.querySelector(`[name="${key}"]`)?.getAttribute('data-grist-type');
}
get(key) {
const value = this._formData.get(key);
if (value === null) { return null; }
const type = this.type(key);
return type === 'Ref' || type === 'RefList' ? Number(value) : value;
}
getAll(key) {
const values = Array.from(this._formData.getAll(key));
if (['Ref', 'RefList'].includes(this.type(key))) {
return values.map(v => Number(v));
}
return values;
}
}
// Handle submissions for plain forms that include special data-grist-* attributes.
async function handleSubmitPlainForm(ev) {
if (!['data-grist-doc', 'data-grist-table']
.some(attr => ev.target.hasAttribute(attr))) {
// This form isn't configured for Grist at all; don't interfere with it.
return;
}
ev.preventDefault();
try {
const docUrl = ev.target.getAttribute('data-grist-doc');
const tableId = ev.target.getAttribute('data-grist-table');
if (!docUrl) { throw new Error("Missing attribute data-grist-doc='GRIST_DOC_URL'"); }
if (!tableId) { throw new Error("Missing attribute data-grist-table='GRIST_TABLE_ID'"); }
const successUrl = ev.target.getAttribute('data-grist-success-url');
await gristFormSubmit(docUrl, tableId, new TypedFormData(ev.target));
// On success, redirect to the requested URL.
if (successUrl) {
window.location.href = successUrl;
}
} catch (err) {
reportSubmitError(ev, err);
}
}
function reportSubmitError(ev, err) {
console.warn("grist-form-submit error:", err.message);
// Find an element to use for the validation message to alert the user.
let scapegoat = null;
(
(scapegoat = ev.submitter)?.setCustomValidity ||
(scapegoat = ev.target.querySelector('input[type=submit]'))?.setCustomValidity ||
(scapegoat = ev.target.querySelector('button'))?.setCustomValidity ||
(scapegoat = [...ev.target.querySelectorAll('input')].pop())?.setCustomValidity
)
scapegoat?.setCustomValidity("Form misconfigured: " + err.message);
ev.target.reportValidity();
}
// Handle submissions for Contact Form 7 forms.
async function handleSubmitWPCF7(ev) {
try {
const formId = ev.detail.contactFormId;
const docUrl = ev.target.querySelector('[data-grist-doc]')?.getAttribute('data-grist-doc');
const tableId = ev.target.querySelector('[data-grist-table]')?.getAttribute('data-grist-table');
if (!docUrl) { throw new Error("Missing attribute data-grist-doc='GRIST_DOC_URL'"); }
if (!tableId) { throw new Error("Missing attribute data-grist-table='GRIST_TABLE_ID'"); }
await gristFormSubmit(docUrl, tableId, new TypedFormData(ev.target));
console.log("grist-form-submit WPCF7 Form %s: Added record", formId);
} catch (err) {
console.warn("grist-form-submit WPCF7 Form %s misconfigured:", formId, err.message);
}
}
function setUpGravityForms(options) {
// Use capture to get the event before GravityForms processes it.
document.addEventListener('submit', ev => handleSubmitGravityForm(ev, options), true);
}
gristFormSubmit.setUpGravityForms = setUpGravityForms;
async function handleSubmitGravityForm(ev, options) {
try {
ev.preventDefault();
ev.stopPropagation();
const docUrl = options.docUrl;
const tableId = options.tableId;
if (!docUrl) { throw new Error("setUpGravityForm: missing docUrl option"); }
if (!tableId) { throw new Error("setUpGravityForm: missing tableId option"); }
const f = new TypedFormData(ev.target);
for (const key of Array.from(f.keys())) {
// Skip fields other than input fields.
if (!key.startsWith("input_")) {
f.delete(key);
continue;
}
// Rename multiple fields to use "[]" convention rather than ".N" convention.
const multi = key.split(".");
if (multi.length > 1) {
f.append(multi[0] + "[]", f.get(key));
f.delete(key);
}
}
console.warn("Processed FormData", f);
await gristFormSubmit(docUrl, tableId, f);
// Follow through by doing the form submission normally.
ev.target.submit();
} catch (err) {
reportSubmitError(ev, err);
return;
}
}
window.gristFormSubmit = gristFormSubmit;
document.addEventListener('submit', handleSubmitPlainForm);
document.addEventListener('wpcf7mailsent', handleSubmitWPCF7);
})();
}

File diff suppressed because one or more lines are too long

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

@ -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",
@ -1123,7 +1124,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"
@ -1350,5 +1352,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."
}
}

@ -1173,7 +1173,10 @@
"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}}"
},
"DescriptionConfig": {
"DESCRIPTION": "DESCRIPCIÓN"
@ -1396,5 +1399,9 @@
"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"
}
}

File diff suppressed because it is too large Load Diff

@ -293,7 +293,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.",
@ -431,7 +432,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",
@ -619,7 +631,25 @@
"SELECTOR FOR": "SÉLECTEUR",
"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 soumission",
"Success text": "Message de succès",
"Table column name": "Nom de la colonne",
"Enter redirect URL": "Saisir l'URL de redirection",
"Reset form": "Restaurer le formulaire",
"Submit another response": "Soumettre une autre réponse",
"Required field": "Champ obligatoire"
},
"RowContextMenu": {
"Insert row": "Insérer une ligne",
@ -692,7 +722,8 @@
"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"
@ -754,7 +785,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 +865,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 +892,17 @@
"modals": {
"Save": "Enregistrer",
"Cancel": "Annuler",
"Ok": "OK"
"Ok": "OK",
"Don't show tips": "Ne pas montrer 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 +1121,10 @@
"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à!"
},
"ColumnTitle": {
"Add description": "Ajouter une description",
@ -1258,5 +1307,49 @@
"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 à vos besoins. Nous pouvons vous montrer les bases de Grist, ou commencer à travailler avec vos données immédiatement 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és",
"Select All": "Tout sélectionner",
"Unmap fields": "Champs non utilisés",
"Unmapped": "Non utilisés",
"Clear": "Effacer",
"Map fields": "Champs utilisés"
},
"FormConfig": {
"Field rules": "Règles du champ",
"Required field": "Champ obligatoire"
},
"Editor": {
"Delete": "Supprimer"
}
}

@ -727,7 +727,10 @@
"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}}"
},
"UserManager": {
"Anyone with link ": "Vsakdo s povezavo ",
@ -1342,5 +1345,9 @@
"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"
}
}

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

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