diff --git a/app/models/Collection.ts b/app/models/Collection.ts index 942ab39aa..a5fd36fcc 100644 --- a/app/models/Collection.ts +++ b/app/models/Collection.ts @@ -1,3 +1,4 @@ +import invariant from "invariant"; import trim from "lodash/trim"; import { action, computed, observable, reaction, runInAction } from "mobx"; import { @@ -148,12 +149,13 @@ export default class Collection extends ParanoidModel { try { this.isFetching = true; - const { data } = await client.post("/collections.documents", { + const res = await client.post("/collections.documents", { id: this.id, }); + invariant(res?.data, "Data should be available"); runInAction("Collection#fetchDocuments", () => { - this.documents = data; + this.documents = res.data; }); } finally { this.isFetching = false; diff --git a/app/models/Document.ts b/app/models/Document.ts index 48835e566..4d3b79865 100644 --- a/app/models/Document.ts +++ b/app/models/Document.ts @@ -3,12 +3,13 @@ import i18n, { t } from "i18next"; import floor from "lodash/floor"; import { action, autorun, computed, observable, set } from "mobx"; 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 { isRTL } from "@shared/utils/rtl"; import slugify from "@shared/utils/slugify"; import DocumentsStore from "~/stores/DocumentsStore"; import User from "~/models/User"; +import type { Properties } from "~/types"; import { client } from "~/utils/ApiClient"; import { settingsPath } from "~/utils/routeHelpers"; import Collection from "./Collection"; @@ -17,7 +18,7 @@ import ParanoidModel from "./base/ParanoidModel"; import Field from "./decorators/Field"; import Relation from "./decorators/Relation"; -type SaveOptions = { +type SaveOptions = JSONObject & { publish?: boolean; done?: boolean; autosave?: boolean; @@ -388,9 +389,9 @@ export default class Document extends ParanoidModel { @action save = async ( - fields?: Partial | undefined, - options?: SaveOptions | undefined - ) => { + fields?: Properties, + options?: SaveOptions + ): Promise => { const params = fields ?? this.toAPI(); this.isSaving = true; diff --git a/app/models/base/Model.ts b/app/models/base/Model.ts index 6159e0492..f3d127fb6 100644 --- a/app/models/base/Model.ts +++ b/app/models/base/Model.ts @@ -1,5 +1,6 @@ import pick from "lodash/pick"; import { set, observable, action } from "mobx"; +import { JSONObject } from "@shared/types"; import type Store from "~/stores/base/Store"; import Logger from "~/utils/Logger"; import { getFieldsForModel } from "../decorators/Field"; @@ -77,7 +78,7 @@ export default abstract class Model { save = async ( params?: Record, options?: Record - ) => { + ): Promise => { this.isSaving = true; try { @@ -108,7 +109,7 @@ export default abstract class Model { } }; - updateData = action((data: any) => { + updateData = action((data: Partial) => { for (const key in data) { this[key] = data[key]; } @@ -117,7 +118,7 @@ export default abstract class Model { this.persistedAttributes = this.toAPI(); }); - fetch = (options?: any) => this.store.fetch(this.id, options); + fetch = (options?: JSONObject) => this.store.fetch(this.id, options); refresh = () => this.fetch({ diff --git a/app/models/decorators/Field.ts b/app/models/decorators/Field.ts index 5163b7851..28c3e43e4 100644 --- a/app/models/decorators/Field.ts +++ b/app/models/decorators/Field.ts @@ -1,8 +1,8 @@ import type Model from "../base/Model"; -const fields = new Map(); +const fields = new Map(); -export const getFieldsForModel = (target: Model) => +export const getFieldsForModel = (target: T) => fields.get(target.constructor.name) ?? []; /** @@ -14,10 +14,7 @@ export const getFieldsForModel = (target: Model) => */ const Field = (target: any, propertyKey: keyof T) => { const className = target.constructor.name; - fields.set(className, [ - ...(fields.get(className) || []), - propertyKey as string, - ]); + fields.set(className, [...(fields.get(className) || []), propertyKey]); }; export default Field; diff --git a/app/scenes/Document/components/CommentThreadItem.tsx b/app/scenes/Document/components/CommentThreadItem.tsx index 8748fe34f..65db246ea 100644 --- a/app/scenes/Document/components/CommentThreadItem.tsx +++ b/app/scenes/Document/components/CommentThreadItem.tsx @@ -8,6 +8,7 @@ import { toast } from "sonner"; import styled, { css } from "styled-components"; import breakpoint from "styled-components-breakpoint"; import { s } from "@shared/styles"; +import { JSONObject } from "@shared/types"; import { dateToRelative } from "@shared/utils/date"; import { Minute } from "@shared/utils/time"; import Comment from "~/models/Comment"; @@ -95,7 +96,7 @@ function CommentThreadItem({ const [isEditing, setEditing, setReadOnly] = useBoolean(); const formRef = React.useRef(null); - const handleChange = (value: (asString: boolean) => object) => { + const handleChange = (value: (asString: boolean) => JSONObject) => { setData(value(false)); }; diff --git a/app/stores/CollectionsStore.ts b/app/stores/CollectionsStore.ts index efb6db548..3af948001 100644 --- a/app/stores/CollectionsStore.ts +++ b/app/stores/CollectionsStore.ts @@ -10,6 +10,7 @@ import { NavigationNode, } from "@shared/types"; import Collection from "~/models/Collection"; +import { Properties } from "~/types"; import { client } from "~/utils/ApiClient"; import RootStore from "./RootStore"; import Store from "./base/Store"; @@ -165,14 +166,14 @@ export default class CollectionsStore extends Store { } }; - async update(params: Record): Promise { + async update(params: Properties): Promise { const result = await super.update(params); // 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 // are now invalid 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); }); } diff --git a/app/stores/CommentsStore.ts b/app/stores/CommentsStore.ts index 8f42dfb73..0054cf81c 100644 --- a/app/stores/CommentsStore.ts +++ b/app/stores/CommentsStore.ts @@ -87,7 +87,7 @@ export default class CommentsStore extends Store { documentId, ...options, }); - invariant(res && res.data, "Comment list not available"); + invariant(res?.data, "Comment list not available"); runInAction("CommentsStore#fetchDocumentComments", () => { res.data.forEach(this.add); diff --git a/app/stores/DocumentsStore.ts b/app/stores/DocumentsStore.ts index b3ac39f74..8b0f0faff 100644 --- a/app/stores/DocumentsStore.ts +++ b/app/stores/DocumentsStore.ts @@ -5,7 +5,12 @@ import find from "lodash/find"; import omitBy from "lodash/omitBy"; import orderBy from "lodash/orderBy"; 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 { bytesToHumanReadable } from "@shared/utils/files"; import naturalSort from "@shared/utils/naturalSort"; @@ -13,7 +18,12 @@ import RootStore from "~/stores/RootStore"; import Store from "~/stores/base/Store"; import Document from "~/models/Document"; import env from "~/env"; -import { FetchOptions, PaginationParams, SearchResult } from "~/types"; +import type { + FetchOptions, + PaginationParams, + Properties, + SearchResult, +} from "~/types"; import { client } from "~/utils/ApiClient"; import { extname } from "~/utils/files"; @@ -704,8 +714,8 @@ export default class DocumentsStore extends Store { @action async update( - params: Partial, - options?: Record + params: Properties, + options?: JSONObject ): Promise { this.isSaving = true; diff --git a/app/stores/NotificationsStore.ts b/app/stores/NotificationsStore.ts index f20abf068..34aa45fe8 100644 --- a/app/stores/NotificationsStore.ts +++ b/app/stores/NotificationsStore.ts @@ -43,7 +43,7 @@ export default class NotificationsStore extends Store { @action markAllAsRead = async () => { await client.post("/notifications.update_all", { - viewedAt: new Date(), + viewedAt: new Date().toISOString(), }); runInAction("NotificationsStore#markAllAsRead", () => { diff --git a/app/stores/SharesStore.ts b/app/stores/SharesStore.ts index 3f0f60de5..8510811fc 100644 --- a/app/stores/SharesStore.ts +++ b/app/stores/SharesStore.ts @@ -4,7 +4,10 @@ import find from "lodash/find"; import isUndefined from "lodash/isUndefined"; import sortBy from "lodash/sortBy"; import { action, computed } from "mobx"; +import type { Required } from "utility-types"; +import type { JSONObject } from "@shared/types"; import Share from "~/models/Share"; +import type { Properties } from "~/types"; import { client } from "~/utils/ApiClient"; import RootStore from "./RootStore"; import Store, { RPCAction } from "./base/Store"; @@ -40,7 +43,7 @@ export default class SharesStore extends Store { }; @action - async create(params: Record) { + async create(params: Required, "documentId">) { const item = this.getByDocumentId(params.documentId); if (item) { return item; @@ -49,10 +52,7 @@ export default class SharesStore extends Store { } @action - async fetch( - documentId: string, - options: Record = {} - ): Promise { + async fetch(documentId: string, options: JSONObject = {}): Promise { const item = this.getByDocumentId(documentId); if (item && !options.force) { return item; diff --git a/app/stores/UsersStore.ts b/app/stores/UsersStore.ts index 62887b37e..ab35d1688 100644 --- a/app/stores/UsersStore.ts +++ b/app/stores/UsersStore.ts @@ -2,7 +2,7 @@ import invariant from "invariant"; import filter from "lodash/filter"; import orderBy from "lodash/orderBy"; 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 { client } from "~/utils/ApiClient"; import RootStore from "./RootStore"; @@ -179,7 +179,7 @@ export default class UsersStore extends Store { }; @action - async delete(user: User, options: Record = {}) { + async delete(user: User, options: JSONObject = {}) { await super.delete(user, options); if (!user.isSuspended && user.lastActiveAt) { diff --git a/app/stores/base/Store.ts b/app/stores/base/Store.ts index b32e01eb0..e29200407 100644 --- a/app/stores/base/Store.ts +++ b/app/stores/base/Store.ts @@ -5,11 +5,12 @@ import orderBy from "lodash/orderBy"; import { observable, action, computed, runInAction } from "mobx"; import pluralize from "pluralize"; import { Pagination } from "@shared/constants"; +import { type JSONObject } from "@shared/types"; import RootStore from "~/stores/RootStore"; import Policy from "~/models/Policy"; import Model from "~/models/base/Model"; import { getInverseRelationsForModelClass } from "~/models/decorators/Relation"; -import { PaginationParams, PartialWithId } from "~/types"; +import type { PaginationParams, PartialWithId, Properties } from "~/types"; import { client } from "~/utils/ApiClient"; import { AuthorizationError, NotFoundError } from "~/utils/errors"; @@ -125,12 +126,9 @@ export default abstract class Store { this.data.delete(id); } - save( - params: Partial, - options: Record = {} - ): Promise { + save(params: Properties, options: JSONObject = {}): Promise { const { isNew, ...rest } = options; - if (isNew || !params.id) { + if (isNew || !("id" in params)) { return this.create(params, rest); } return this.update(params, rest); @@ -141,10 +139,7 @@ export default abstract class Store { } @action - async create( - params: Partial, - options?: Record - ): Promise { + async create(params: Properties, options?: JSONObject): Promise { if (!this.actions.includes(RPCAction.Create)) { throw new Error(`Cannot create ${this.modelName}`); } @@ -168,10 +163,7 @@ export default abstract class Store { } @action - async update( - params: Partial, - options?: Record - ): Promise { + async update(params: Properties, options?: JSONObject): Promise { if (!this.actions.includes(RPCAction.Update)) { throw new Error(`Cannot update ${this.modelName}`); } @@ -195,7 +187,7 @@ export default abstract class Store { } @action - async delete(item: T, options: Record = {}) { + async delete(item: T, options: JSONObject = {}) { if (!this.actions.includes(RPCAction.Delete)) { throw new Error(`Cannot delete ${this.modelName}`); } @@ -218,7 +210,7 @@ export default abstract class Store { } @action - async fetch(id: string, options: Record = {}): Promise { + async fetch(id: string, options: JSONObject = {}): Promise { if (!this.actions.includes(RPCAction.Info)) { throw new Error(`Cannot fetch ${this.modelName}`); } diff --git a/app/types.ts b/app/types.ts index 108fdc315..8000bea6e 100644 --- a/app/types.ts +++ b/app/types.ts @@ -1,5 +1,7 @@ +/* eslint-disable @typescript-eslint/ban-types */ import { Location, LocationDescriptor } from "history"; import { TFunction } from "i18next"; +import { JSONValue } from "@shared/types"; import RootStore from "~/stores/RootStore"; import Document from "./models/Document"; import FileOperation from "./models/FileOperation"; @@ -198,3 +200,10 @@ export type WebsocketEvent = export type AwarenessChangeEvent = { states: { user?: { id: string }; cursor: any; scrollY: number | undefined }[]; }; + +// TODO: Can we make this type driven by the @Field decorator +export type Properties = { + [Property in keyof C as C[Property] extends JSONValue + ? Property + : never]?: C[Property]; +}; diff --git a/app/utils/ApiClient.ts b/app/utils/ApiClient.ts index 0275487e1..f8519fd51 100644 --- a/app/utils/ApiClient.ts +++ b/app/utils/ApiClient.ts @@ -2,6 +2,7 @@ import retry from "fetch-retry"; import trim from "lodash/trim"; import queryString from "query-string"; import EDITOR_VERSION from "@shared/editor/version"; +import { JSONObject } from "@shared/types"; import stores from "~/stores"; import Logger from "./Logger"; import download from "./download"; @@ -23,11 +24,11 @@ type Options = { baseUrl?: string; }; -type FetchOptions = { +interface FetchOptions { download?: boolean; credentials?: "omit" | "same-origin" | "include"; headers?: Record; -}; +} const fetchWithRetry = retry(fetch); @@ -38,12 +39,12 @@ class ApiClient { this.baseUrl = options.baseUrl || "/api"; } - fetch = async ( + fetch = async ( path: string, method: string, - data: Record | FormData | undefined, + data: JSONObject | FormData | undefined, options: FetchOptions = {} - ) => { + ): Promise => { let body: string | FormData | undefined; let modifiedPath; let urlToFetch; @@ -123,9 +124,9 @@ class ApiClient { response.headers.get("content-disposition") || "" ).split("filename=")[1]; download(blob, trim(fileName, '"')); - return; + return undefined as T; } else if (success && response.status === 204) { - return; + return undefined as T; } else if (success) { return response.json(); } @@ -133,7 +134,7 @@ class ApiClient { // Handle 401, log out user if (response.status === 401) { await stores.auth.logout(true, false); - return; + throw new AuthorizationError(); } // Handle failed responses @@ -168,7 +169,6 @@ class ApiClient { if (response.status === 403) { if (error.error === "user_suspended") { await stores.auth.logout(false, false); - return; } throw new AuthorizationError(error.message); @@ -204,17 +204,17 @@ class ApiClient { throw err; }; - get = ( + get = ( path: string, - data: Record | undefined, + data: JSONObject | undefined, options?: FetchOptions - ) => this.fetch(path, "GET", data, options); + ) => this.fetch(path, "GET", data, options); - post = ( + post = ( path: string, - data?: Record | undefined, + data?: JSONObject | FormData | undefined, options?: FetchOptions - ) => this.fetch(path, "POST", data, options); + ) => this.fetch(path, "POST", data, options); } export const client = new ApiClient(); diff --git a/shared/types.ts b/shared/types.ts index 41ac60735..0e0985b5c 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -250,5 +250,15 @@ export type Unfurl = { meta?: Record; }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type ProsemirrorData = Record; +export type JSONValue = + | string + | number + | boolean + | undefined + | null + | { [x: string]: JSONValue } + | Array; + +export type JSONObject = { [x: string]: JSONValue }; + +export type ProsemirrorData = JSONObject;