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:
@@ -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),
|
||||
]));
|
||||
}
|
||||
Reference in New Issue
Block a user