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