chore: Improve typings around model methods (#6324)

This commit is contained in:
Tom Moor
2023-12-28 20:11:27 -04:00
committed by GitHub
parent ed1f345326
commit 55a55376c6
15 changed files with 89 additions and 65 deletions

View File

@@ -1,3 +1,4 @@
import invariant from "invariant";
import trim from "lodash/trim"; import trim from "lodash/trim";
import { action, computed, observable, reaction, runInAction } from "mobx"; import { action, computed, observable, reaction, runInAction } from "mobx";
import { import {
@@ -148,12 +149,13 @@ export default class Collection extends ParanoidModel {
try { try {
this.isFetching = true; this.isFetching = true;
const { data } = await client.post("/collections.documents", { const res = await client.post("/collections.documents", {
id: this.id, id: this.id,
}); });
invariant(res?.data, "Data should be available");
runInAction("Collection#fetchDocuments", () => { runInAction("Collection#fetchDocuments", () => {
this.documents = data; this.documents = res.data;
}); });
} finally { } finally {
this.isFetching = false; this.isFetching = false;

View File

@@ -3,12 +3,13 @@ import i18n, { t } from "i18next";
import floor from "lodash/floor"; import floor from "lodash/floor";
import { action, autorun, computed, observable, set } from "mobx"; import { action, autorun, computed, observable, set } from "mobx";
import { ExportContentType } from "@shared/types"; import { ExportContentType } from "@shared/types";
import type { NavigationNode } from "@shared/types"; import type { JSONObject, NavigationNode } from "@shared/types";
import Storage from "@shared/utils/Storage"; import Storage from "@shared/utils/Storage";
import { isRTL } from "@shared/utils/rtl"; import { isRTL } from "@shared/utils/rtl";
import slugify from "@shared/utils/slugify"; import slugify from "@shared/utils/slugify";
import DocumentsStore from "~/stores/DocumentsStore"; import DocumentsStore from "~/stores/DocumentsStore";
import User from "~/models/User"; import User from "~/models/User";
import type { Properties } from "~/types";
import { client } from "~/utils/ApiClient"; import { client } from "~/utils/ApiClient";
import { settingsPath } from "~/utils/routeHelpers"; import { settingsPath } from "~/utils/routeHelpers";
import Collection from "./Collection"; import Collection from "./Collection";
@@ -17,7 +18,7 @@ import ParanoidModel from "./base/ParanoidModel";
import Field from "./decorators/Field"; import Field from "./decorators/Field";
import Relation from "./decorators/Relation"; import Relation from "./decorators/Relation";
type SaveOptions = { type SaveOptions = JSONObject & {
publish?: boolean; publish?: boolean;
done?: boolean; done?: boolean;
autosave?: boolean; autosave?: boolean;
@@ -388,9 +389,9 @@ export default class Document extends ParanoidModel {
@action @action
save = async ( save = async (
fields?: Partial<Document> | undefined, fields?: Properties<typeof this>,
options?: SaveOptions | undefined options?: SaveOptions
) => { ): Promise<Document> => {
const params = fields ?? this.toAPI(); const params = fields ?? this.toAPI();
this.isSaving = true; this.isSaving = true;

View File

@@ -1,5 +1,6 @@
import pick from "lodash/pick"; import pick from "lodash/pick";
import { set, observable, action } from "mobx"; import { set, observable, action } from "mobx";
import { JSONObject } from "@shared/types";
import type Store from "~/stores/base/Store"; import type Store from "~/stores/base/Store";
import Logger from "~/utils/Logger"; import Logger from "~/utils/Logger";
import { getFieldsForModel } from "../decorators/Field"; import { getFieldsForModel } from "../decorators/Field";
@@ -77,7 +78,7 @@ export default abstract class Model {
save = async ( save = async (
params?: Record<string, any>, params?: Record<string, any>,
options?: Record<string, string | boolean | number | undefined> options?: Record<string, string | boolean | number | undefined>
) => { ): Promise<Model> => {
this.isSaving = true; this.isSaving = true;
try { try {
@@ -108,7 +109,7 @@ export default abstract class Model {
} }
}; };
updateData = action((data: any) => { updateData = action((data: Partial<Model>) => {
for (const key in data) { for (const key in data) {
this[key] = data[key]; this[key] = data[key];
} }
@@ -117,7 +118,7 @@ export default abstract class Model {
this.persistedAttributes = this.toAPI(); this.persistedAttributes = this.toAPI();
}); });
fetch = (options?: any) => this.store.fetch(this.id, options); fetch = (options?: JSONObject) => this.store.fetch(this.id, options);
refresh = () => refresh = () =>
this.fetch({ this.fetch({

View File

@@ -1,8 +1,8 @@
import type Model from "../base/Model"; import type Model from "../base/Model";
const fields = new Map<string, string[]>(); const fields = new Map<string, (string | number | symbol)[]>();
export const getFieldsForModel = (target: Model) => export const getFieldsForModel = <T extends Model>(target: T) =>
fields.get(target.constructor.name) ?? []; fields.get(target.constructor.name) ?? [];
/** /**
@@ -14,10 +14,7 @@ export const getFieldsForModel = (target: Model) =>
*/ */
const Field = <T>(target: any, propertyKey: keyof T) => { const Field = <T>(target: any, propertyKey: keyof T) => {
const className = target.constructor.name; const className = target.constructor.name;
fields.set(className, [ fields.set(className, [...(fields.get(className) || []), propertyKey]);
...(fields.get(className) || []),
propertyKey as string,
]);
}; };
export default Field; export default Field;

View File

@@ -8,6 +8,7 @@ import { toast } from "sonner";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles"; import { s } from "@shared/styles";
import { JSONObject } from "@shared/types";
import { dateToRelative } from "@shared/utils/date"; import { dateToRelative } from "@shared/utils/date";
import { Minute } from "@shared/utils/time"; import { Minute } from "@shared/utils/time";
import Comment from "~/models/Comment"; import Comment from "~/models/Comment";
@@ -95,7 +96,7 @@ function CommentThreadItem({
const [isEditing, setEditing, setReadOnly] = useBoolean(); const [isEditing, setEditing, setReadOnly] = useBoolean();
const formRef = React.useRef<HTMLFormElement>(null); const formRef = React.useRef<HTMLFormElement>(null);
const handleChange = (value: (asString: boolean) => object) => { const handleChange = (value: (asString: boolean) => JSONObject) => {
setData(value(false)); setData(value(false));
}; };

View File

@@ -10,6 +10,7 @@ import {
NavigationNode, NavigationNode,
} from "@shared/types"; } from "@shared/types";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import { Properties } from "~/types";
import { client } from "~/utils/ApiClient"; import { client } from "~/utils/ApiClient";
import RootStore from "./RootStore"; import RootStore from "./RootStore";
import Store from "./base/Store"; import Store from "./base/Store";
@@ -165,14 +166,14 @@ export default class CollectionsStore extends Store<Collection> {
} }
}; };
async update(params: Record<string, any>): Promise<Collection> { async update(params: Properties<Collection>): Promise<Collection> {
const result = await super.update(params); const result = await super.update(params);
// If we're changing sharing permissions on the collection then we need to // If we're changing sharing permissions on the collection then we need to
// remove all locally cached policies for documents in the collection as they // remove all locally cached policies for documents in the collection as they
// are now invalid // are now invalid
if (params.sharing !== undefined) { if (params.sharing !== undefined) {
this.rootStore.documents.inCollection(params.id).forEach((document) => { this.rootStore.documents.inCollection(result.id).forEach((document) => {
this.rootStore.policies.remove(document.id); this.rootStore.policies.remove(document.id);
}); });
} }

View File

@@ -87,7 +87,7 @@ export default class CommentsStore extends Store<Comment> {
documentId, documentId,
...options, ...options,
}); });
invariant(res && res.data, "Comment list not available"); invariant(res?.data, "Comment list not available");
runInAction("CommentsStore#fetchDocumentComments", () => { runInAction("CommentsStore#fetchDocumentComments", () => {
res.data.forEach(this.add); res.data.forEach(this.add);

View File

@@ -5,7 +5,12 @@ import find from "lodash/find";
import omitBy from "lodash/omitBy"; import omitBy from "lodash/omitBy";
import orderBy from "lodash/orderBy"; import orderBy from "lodash/orderBy";
import { observable, action, computed, runInAction } from "mobx"; import { observable, action, computed, runInAction } from "mobx";
import { DateFilter, NavigationNode, PublicTeam } from "@shared/types"; import type {
DateFilter,
JSONObject,
NavigationNode,
PublicTeam,
} from "@shared/types";
import { subtractDate } from "@shared/utils/date"; import { subtractDate } from "@shared/utils/date";
import { bytesToHumanReadable } from "@shared/utils/files"; import { bytesToHumanReadable } from "@shared/utils/files";
import naturalSort from "@shared/utils/naturalSort"; import naturalSort from "@shared/utils/naturalSort";
@@ -13,7 +18,12 @@ import RootStore from "~/stores/RootStore";
import Store from "~/stores/base/Store"; import Store from "~/stores/base/Store";
import Document from "~/models/Document"; import Document from "~/models/Document";
import env from "~/env"; import env from "~/env";
import { FetchOptions, PaginationParams, SearchResult } from "~/types"; import type {
FetchOptions,
PaginationParams,
Properties,
SearchResult,
} from "~/types";
import { client } from "~/utils/ApiClient"; import { client } from "~/utils/ApiClient";
import { extname } from "~/utils/files"; import { extname } from "~/utils/files";
@@ -704,8 +714,8 @@ export default class DocumentsStore extends Store<Document> {
@action @action
async update( async update(
params: Partial<Document>, params: Properties<Document>,
options?: Record<string, string | boolean | number | undefined> options?: JSONObject
): Promise<Document> { ): Promise<Document> {
this.isSaving = true; this.isSaving = true;

View File

@@ -43,7 +43,7 @@ export default class NotificationsStore extends Store<Notification> {
@action @action
markAllAsRead = async () => { markAllAsRead = async () => {
await client.post("/notifications.update_all", { await client.post("/notifications.update_all", {
viewedAt: new Date(), viewedAt: new Date().toISOString(),
}); });
runInAction("NotificationsStore#markAllAsRead", () => { runInAction("NotificationsStore#markAllAsRead", () => {

View File

@@ -4,7 +4,10 @@ import find from "lodash/find";
import isUndefined from "lodash/isUndefined"; import isUndefined from "lodash/isUndefined";
import sortBy from "lodash/sortBy"; import sortBy from "lodash/sortBy";
import { action, computed } from "mobx"; import { action, computed } from "mobx";
import type { Required } from "utility-types";
import type { JSONObject } from "@shared/types";
import Share from "~/models/Share"; import Share from "~/models/Share";
import type { Properties } from "~/types";
import { client } from "~/utils/ApiClient"; import { client } from "~/utils/ApiClient";
import RootStore from "./RootStore"; import RootStore from "./RootStore";
import Store, { RPCAction } from "./base/Store"; import Store, { RPCAction } from "./base/Store";
@@ -40,7 +43,7 @@ export default class SharesStore extends Store<Share> {
}; };
@action @action
async create(params: Record<string, any>) { async create(params: Required<Properties<Share>, "documentId">) {
const item = this.getByDocumentId(params.documentId); const item = this.getByDocumentId(params.documentId);
if (item) { if (item) {
return item; return item;
@@ -49,10 +52,7 @@ export default class SharesStore extends Store<Share> {
} }
@action @action
async fetch( async fetch(documentId: string, options: JSONObject = {}): Promise<any> {
documentId: string,
options: Record<string, any> = {}
): Promise<any> {
const item = this.getByDocumentId(documentId); const item = this.getByDocumentId(documentId);
if (item && !options.force) { if (item && !options.force) {
return item; return item;

View File

@@ -2,7 +2,7 @@ import invariant from "invariant";
import filter from "lodash/filter"; import filter from "lodash/filter";
import orderBy from "lodash/orderBy"; import orderBy from "lodash/orderBy";
import { observable, computed, action, runInAction } from "mobx"; import { observable, computed, action, runInAction } from "mobx";
import { UserRole } from "@shared/types"; import { type JSONObject, UserRole } from "@shared/types";
import User from "~/models/User"; import User from "~/models/User";
import { client } from "~/utils/ApiClient"; import { client } from "~/utils/ApiClient";
import RootStore from "./RootStore"; import RootStore from "./RootStore";
@@ -179,7 +179,7 @@ export default class UsersStore extends Store<User> {
}; };
@action @action
async delete(user: User, options: Record<string, any> = {}) { async delete(user: User, options: JSONObject = {}) {
await super.delete(user, options); await super.delete(user, options);
if (!user.isSuspended && user.lastActiveAt) { if (!user.isSuspended && user.lastActiveAt) {

View File

@@ -5,11 +5,12 @@ import orderBy from "lodash/orderBy";
import { observable, action, computed, runInAction } from "mobx"; import { observable, action, computed, runInAction } from "mobx";
import pluralize from "pluralize"; import pluralize from "pluralize";
import { Pagination } from "@shared/constants"; import { Pagination } from "@shared/constants";
import { type JSONObject } from "@shared/types";
import RootStore from "~/stores/RootStore"; import RootStore from "~/stores/RootStore";
import Policy from "~/models/Policy"; import Policy from "~/models/Policy";
import Model from "~/models/base/Model"; import Model from "~/models/base/Model";
import { getInverseRelationsForModelClass } from "~/models/decorators/Relation"; import { getInverseRelationsForModelClass } from "~/models/decorators/Relation";
import { PaginationParams, PartialWithId } from "~/types"; import type { PaginationParams, PartialWithId, Properties } from "~/types";
import { client } from "~/utils/ApiClient"; import { client } from "~/utils/ApiClient";
import { AuthorizationError, NotFoundError } from "~/utils/errors"; import { AuthorizationError, NotFoundError } from "~/utils/errors";
@@ -125,12 +126,9 @@ export default abstract class Store<T extends Model> {
this.data.delete(id); this.data.delete(id);
} }
save( save(params: Properties<T>, options: JSONObject = {}): Promise<T> {
params: Partial<T>,
options: Record<string, string | boolean | number | undefined> = {}
): Promise<T> {
const { isNew, ...rest } = options; const { isNew, ...rest } = options;
if (isNew || !params.id) { if (isNew || !("id" in params)) {
return this.create(params, rest); return this.create(params, rest);
} }
return this.update(params, rest); return this.update(params, rest);
@@ -141,10 +139,7 @@ export default abstract class Store<T extends Model> {
} }
@action @action
async create( async create(params: Properties<T>, options?: JSONObject): Promise<T> {
params: Partial<T>,
options?: Record<string, string | boolean | number | undefined>
): Promise<T> {
if (!this.actions.includes(RPCAction.Create)) { if (!this.actions.includes(RPCAction.Create)) {
throw new Error(`Cannot create ${this.modelName}`); throw new Error(`Cannot create ${this.modelName}`);
} }
@@ -168,10 +163,7 @@ export default abstract class Store<T extends Model> {
} }
@action @action
async update( async update(params: Properties<T>, options?: JSONObject): Promise<T> {
params: Partial<T>,
options?: Record<string, string | boolean | number | undefined>
): Promise<T> {
if (!this.actions.includes(RPCAction.Update)) { if (!this.actions.includes(RPCAction.Update)) {
throw new Error(`Cannot update ${this.modelName}`); throw new Error(`Cannot update ${this.modelName}`);
} }
@@ -195,7 +187,7 @@ export default abstract class Store<T extends Model> {
} }
@action @action
async delete(item: T, options: Record<string, any> = {}) { async delete(item: T, options: JSONObject = {}) {
if (!this.actions.includes(RPCAction.Delete)) { if (!this.actions.includes(RPCAction.Delete)) {
throw new Error(`Cannot delete ${this.modelName}`); throw new Error(`Cannot delete ${this.modelName}`);
} }
@@ -218,7 +210,7 @@ export default abstract class Store<T extends Model> {
} }
@action @action
async fetch(id: string, options: Record<string, any> = {}): Promise<T> { async fetch(id: string, options: JSONObject = {}): Promise<T> {
if (!this.actions.includes(RPCAction.Info)) { if (!this.actions.includes(RPCAction.Info)) {
throw new Error(`Cannot fetch ${this.modelName}`); throw new Error(`Cannot fetch ${this.modelName}`);
} }

View File

@@ -1,5 +1,7 @@
/* eslint-disable @typescript-eslint/ban-types */
import { Location, LocationDescriptor } from "history"; import { Location, LocationDescriptor } from "history";
import { TFunction } from "i18next"; import { TFunction } from "i18next";
import { JSONValue } from "@shared/types";
import RootStore from "~/stores/RootStore"; import RootStore from "~/stores/RootStore";
import Document from "./models/Document"; import Document from "./models/Document";
import FileOperation from "./models/FileOperation"; import FileOperation from "./models/FileOperation";
@@ -198,3 +200,10 @@ export type WebsocketEvent =
export type AwarenessChangeEvent = { export type AwarenessChangeEvent = {
states: { user?: { id: string }; cursor: any; scrollY: number | undefined }[]; states: { user?: { id: string }; cursor: any; scrollY: number | undefined }[];
}; };
// TODO: Can we make this type driven by the @Field decorator
export type Properties<C> = {
[Property in keyof C as C[Property] extends JSONValue
? Property
: never]?: C[Property];
};

View File

@@ -2,6 +2,7 @@ import retry from "fetch-retry";
import trim from "lodash/trim"; import trim from "lodash/trim";
import queryString from "query-string"; import queryString from "query-string";
import EDITOR_VERSION from "@shared/editor/version"; import EDITOR_VERSION from "@shared/editor/version";
import { JSONObject } from "@shared/types";
import stores from "~/stores"; import stores from "~/stores";
import Logger from "./Logger"; import Logger from "./Logger";
import download from "./download"; import download from "./download";
@@ -23,11 +24,11 @@ type Options = {
baseUrl?: string; baseUrl?: string;
}; };
type FetchOptions = { interface FetchOptions {
download?: boolean; download?: boolean;
credentials?: "omit" | "same-origin" | "include"; credentials?: "omit" | "same-origin" | "include";
headers?: Record<string, string>; headers?: Record<string, string>;
}; }
const fetchWithRetry = retry(fetch); const fetchWithRetry = retry(fetch);
@@ -38,12 +39,12 @@ class ApiClient {
this.baseUrl = options.baseUrl || "/api"; this.baseUrl = options.baseUrl || "/api";
} }
fetch = async ( fetch = async <T = any>(
path: string, path: string,
method: string, method: string,
data: Record<string, any> | FormData | undefined, data: JSONObject | FormData | undefined,
options: FetchOptions = {} options: FetchOptions = {}
) => { ): Promise<T> => {
let body: string | FormData | undefined; let body: string | FormData | undefined;
let modifiedPath; let modifiedPath;
let urlToFetch; let urlToFetch;
@@ -123,9 +124,9 @@ class ApiClient {
response.headers.get("content-disposition") || "" response.headers.get("content-disposition") || ""
).split("filename=")[1]; ).split("filename=")[1];
download(blob, trim(fileName, '"')); download(blob, trim(fileName, '"'));
return; return undefined as T;
} else if (success && response.status === 204) { } else if (success && response.status === 204) {
return; return undefined as T;
} else if (success) { } else if (success) {
return response.json(); return response.json();
} }
@@ -133,7 +134,7 @@ class ApiClient {
// Handle 401, log out user // Handle 401, log out user
if (response.status === 401) { if (response.status === 401) {
await stores.auth.logout(true, false); await stores.auth.logout(true, false);
return; throw new AuthorizationError();
} }
// Handle failed responses // Handle failed responses
@@ -168,7 +169,6 @@ class ApiClient {
if (response.status === 403) { if (response.status === 403) {
if (error.error === "user_suspended") { if (error.error === "user_suspended") {
await stores.auth.logout(false, false); await stores.auth.logout(false, false);
return;
} }
throw new AuthorizationError(error.message); throw new AuthorizationError(error.message);
@@ -204,17 +204,17 @@ class ApiClient {
throw err; throw err;
}; };
get = ( get = <T = any>(
path: string, path: string,
data: Record<string, any> | undefined, data: JSONObject | undefined,
options?: FetchOptions options?: FetchOptions
) => this.fetch(path, "GET", data, options); ) => this.fetch<T>(path, "GET", data, options);
post = ( post = <T = any>(
path: string, path: string,
data?: Record<string, any> | undefined, data?: JSONObject | FormData | undefined,
options?: FetchOptions options?: FetchOptions
) => this.fetch(path, "POST", data, options); ) => this.fetch<T>(path, "POST", data, options);
} }
export const client = new ApiClient(); export const client = new ApiClient();

View File

@@ -250,5 +250,15 @@ export type Unfurl<T = OEmbedType> = {
meta?: Record<string, string>; meta?: Record<string, string>;
}; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any export type JSONValue =
export type ProsemirrorData = Record<string, any>; | string
| number
| boolean
| undefined
| null
| { [x: string]: JSONValue }
| Array<JSONValue>;
export type JSONObject = { [x: string]: JSONValue };
export type ProsemirrorData = JSONObject;