mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Refactor forms implementation
Summary: WIP Test Plan: Existing tests. Reviewers: jarek Reviewed By: jarek Subscribers: jarek Differential Revision: https://phab.getgrist.com/D4196
This commit is contained in:
@@ -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));
|
||||
|
||||
360
app/client/components/FormRenderer.ts
Normal file
360
app/client/components/FormRenderer.ts
Normal file
@@ -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,
|
||||
};
|
||||
254
app/client/components/FormRendererCss.ts
Normal file
254
app/client/components/FormRendererCss.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import {colors, vars} from 'app/client/ui2018/cssVars';
|
||||
import {styled} from 'grainjs';
|
||||
|
||||
export const label = styled('div', `
|
||||
&-required::after {
|
||||
content: "*";
|
||||
color: ${vars.primaryBg};
|
||||
margin-left: 4px;
|
||||
}
|
||||
`);
|
||||
|
||||
export const paragraph = styled('div', `
|
||||
&-alignment-left {
|
||||
text-align: left;
|
||||
}
|
||||
&-alignment-center {
|
||||
text-align: center;
|
||||
}
|
||||
&-alignment-right {
|
||||
text-align: right;
|
||||
}
|
||||
`);
|
||||
|
||||
export const section = styled('div', `
|
||||
border-radius: 3px;
|
||||
border: 1px solid ${colors.darkGrey};
|
||||
padding: 24px;
|
||||
margin-top: 24px;
|
||||
|
||||
& > div + div {
|
||||
margin-top: 16px;
|
||||
}
|
||||
`);
|
||||
|
||||
export const columns = styled('div', `
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--grist-columns-count), 1fr);
|
||||
gap: 4px;
|
||||
`);
|
||||
|
||||
export const submit = styled('div', `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
& input[type="submit"] {
|
||||
background-color: ${vars.primaryBg};
|
||||
border: 1px solid ${vars.primaryBg};
|
||||
color: white;
|
||||
padding: 10px 24px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
line-height: inherit;
|
||||
}
|
||||
& input[type="submit"]:hover {
|
||||
border-color: ${vars.primaryBgHover};
|
||||
background-color: ${vars.primaryBgHover};
|
||||
}
|
||||
`);
|
||||
|
||||
// TODO: break up into multiple variables, one for each field type.
|
||||
export const field = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& input[type="text"],
|
||||
& input[type="date"],
|
||||
& input[type="datetime-local"],
|
||||
& input[type="number"] {
|
||||
height: 27px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid ${colors.darkGrey};
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
}
|
||||
& input[type="text"] {
|
||||
font-size: 13px;
|
||||
outline-color: ${vars.primaryBg};
|
||||
outline-width: 1px;
|
||||
line-height: inherit;
|
||||
width: 100%;
|
||||
color: ${colors.dark};
|
||||
background-color: ${colors.light};
|
||||
}
|
||||
& input[type="datetime-local"],
|
||||
& input[type="date"] {
|
||||
width: 100%;
|
||||
line-height: inherit;
|
||||
}
|
||||
& input[type="checkbox"] {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
--radius: 3px;
|
||||
position: relative;
|
||||
margin-right: 8px;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
& input[type="checkbox"]:checked:enabled,
|
||||
& input[type="checkbox"]:indeterminate:enabled {
|
||||
--color: ${vars.primaryBg};
|
||||
}
|
||||
& input[type="checkbox"]:disabled {
|
||||
--color: ${colors.darkGrey};
|
||||
cursor: not-allowed;
|
||||
}
|
||||
& input[type="checkbox"]::before,
|
||||
& input[type="checkbox"]::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--color, ${colors.darkGrey});
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
& input[type="checkbox"]:checked::before,
|
||||
& input[type="checkbox"]:disabled::before,
|
||||
& input[type="checkbox"]:indeterminate::before {
|
||||
background-color: var(--color);
|
||||
}
|
||||
& input[type="checkbox"]:not(:checked):indeterminate::after {
|
||||
-webkit-mask-image: var(--icon-Minus);
|
||||
}
|
||||
& input[type="checkbox"]:not(:disabled)::after {
|
||||
background-color: ${colors.light};
|
||||
}
|
||||
& input[type="checkbox"]:checked::after,
|
||||
& input[type="checkbox"]:indeterminate::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
-webkit-mask-image: var(--icon-Tick);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-position: center;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
background-color: ${colors.light};
|
||||
}
|
||||
& > .${label.className} {
|
||||
color: ${colors.dark};
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: 16px; /* 145.455% */
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
`);
|
||||
|
||||
export const error = styled('div', `
|
||||
text-align: center;
|
||||
color: ${colors.error};
|
||||
min-height: 22px;
|
||||
`);
|
||||
|
||||
export const toggle = styled('label', `
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
& input[type='checkbox'] {
|
||||
position: absolute;
|
||||
}
|
||||
& > span {
|
||||
margin-left: 8px;
|
||||
}
|
||||
`);
|
||||
|
||||
export const gristSwitchSlider = styled('div', `
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
border-radius: 17px;
|
||||
-webkit-transition: .4s;
|
||||
transition: .4s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 1px #2196F3;
|
||||
}
|
||||
`);
|
||||
|
||||
export const gristSwitchCircle = styled('div', `
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
content: "";
|
||||
height: 13px;
|
||||
width: 13px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background-color: white;
|
||||
border-radius: 17px;
|
||||
-webkit-transition: .4s;
|
||||
transition: .4s;
|
||||
`);
|
||||
|
||||
export const gristSwitch = styled('div', `
|
||||
position: relative;
|
||||
width: 30px;
|
||||
height: 17px;
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
|
||||
input:checked + & > .${gristSwitchSlider.className} {
|
||||
background-color: ${vars.primaryBg};
|
||||
}
|
||||
|
||||
input:checked + & > .${gristSwitchCircle.className} {
|
||||
-webkit-transform: translateX(13px);
|
||||
-ms-transform: translateX(13px);
|
||||
transform: translateX(13px);
|
||||
}
|
||||
`);
|
||||
|
||||
export const checkboxList = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`);
|
||||
|
||||
export const checkbox = styled('label', `
|
||||
display: flex;
|
||||
|
||||
&:hover {
|
||||
--color: ${colors.hover};
|
||||
}
|
||||
`);
|
||||
|
||||
export const select = styled('select', `
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid ${colors.darkGrey};
|
||||
font-size: 13px;
|
||||
outline-color: ${vars.primaryBg};
|
||||
outline-width: 1px;
|
||||
background: white;
|
||||
line-height: inherit;
|
||||
height: 27px;
|
||||
flex: auto;
|
||||
width: 100%;
|
||||
`);
|
||||
@@ -1,3 +1,4 @@
|
||||
import {FormLayoutNode} from 'app/client/components/FormRenderer';
|
||||
import {buildEditor} from 'app/client/components/Forms/Editor';
|
||||
import {FieldModel} from 'app/client/components/Forms/Field';
|
||||
import {buildMenu} from 'app/client/components/Forms/Menu';
|
||||
@@ -6,7 +7,6 @@ import * as style from 'app/client/components/Forms/styles';
|
||||
import {makeTestId} from 'app/client/lib/domUtils';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import * as menus from 'app/client/ui2018/menus';
|
||||
import {Box} from 'app/common/Forms';
|
||||
import {inlineStyle, not} from 'app/common/gutil';
|
||||
import {bundleChanges, Computed, dom, IDomArgs, MultiHolder, Observable, styled} from 'grainjs';
|
||||
|
||||
@@ -28,7 +28,7 @@ export class ColumnsModel extends BoxModel {
|
||||
}
|
||||
|
||||
// Dropping a box on this component (Columns) directly will add it as a new column.
|
||||
public accept(dropped: Box): BoxModel {
|
||||
public accept(dropped: FormLayoutNode): BoxModel {
|
||||
if (!this.parent) { throw new Error('No parent'); }
|
||||
|
||||
// We need to remove it from the parent, so find it first.
|
||||
@@ -206,7 +206,7 @@ export class PlaceholderModel extends BoxModel {
|
||||
...args,
|
||||
);
|
||||
|
||||
function insertBox(childBox: Box) {
|
||||
function insertBox(childBox: FormLayoutNode) {
|
||||
// Make sure we have at least as many columns as the index we are inserting at.
|
||||
if (!box.parent) { throw new Error('No parent'); }
|
||||
return box.parent.replace(box, childBox);
|
||||
@@ -218,15 +218,15 @@ export class PlaceholderModel extends BoxModel {
|
||||
}
|
||||
}
|
||||
|
||||
export function Paragraph(text: string, alignment?: 'left'|'right'|'center'): Box {
|
||||
export function Paragraph(text: string, alignment?: 'left'|'right'|'center'): FormLayoutNode {
|
||||
return {type: 'Paragraph', text, alignment};
|
||||
}
|
||||
|
||||
export function Placeholder(): Box {
|
||||
export function Placeholder(): FormLayoutNode {
|
||||
return {type: 'Placeholder'};
|
||||
}
|
||||
|
||||
export function Columns(): Box {
|
||||
export function Columns(): FormLayoutNode {
|
||||
return {type: 'Columns', children: [Placeholder(), Placeholder()]};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {CHOOSE_TEXT, FormLayoutNode} from 'app/client/components/FormRenderer';
|
||||
import {buildEditor} from 'app/client/components/Forms/Editor';
|
||||
import {FormView} from 'app/client/components/Forms/FormView';
|
||||
import {BoxModel, ignoreClick} from 'app/client/components/Forms/Model';
|
||||
@@ -7,7 +8,6 @@ import {refRecord} from 'app/client/models/DocModel';
|
||||
import {autoGrow} from 'app/client/ui/forms';
|
||||
import {squareCheckbox} from 'app/client/ui2018/checkbox';
|
||||
import {colors} from 'app/client/ui2018/cssVars';
|
||||
import {Box, CHOOSE_TEXT} from 'app/common/Forms';
|
||||
import {Constructor, not} from 'app/common/gutil';
|
||||
import {
|
||||
BindableValue,
|
||||
@@ -78,7 +78,7 @@ export class FieldModel extends BoxModel {
|
||||
return instance;
|
||||
});
|
||||
|
||||
constructor(box: Box, parent: BoxModel | null, view: FormView) {
|
||||
constructor(box: FormLayoutNode, parent: BoxModel | null, view: FormView) {
|
||||
super(box, parent, view);
|
||||
|
||||
this.required = Computed.create(this, (use) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import BaseView from 'app/client/components/BaseView';
|
||||
import * as commands from 'app/client/components/commands';
|
||||
import {Cursor} from 'app/client/components/Cursor';
|
||||
import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer';
|
||||
import * as components from 'app/client/components/Forms/elements';
|
||||
import {NewBox} from 'app/client/components/Forms/Menu';
|
||||
import {BoxModel, LayoutModel, parseBox, Place} from 'app/client/components/Forms/Model';
|
||||
@@ -16,13 +17,13 @@ import DataTableModel from 'app/client/models/DataTableModel';
|
||||
import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {ShareRec} from 'app/client/models/entities/ShareRec';
|
||||
import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {docUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
import {SortedRowSet} from 'app/client/models/rowset';
|
||||
import {showTransientTooltip} from 'app/client/ui/tooltips';
|
||||
import {cssButton} from 'app/client/ui2018/buttons';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {confirmModal} from 'app/client/ui2018/modals';
|
||||
import {Box, BoxType, INITIAL_FIELDS_COUNT} from "app/common/Forms";
|
||||
import {INITIAL_FIELDS_COUNT} from 'app/common/Forms';
|
||||
import {Events as BackboneEvents} from 'backbone';
|
||||
import {Computed, dom, Holder, IDomArgs, MultiHolder, Observable} from 'grainjs';
|
||||
import defaults from 'lodash/defaults';
|
||||
@@ -47,7 +48,7 @@ export class FormView extends Disposable {
|
||||
protected menuHolder: Holder<any>;
|
||||
protected bundle: (clb: () => Promise<void>) => Promise<void>;
|
||||
|
||||
private _autoLayout: Computed<Box>;
|
||||
private _autoLayout: Computed<FormLayoutNode>;
|
||||
private _root: BoxModel;
|
||||
private _savedLayout: any;
|
||||
private _saving: boolean = false;
|
||||
@@ -290,14 +291,14 @@ export class FormView extends Disposable {
|
||||
// Sanity check that type is correct.
|
||||
if (!colIds.every(c => typeof c === 'string')) { throw new Error('Invalid column id'); }
|
||||
this._root.save(async () => {
|
||||
const boxes: Box[] = [];
|
||||
const boxes: FormLayoutNode[] = [];
|
||||
for (const colId of colIds) {
|
||||
const fieldRef = await this.viewSection.showColumn(colId);
|
||||
const field = this.viewSection.viewFields().all().find(f => f.getRowId() === fieldRef);
|
||||
if (!field) { continue; }
|
||||
const box = {
|
||||
leaf: fieldRef,
|
||||
type: 'Field' as BoxType,
|
||||
type: 'Field' as FormLayoutNodeType,
|
||||
};
|
||||
boxes.push(box);
|
||||
}
|
||||
@@ -333,8 +334,7 @@ export class FormView extends Disposable {
|
||||
const doc = use(this.gristDoc.docPageModel.currentDoc);
|
||||
if (!doc) { return ''; }
|
||||
const url = urlState().makeUrl({
|
||||
api: true,
|
||||
doc: doc.id,
|
||||
...docUrl(doc),
|
||||
form: {
|
||||
vsId: use(this.viewSection.id),
|
||||
},
|
||||
@@ -723,11 +723,11 @@ export class FormView extends Disposable {
|
||||
* Generates a form template based on the fields in the view section.
|
||||
*/
|
||||
private _formTemplate(fields: ViewFieldRec[]) {
|
||||
const boxes: Box[] = fields.map(f => {
|
||||
const boxes: FormLayoutNode[] = fields.map(f => {
|
||||
return {
|
||||
type: 'Field',
|
||||
leaf: f.id()
|
||||
} as Box;
|
||||
} as FormLayoutNode;
|
||||
});
|
||||
const section = {
|
||||
type: 'Section',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {allCommands} from 'app/client/components/commands';
|
||||
import {FormLayoutNodeType} from 'app/client/components/FormRenderer';
|
||||
import * as components from 'app/client/components/Forms/elements';
|
||||
import {FormView} from 'app/client/components/Forms/FormView';
|
||||
import {BoxModel, Place} from 'app/client/components/Forms/Model';
|
||||
@@ -7,14 +8,13 @@ import {FocusLayer} from 'app/client/lib/FocusLayer';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {getColumnTypes as getNewColumnTypes} from 'app/client/ui/GridViewMenus';
|
||||
import * as menus from 'app/client/ui2018/menus';
|
||||
import {BoxType} from 'app/common/Forms';
|
||||
import {Computed, dom, IDomArgs, MultiHolder} from 'grainjs';
|
||||
|
||||
const t = makeT('FormView');
|
||||
const testId = makeTestId('test-forms-menu-');
|
||||
|
||||
// New box to add, either a new column of type, an existing column (by column id), or a structure.
|
||||
export type NewBox = {add: string} | {show: string} | {structure: BoxType};
|
||||
export type NewBox = {add: string} | {show: string} | {structure: FormLayoutNodeType};
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -77,7 +77,7 @@ export function buildMenu(props: Props, ...args: IDomArgs<HTMLElement>): IDomArg
|
||||
box?.view.selectedBox.set(box);
|
||||
|
||||
// Same for structure.
|
||||
const struct = (structure: BoxType) => ({structure});
|
||||
const struct = (structure: FormLayoutNodeType) => ({structure});
|
||||
|
||||
// Actions:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer';
|
||||
import * as elements from 'app/client/components/Forms/elements';
|
||||
import {FormView} from 'app/client/components/Forms/FormView';
|
||||
import {Box, BoxType} from 'app/common/Forms';
|
||||
import {bundleChanges, Computed, Disposable, dom, IDomArgs, MutableObsArray, obsArray, Observable} from 'grainjs';
|
||||
import {v4 as uuidv4} from 'uuid';
|
||||
|
||||
@@ -9,7 +9,7 @@ type Callback = () => Promise<void>;
|
||||
/**
|
||||
* A place where to insert a box.
|
||||
*/
|
||||
export type Place = (box: Box) => BoxModel;
|
||||
export type Place = (box: FormLayoutNode) => BoxModel;
|
||||
|
||||
/**
|
||||
* View model constructed from a box JSON structure.
|
||||
@@ -19,7 +19,7 @@ export abstract class BoxModel extends Disposable {
|
||||
/**
|
||||
* A factory method that creates a new BoxModel from a Box JSON by picking the right class based on the type.
|
||||
*/
|
||||
public static new(box: Box, parent: BoxModel | null, view: FormView | null = null): BoxModel {
|
||||
public static new(box: FormLayoutNode, parent: BoxModel | null, view: FormView | null = null): BoxModel {
|
||||
const subClassName = `${box.type.split(':')[0]}Model`;
|
||||
const factories = elements as any;
|
||||
const factory = factories[subClassName];
|
||||
@@ -42,7 +42,7 @@ export abstract class BoxModel extends Disposable {
|
||||
* Type of the box. As the type is bounded to the class that is used to render the box, it is possible
|
||||
* to change the type of the box just by changing this value. The box is then replaced in the parent.
|
||||
*/
|
||||
public type: BoxType;
|
||||
public type: FormLayoutNodeType;
|
||||
/**
|
||||
* List of children boxes.
|
||||
*/
|
||||
@@ -65,7 +65,7 @@ export abstract class BoxModel extends Disposable {
|
||||
/**
|
||||
* Don't use it directly, use the BoxModel.new factory method instead.
|
||||
*/
|
||||
constructor(box: Box, public parent: BoxModel | null, public view: FormView) {
|
||||
constructor(box: FormLayoutNode, public parent: BoxModel | null, public view: FormView) {
|
||||
super();
|
||||
|
||||
this.selected = Computed.create(this, (use) => use(view.selectedBox) === this && use(view.viewSection.hasFocus));
|
||||
@@ -149,7 +149,7 @@ export abstract class BoxModel extends Disposable {
|
||||
* - child: it will add it as a child.
|
||||
* - swap: swaps with the box
|
||||
*/
|
||||
public willAccept(box?: Box|BoxModel|null): 'sibling' | 'child' | 'swap' | null {
|
||||
public willAccept(box?: FormLayoutNode|BoxModel|null): 'sibling' | 'child' | 'swap' | null {
|
||||
// If myself and the dropped element share the same parent, and the parent is a column
|
||||
// element, just swap us.
|
||||
if (this.parent && box instanceof BoxModel && this.parent === box?.parent && box.parent?.type === 'Columns') {
|
||||
@@ -166,7 +166,7 @@ export abstract class BoxModel extends Disposable {
|
||||
* Accepts box from clipboard and inserts it before this box or if this is a container box, then
|
||||
* as a first child. Default implementation is to insert before self.
|
||||
*/
|
||||
public accept(dropped: Box, hint: 'above'|'below' = 'above') {
|
||||
public accept(dropped: FormLayoutNode, hint: 'above'|'below' = 'above') {
|
||||
// Get the box that was dropped.
|
||||
if (!dropped) { return null; }
|
||||
if (dropped.id === this.id) {
|
||||
@@ -200,7 +200,7 @@ export abstract class BoxModel extends Disposable {
|
||||
/**
|
||||
* Replaces children at index.
|
||||
*/
|
||||
public replaceAtIndex(box: Box, index: number) {
|
||||
public replaceAtIndex(box: FormLayoutNode, index: number) {
|
||||
const newOne = BoxModel.new(box, this);
|
||||
this.children.splice(index, 1, newOne);
|
||||
return newOne;
|
||||
@@ -216,13 +216,13 @@ export abstract class BoxModel extends Disposable {
|
||||
this.replace(box2, box1JSON);
|
||||
}
|
||||
|
||||
public append(box: Box) {
|
||||
public append(box: FormLayoutNode) {
|
||||
const newOne = BoxModel.new(box, this);
|
||||
this.children.push(newOne);
|
||||
return newOne;
|
||||
}
|
||||
|
||||
public insert(box: Box, index: number) {
|
||||
public insert(box: FormLayoutNode, index: number) {
|
||||
const newOne = BoxModel.new(box, this);
|
||||
this.children.splice(index, 0, newOne);
|
||||
return newOne;
|
||||
@@ -232,7 +232,7 @@ export abstract class BoxModel extends Disposable {
|
||||
/**
|
||||
* Replaces existing box with a new one, whenever it is found.
|
||||
*/
|
||||
public replace(existing: BoxModel, newOne: Box|BoxModel) {
|
||||
public replace(existing: BoxModel, newOne: FormLayoutNode|BoxModel) {
|
||||
const index = this.children.get().indexOf(existing);
|
||||
if (index < 0) { throw new Error('Cannot replace box that is not in parent'); }
|
||||
const model = newOne instanceof BoxModel ? newOne : BoxModel.new(newOne, this);
|
||||
@@ -246,20 +246,20 @@ export abstract class BoxModel extends Disposable {
|
||||
* Creates a place to insert a box before this box.
|
||||
*/
|
||||
public placeBeforeFirstChild() {
|
||||
return (box: Box) => this.insert(box, 0);
|
||||
return (box: FormLayoutNode) => this.insert(box, 0);
|
||||
}
|
||||
|
||||
// Some other places.
|
||||
public placeAfterListChild() {
|
||||
return (box: Box) => this.insert(box, this.children.get().length);
|
||||
return (box: FormLayoutNode) => this.insert(box, this.children.get().length);
|
||||
}
|
||||
|
||||
public placeAt(index: number) {
|
||||
return (box: Box) => this.insert(box, index);
|
||||
return (box: FormLayoutNode) => this.insert(box, index);
|
||||
}
|
||||
|
||||
public placeAfterChild(child: BoxModel) {
|
||||
return (box: Box) => this.insert(box, this.children.get().indexOf(child) + 1);
|
||||
return (box: FormLayoutNode) => this.insert(box, this.children.get().indexOf(child) + 1);
|
||||
}
|
||||
|
||||
public placeAfterMe() {
|
||||
@@ -319,7 +319,7 @@ export abstract class BoxModel extends Disposable {
|
||||
* The core responsibility of this method is to update this box and all children based on the box JSON.
|
||||
* This is counterpart of the FloatingRowModel, that enables this instance to point to a different box.
|
||||
*/
|
||||
public update(boxDef: Box) {
|
||||
public update(boxDef: FormLayoutNode) {
|
||||
// If we have a type and the type is changed, then we need to replace the box.
|
||||
if (this.type && boxDef.type !== this.type) {
|
||||
if (!this.parent) { throw new Error('Cannot replace detached box'); }
|
||||
@@ -329,7 +329,7 @@ export abstract class BoxModel extends Disposable {
|
||||
|
||||
// Update all properties of self.
|
||||
for (const someKey in boxDef) {
|
||||
const key = someKey as keyof Box;
|
||||
const key = someKey as keyof FormLayoutNode;
|
||||
// Skip some keys.
|
||||
if (key === 'id' || key === 'type' || key === 'children') { continue; }
|
||||
// Skip any inherited properties.
|
||||
@@ -365,7 +365,7 @@ export abstract class BoxModel extends Disposable {
|
||||
/**
|
||||
* Serialize this box to JSON.
|
||||
*/
|
||||
public toJSON(): Box {
|
||||
public toJSON(): FormLayoutNode {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
@@ -388,7 +388,7 @@ export abstract class BoxModel extends Disposable {
|
||||
|
||||
export class LayoutModel extends BoxModel {
|
||||
constructor(
|
||||
box: Box,
|
||||
box: FormLayoutNode,
|
||||
public parent: BoxModel | null,
|
||||
public _save: (clb?: Callback) => Promise<void>,
|
||||
public view: FormView
|
||||
@@ -420,7 +420,7 @@ export function unwrap<T>(val: T | Computed<T>): T {
|
||||
return val instanceof Computed ? val.get() : val;
|
||||
}
|
||||
|
||||
export function parseBox(text: string): Box|null {
|
||||
export function parseBox(text: string): FormLayoutNode|null {
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
return json && typeof json === 'object' && json.type ? json : null;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as style from './styles';
|
||||
import {FormLayoutNode} from 'app/client/components/FormRenderer';
|
||||
import {buildEditor} from 'app/client/components/Forms/Editor';
|
||||
import {FieldModel} from 'app/client/components/Forms/Field';
|
||||
import {buildMenu} from 'app/client/components/Forms/Menu';
|
||||
import {BoxModel} from 'app/client/components/Forms/Model';
|
||||
import * as style from 'app/client/components/Forms/styles';
|
||||
import {makeTestId} from 'app/client/lib/domUtils';
|
||||
import {Box} from 'app/common/Forms';
|
||||
import {dom, styled} from 'grainjs';
|
||||
|
||||
const testId = makeTestId('test-forms-');
|
||||
@@ -51,7 +51,7 @@ export class SectionModel extends BoxModel {
|
||||
* Accepts box from clipboard and inserts it before this box or if this is a container box, then
|
||||
* as a first child. Default implementation is to insert before self.
|
||||
*/
|
||||
public override accept(dropped: Box) {
|
||||
public override accept(dropped: FormLayoutNode) {
|
||||
// Get the box that was dropped.
|
||||
if (!dropped) { return null; }
|
||||
if (dropped.id === this.id) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer';
|
||||
import {Columns, Paragraph, Placeholder} from 'app/client/components/Forms/Columns';
|
||||
import {Box, BoxType} from 'app/common/Forms';
|
||||
/**
|
||||
* Add any other element you whish to use in the form here.
|
||||
* FormView will look for any exported BoxModel derived class in format `type` + `Model`, and use It
|
||||
@@ -12,7 +12,7 @@ export * from './Columns';
|
||||
export * from './Submit';
|
||||
export * from './Label';
|
||||
|
||||
export function defaultElement(type: BoxType): Box {
|
||||
export function defaultElement(type: FormLayoutNodeType): FormLayoutNode {
|
||||
switch(type) {
|
||||
case 'Columns': return Columns();
|
||||
case 'Placeholder': return Placeholder();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {setUpErrPage} from 'app/client/ui/errorPages';
|
||||
import {createAppPage} from 'app/client/ui/createAppPage';
|
||||
import {createErrPage} from 'app/client/ui/errorPages';
|
||||
|
||||
setUpErrPage();
|
||||
createAppPage((appModel) => createErrPage(appModel));
|
||||
|
||||
5
app/client/formMain.ts
Normal file
5
app/client/formMain.ts
Normal file
@@ -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 {
|
||||
|
||||
107
app/client/models/FormModel.ts
Normal file
107
app/client/models/FormModel.ts
Normal file
@@ -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;'
|
||||
|
||||
117
app/client/ui/FormAPI.ts
Normal file
117
app/client/ui/FormAPI.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import {BaseAPI, IOptions} from 'app/common/BaseAPI';
|
||||
import {CellValue, ColValues} from 'app/common/DocActions';
|
||||
|
||||
/**
|
||||
* Form and associated field metadata from a Grist view section.
|
||||
*
|
||||
* Includes the layout of the form, metadata such as the form title, and
|
||||
* a map of data for each field in the form. All of this is used to build a
|
||||
* submittable version of the form (see `FormRenderer.ts`, which handles the
|
||||
* actual building of forms).
|
||||
*/
|
||||
export interface Form {
|
||||
formFieldsById: Record<number, FormField>;
|
||||
formLayoutSpec: string;
|
||||
formTitle: string;
|
||||
formTableId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for a field in a form.
|
||||
*
|
||||
* Form fields are directly related to Grist fields; the former is based on data
|
||||
* from the latter, with additional metadata specific to forms, like whether a
|
||||
* form field is required. All of this is used to build a field in a submittable
|
||||
* version of the form (see `FormRenderer.ts`, which handles the actual building
|
||||
* of forms).
|
||||
*/
|
||||
export interface FormField {
|
||||
/** The field label. Defaults to the Grist column label or id. */
|
||||
question: string;
|
||||
/** The field description. */
|
||||
description: string;
|
||||
/** The Grist column id of the field. */
|
||||
colId: string;
|
||||
/** The Grist column type of the field (e.g. "Text"). */
|
||||
type: string;
|
||||
/** Additional field options. */
|
||||
options: FormFieldOptions;
|
||||
/** Populated with data from a referenced table. Only set if `type` is a Reference type. */
|
||||
refValues: [number, CellValue][] | null;
|
||||
}
|
||||
|
||||
interface FormFieldOptions {
|
||||
/** True if the field is required to submit the form. */
|
||||
formRequired?: boolean;
|
||||
/** Populated with a list of options. Only set if the field `type` is a Choice/Reference Liste. */
|
||||
choices?: string[];
|
||||
}
|
||||
|
||||
export interface FormAPI {
|
||||
getForm(options: GetFormOptions): Promise<Form>;
|
||||
createRecord(options: CreateRecordOptions): Promise<void>;
|
||||
}
|
||||
|
||||
interface GetFormCommonOptions {
|
||||
vsId: number;
|
||||
}
|
||||
|
||||
interface GetFormWithDocIdOptions extends GetFormCommonOptions {
|
||||
docId: string;
|
||||
}
|
||||
|
||||
interface GetFormWithShareKeyOptions extends GetFormCommonOptions {
|
||||
shareKey: string;
|
||||
}
|
||||
|
||||
type GetFormOptions = GetFormWithDocIdOptions | GetFormWithShareKeyOptions;
|
||||
|
||||
interface CreateRecordCommonOptions {
|
||||
tableId: string;
|
||||
colValues: ColValues;
|
||||
}
|
||||
|
||||
interface CreateRecordWithDocIdOptions extends CreateRecordCommonOptions {
|
||||
docId: string;
|
||||
}
|
||||
|
||||
interface CreateRecordWithShareKeyOptions extends CreateRecordCommonOptions {
|
||||
shareKey: string;
|
||||
}
|
||||
|
||||
type CreateRecordOptions = CreateRecordWithDocIdOptions | CreateRecordWithShareKeyOptions;
|
||||
|
||||
export class FormAPIImpl extends BaseAPI implements FormAPI {
|
||||
private _url: string;
|
||||
|
||||
constructor(url: string, options: IOptions = {}) {
|
||||
super(options);
|
||||
this._url = url.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
public async getForm(options: GetFormOptions): Promise<Form> {
|
||||
if ('docId' in options) {
|
||||
const {docId, vsId} = options;
|
||||
return this.requestJson(`${this._url}/api/docs/${docId}/forms/${vsId}`, {method: 'GET'});
|
||||
} else {
|
||||
const {shareKey, vsId} = options;
|
||||
return this.requestJson(`${this._url}/api/s/${shareKey}/forms/${vsId}`, {method: 'GET'});
|
||||
}
|
||||
}
|
||||
|
||||
public async createRecord(options: CreateRecordOptions): Promise<void> {
|
||||
if ('docId' in options) {
|
||||
const {docId, tableId, colValues} = options;
|
||||
return this.requestJson(`${this._url}/api/docs/${docId}/tables/${tableId}/records`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({records: [{fields: colValues}]}),
|
||||
});
|
||||
} else {
|
||||
const {shareKey, tableId, colValues} = options;
|
||||
return this.requestJson(`${this._url}/api/s/${shareKey}/tables/${tableId}/records`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({records: [{fields: colValues}]}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
36
app/client/ui/FormContainer.ts
Normal file
36
app/client/ui/FormContainer.ts
Normal file
@@ -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'),
|
||||
);
|
||||
}
|
||||
26
app/client/ui/FormErrorPage.ts
Normal file
26
app/client/ui/FormErrorPage.ts
Normal file
@@ -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')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
151
app/client/ui/FormPage.ts
Normal file
151
app/client/ui/FormPage.ts
Normal file
@@ -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;
|
||||
}
|
||||
`);
|
||||
139
app/client/ui/FormPagesCss.ts
Normal file
139
app/client/ui/FormPagesCss.ts
Normal file
@@ -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;
|
||||
`);
|
||||
78
app/client/ui/FormSuccessPage.ts
Normal file
78
app/client/ui/FormSuccessPage.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {FormModel } from 'app/client/models/FormModel';
|
||||
import {buildFormContainer} from 'app/client/ui/FormContainer';
|
||||
import * as css from 'app/client/ui/FormPagesCss';
|
||||
import {vars} from 'app/client/ui2018/cssVars';
|
||||
import {getPageTitleSuffix} from 'app/common/gristUrls';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {Computed, Disposable, dom, makeTestId, styled} from 'grainjs';
|
||||
|
||||
const testId = makeTestId('test-form-');
|
||||
|
||||
const t = makeT('FormSuccessPage');
|
||||
|
||||
export class FormSuccessPage extends Disposable {
|
||||
private _successText = Computed.create(this, this._model.formLayout, (_use, layout) => {
|
||||
if (!layout) { return null; }
|
||||
|
||||
return layout.successText || t('Thank you! Your response has been recorded.');
|
||||
});
|
||||
|
||||
private _showNewResponseButton = Computed.create(this, this._model.formLayout, (_use, layout) => {
|
||||
return Boolean(layout?.anotherResponse);
|
||||
});
|
||||
|
||||
constructor(private _model: FormModel) {
|
||||
super();
|
||||
document.title = `${t('Form Submitted')}${getPageTitleSuffix(getGristConfig())}`;
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
return buildFormContainer(() => [
|
||||
css.formSuccessMessageImageContainer(css.formSuccessMessageImage({
|
||||
src: 'img/form-success.svg',
|
||||
})),
|
||||
css.formMessageText(dom.text(this._successText), testId('success-text')),
|
||||
dom.maybe(this._showNewResponseButton, () =>
|
||||
cssFormButtons(
|
||||
cssFormNewResponseButton(
|
||||
'Submit new response',
|
||||
dom.on('click', () => this._handleClickNewResponseButton()),
|
||||
),
|
||||
)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
private async _handleClickNewResponseButton() {
|
||||
await this._model.fetchForm();
|
||||
}
|
||||
}
|
||||
|
||||
const cssFormButtons = styled('div', `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 24px;
|
||||
`);
|
||||
|
||||
const cssFormNewResponseButton = styled('button', `
|
||||
position: relative;
|
||||
outline: none;
|
||||
border-style: none;
|
||||
line-height: normal;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 12px 24px;
|
||||
min-height: 40px;
|
||||
background: ${vars.primaryBg};
|
||||
border-radius: 3px;
|
||||
color: ${vars.primaryFg};
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background: ${vars.primaryBgHover};
|
||||
}
|
||||
`);
|
||||
@@ -133,7 +133,8 @@ export const textButton = styled(gristTextButton, `
|
||||
`);
|
||||
|
||||
export const pageContainer = styled('div', `
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: ${theme.loginPageBackdrop};
|
||||
|
||||
@media ${mediaXSmall} {
|
||||
|
||||
@@ -37,7 +37,7 @@ export function buildPinnedDoc(home: HomeModel, doc: Document, workspace: Worksp
|
||||
pinnedDoc(
|
||||
isRenaming || doc.removedAt ?
|
||||
null :
|
||||
urlState().setLinkUrl(docUrl(doc, isExample ? {org: workspace.orgDomain} : undefined)),
|
||||
urlState().setLinkUrl({...docUrl(doc), ...(isExample ? {org: workspace.orgDomain} : {})}),
|
||||
pinnedDoc.cls('-no-access', !roles.canView(doc.access)),
|
||||
pinnedDocPreview(
|
||||
(doc.options?.icon ?
|
||||
|
||||
@@ -40,7 +40,7 @@ function buildTemplateDoc(home: HomeModel, doc: Document, workspace: Workspace,
|
||||
} else {
|
||||
return css.docRowWrapper(
|
||||
cssDocRowLink(
|
||||
urlState().setLinkUrl(docUrl(doc, {org: workspace.orgDomain})),
|
||||
urlState().setLinkUrl({...docUrl(doc), org: workspace.orgDomain}),
|
||||
cssDocName(doc.name, testId('template-doc-title')),
|
||||
doc.options?.description ? cssDocRowDetails(doc.options.description, testId('template-doc-description')) : null,
|
||||
),
|
||||
|
||||
@@ -68,7 +68,6 @@ async function switchToPersonalUrl(ev: MouseEvent, appModel: AppModel, org: stri
|
||||
}
|
||||
|
||||
const cssPageContainer = styled(css.pageContainer, `
|
||||
overflow: auto;
|
||||
padding-bottom: 40px;
|
||||
`);
|
||||
|
||||
|
||||
38
app/client/ui/createAppPage.ts
Normal file
38
app/client/ui/createAppPage.ts
Normal file
@@ -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),
|
||||
];
|
||||
}));
|
||||
}
|
||||
39
app/client/ui/createPage.ts
Normal file
39
app/client/ui/createPage.ts
Normal file
@@ -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};`);
|
||||
|
||||
Reference in New Issue
Block a user