(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:
George Gevoian
2024-02-21 14:22:01 -05:00
parent 6800ebfbad
commit c6fd79ac1f
53 changed files with 1746 additions and 1811 deletions

View File

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

View 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'),
);
}

View 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
View 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;
}
`);

View 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;
`);

View 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};
}
`);

View File

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

View File

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

View File

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

View File

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

View 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),
];
}));
}

View 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),
]);
}

View File

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

View File

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