import { nanoid } from "nanoid" import type { DocumentBranch, DocumentBranchCreateRequest, DocumentBranchCreateResponse, DocumentBranchesResponse, DocumentBranchSearchResponse, BranchReviewersResponse, DocumentCreateRequest, DocumentCreateResponse, DocumentMaintainersResponse, DocumentRecentsResponse, DocumentSearchParams, DocumentSearchResponse, DocumentTreeElement, DocumentTreeResponse, DocumentTreeUpdateRequest, DocumentViewSource, MetricSimulationCheckResponse, } from "~/utils" import isDeepEqual from "fast-deep-equal" import { DOCUMENT_HOOK_QUERY_KEYS } from "./useDocumentHookAPI" import { TAG_QUERY_KEYS } from "./useTagAPI" // exported because the tag tree carries document summaries: useTagAPI reads // this entry to build the row it optimistically inserts under a tag. export const DOCUMENT_QUERY_KEYS = { root: ["documents", "tree"] as const, branches: (docId: string) => ["documents", docId, "branches"] as const, documentMaintainers: (docId: string) => ["documents", docId, "maintainers"] as const, branchReviewers: (docId: string, branchId: string) => ["documents", docId, "branches", branchId, "reviewers"] as const, search: (q: string, currentDocId: string | null) => ["documents", "search", q, currentDocId] as const, recent: (from: DocumentViewSource) => ["documents", "recent", from] as const, } export default function () { const { $coreAPIClient, $authRealtimeAPIClient } = useNuxtApp() const { fetchAuthSession, fetchOrganization } = useAuthSession() const queryCache = useQueryCache() const fetchDocumentTree = useQuery({ key: DOCUMENT_QUERY_KEYS.root, query: async () => { return await $coreAPIClient(`/api/documents/tree`, { method: "GET", }) }, refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, staleTime: 3 * 60 * 1000, // 3mins autoRefetch: true, }) const updateDocumentTree = useMutation< any, UnprocessedDocumentTreeUpdateRequest, Error, { newTree: DocumentTreeResponse oldTree: DocumentTreeResponse | undefined finalReq: DocumentTreeUpdateRequest } >({ onMutate(req) { if (!isXid(req.id)) { // optimisticInserts use nanoid return } const oldTree = clone( queryCache.getQueryData(DOCUMENT_QUERY_KEYS.root), ) const newTree = clone(oldTree) ?? [] // the assertions keep the declared union as the flow type: walkTree // assigns these from a closure, which TS's flow analysis ignores, // so without them every check below looks dead to TS. let targetItem = null as DocumentTreeElement | null let targetItemOldIndex = null as number | null let newParent = null as DocumentTreeElement | "root" | null let oldParent = null as DocumentTreeElement | "root" | null const walkTree = ( elems: DocumentTreeElement[], parent: DocumentTreeElement | "root", ): boolean => { for (const [i, elem] of elems.entries()) { if (!targetItem) { if (elem.id === req.id) { targetItem = elem targetItemOldIndex = i oldParent = parent } } if (!newParent) { if (elem.id === req.parentId) { newParent = elem } } if ( targetItem && targetItemOldIndex !== null && newParent && oldParent ) { return true } if (elem.children) { if (walkTree(elem.children, elem)) { return true } } } return false } if (req.parentId === null) { newParent = "root" } walkTree(newTree, "root") if ( !targetItem || targetItemOldIndex === null || !newParent || !oldParent ) { throw new Error("invalid document tree update data") } if (oldParent === "root") { newTree.splice(targetItemOldIndex, 1) } else { const oldP = oldParent oldP.children?.splice(targetItemOldIndex, 1) if (!oldP.children?.length) { delete oldP.children } } let targetItemNewIndex = 0 if (newParent === "root") { if (req.insertBeforeId) { for (const [i, elem] of newTree.entries()) { if (elem.id === req.insertBeforeId) { // this item will be pushed down and the new item // will be inserted in its place targetItemNewIndex = i break } } } newTree.splice(targetItemNewIndex, 0, targetItem) } else { const newP = newParent if (req.insertBeforeId) { for (const [i, child] of (newP.children ?? []).entries()) { if (child.id === req.insertBeforeId) { // this item will be pushed down and the new item // will be inserted in its place targetItemNewIndex = i break } } } newP.children ??= [] newP.children.splice(targetItemNewIndex, 0, targetItem) } const finalReq: DocumentTreeUpdateRequest = { id: req.id, parentId: req.parentId, sortIndex: targetItemNewIndex, } queryCache.setQueryData(DOCUMENT_QUERY_KEYS.root, newTree) queryCache.cancelQueries({ key: DOCUMENT_QUERY_KEYS.root }) return { newTree, oldTree, finalReq } }, mutation: async (req, ctx) => { if (!isXid(req.id)) { // optimisticInserts use nanoid — onMutate bails out without a // context for them, so the guard runs before touching it return } await $coreAPIClient(`/api/documents/tree`, { method: "PUT", body: ctx.finalReq, }) }, async onSuccess(_data, req) { if (!isXid(req.id)) { return } await queryCache.invalidateQueries({ key: DOCUMENT_QUERY_KEYS.root }) }, onError(_err, _title, { oldTree, newTree }) { const cachedTree = queryCache.getQueryData(DOCUMENT_QUERY_KEYS.root) if (!isDeepEqual(newTree, cachedTree)) { return } // rollback queryCache.setQueryData(DOCUMENT_QUERY_KEYS.root, oldTree) }, }) const createDocument = useMutation({ onMutate(req) { if ( req.skipLocalOptimisticInsert || (req.parentId && !isXid(req.parentId)) ) { // optimisticInserts use nanoid return } const oldTree = clone( queryCache.getQueryData(DOCUMENT_QUERY_KEYS.root), ) const newTreeElem: DocumentTreeElement = { id: nanoid(), documentName: req.name, icon: req.icon, children: null, protected: false, localOptimisticInsert: true, } const newTree = clone(oldTree) ?? [] if (!newTree.length) { newTree.push(newTreeElem) } else { if (!req.parentId) { newTree.unshift(newTreeElem) } else { const walkTree = (elems: DocumentTreeElement[]): boolean => { for (const elem of elems) { if (elem.id === req.parentId) { elem.children ??= [] elem.children.unshift(newTreeElem) return true } if (elem.children) { if (walkTree(elem.children)) { return true } } } return false } walkTree(newTree) } } queryCache.setQueryData(DOCUMENT_QUERY_KEYS.root, newTree) queryCache.cancelQueries({ key: DOCUMENT_QUERY_KEYS.root }) return { newTree, oldTree, newTreeElem } }, mutation: async (req: DocumentCreateRequest) => { if (req.parentId && !isXid(req.parentId)) { // optimisticInserts use nanoid return } const cleanReq = clone(req) delete cleanReq.skipLocalOptimisticInsert return await $coreAPIClient(`/api/documents`, { method: "POST", body: cleanReq, }) }, async onSuccess(_data, req) { if (req.parentId && !isXid(req.parentId)) { return } await queryCache.invalidateQueries({ key: DOCUMENT_QUERY_KEYS.root }) }, onError(_err, _data, { oldTree, newTree }) { const cachedTree = queryCache.getQueryData(DOCUMENT_QUERY_KEYS.root) if (!isDeepEqual(newTree, cachedTree)) { return } // rollback queryCache.setQueryData(DOCUMENT_QUERY_KEYS.root, oldTree) }, }) const deleteDocument = useMutation({ onMutate(id) { if (!isXid(id)) { // optimisticInserts use nanoid return } const oldTree = clone( queryCache.getQueryData(DOCUMENT_QUERY_KEYS.root), ) const newTree = clone(oldTree) ?? [] const walkTree = (elems: DocumentTreeElement[]): boolean => { for (const [i, elem] of elems.entries()) { if (elem.id === id) { elems.splice(i, 1) return true } if (elem.children) { if (walkTree(elem.children)) { if (!elem.children.length) { delete elem.children } return true } } } return false } walkTree(newTree) queryCache.setQueryData(DOCUMENT_QUERY_KEYS.root, newTree) queryCache.cancelQueries({ key: DOCUMENT_QUERY_KEYS.root }) return { newTree, oldTree } }, mutation: async (id: string) => { if (!isXid(id)) { // optimisticInserts use nanoid return } await $coreAPIClient(`/api/documents/${id}`, { method: "DELETE", }) }, async onSuccess(_data, id) { if (!isXid(id)) { return } await queryCache.invalidateQueries({ key: DOCUMENT_QUERY_KEYS.root }) // the document leaves every tag it was listed under await queryCache.invalidateQueries({ key: TAG_QUERY_KEYS.tree }) }, onError(_err, _title, { oldTree, newTree }) { const cachedTree = queryCache.getQueryData(DOCUMENT_QUERY_KEYS.root) if (!isDeepEqual(newTree, cachedTree)) { return } // rollback queryCache.setQueryData(DOCUMENT_QUERY_KEYS.root, oldTree) }, }) function updateDocumentTreeElementCache( id: string, data: { name: string icon: string protected: boolean }, ) { const tree = clone( queryCache.getQueryData(DOCUMENT_QUERY_KEYS.root), ) if (!tree) { return } const walkTree = (elems: DocumentTreeElement[]): boolean => { for (const elem of elems) { if (elem.id === id) { elem.documentName = data.name elem.icon = data.icon elem.protected = data.protected return true } if (elem.children) { if (walkTree(elem.children)) { return true } } } return false } walkTree(tree) queryCache.setQueryData(DOCUMENT_QUERY_KEYS.root, tree) } const duplicateDocument = useMutation({ onMutate(id: string) { if (!isXid(id)) { return } const oldTree = clone( queryCache.getQueryData(DOCUMENT_QUERY_KEYS.root), ) // Find the source document to get its name, icon, and parent let name = "New Document" let icon = "mingcute:document-2-fill" // the assertion keeps the declared union as the flow type: findSource // assigns parentId from a closure, which TS's flow analysis ignores. let parentId = null as string | null const findSource = ( elems: DocumentTreeElement[], parent: string | null, ): boolean => { for (const elem of elems) { if (elem.id === id) { name = elem.documentName icon = elem.icon parentId = parent return true } if (elem.children) { if (findSource(elem.children, elem.id)) { return true } } } return false } const newTree = clone(oldTree) ?? [] findSource(newTree, null) const now = new Date() const month = now.toLocaleString("en-US", { month: "short" }) const timestamp = `${now.getFullYear()} ${month}. ${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}` const newTreeElem: DocumentTreeElement = { id: nanoid(), documentName: `${name} (${timestamp})`, icon: icon, children: null, protected: false, localOptimisticInsert: true, } // Insert as sibling (same parent as source) if (!parentId) { newTree.unshift(newTreeElem) } else { const insertAsSibling = (elems: DocumentTreeElement[]): boolean => { for (const elem of elems) { if (elem.id === parentId) { elem.children ??= [] elem.children.unshift(newTreeElem) return true } if (elem.children) { if (insertAsSibling(elem.children)) { return true } } } return false } insertAsSibling(newTree) } queryCache.setQueryData(DOCUMENT_QUERY_KEYS.root, newTree) queryCache.cancelQueries({ key: DOCUMENT_QUERY_KEYS.root }) return { newTree, oldTree, newTreeElem } }, mutation: async (id: string) => { if (!isXid(id)) { return } return await $coreAPIClient( `/api/documents/${id}/duplicate`, { method: "POST", }, ) }, async onSuccess(_data, id) { if (!isXid(id)) { return } await queryCache.invalidateQueries({ key: DOCUMENT_QUERY_KEYS.root }) // the copy carries the source's tags, so the tag tree lists it too await queryCache.invalidateQueries({ key: TAG_QUERY_KEYS.tree }) }, onError(_err, _id, { oldTree, newTree }) { const cachedTree = queryCache.getQueryData(DOCUMENT_QUERY_KEYS.root) if (!isDeepEqual(newTree, cachedTree)) { return } // rollback queryCache.setQueryData(DOCUMENT_QUERY_KEYS.root, oldTree) }, }) function useSearchDocuments( paramsRef: MaybeRefOrGetter, ) { return useInfiniteQuery({ key: () => { const params = toValue(paramsRef) return DOCUMENT_QUERY_KEYS.search(params.q, params.currentDocId) }, query: async ({ pageParam }) => { const params = toValue(paramsRef) const searchParams = new URLSearchParams({ q: params.q }) if (params.currentDocId) { searchParams.set("currentDocId", params.currentDocId) } if (pageParam) { searchParams.set("nextToken", pageParam) } return await $coreAPIClient( `/api/documents/search?${searchParams.toString()}`, { method: "GET" }, ) }, initialPageParam: null as string | null, getNextPageParam: (lastPage) => lastPage.nextToken, enabled: () => toValue(paramsRef).q.length >= DOCUMENT_SEARCH_QUERY_MIN_LENGTH, // the previous search stays on screen while the next one loads, // but not across an emptied query placeholderData: (previousData) => toValue(paramsRef).q ? previousData : undefined, refetchOnWindowFocus: false, refetchOnReconnect: false, // a search is one-off, so its result is dropped once the query // text moves on. Typing the same text again searches again. gcTime: 0, }) } // loads the hits of one branch that come after the given token. The // first token is the grouped result's nextHitsToken. async function searchDocumentBranch( docId: string, branchId: string, q: string, nextToken: string, ): Promise { const searchParams = new URLSearchParams({ q, nextToken }) return await $coreAPIClient( `/api/documents/${docId}/branches/${branchId}/search?${searchParams.toString()}`, { method: "GET" }, ) } // the branches the user opened most recently. A source other than // "all" lists only the opens recorded with it function useFetchRecentDocuments( from: DocumentViewSource, enabledRef: MaybeRefOrGetter, ) { return useQuery({ key: DOCUMENT_QUERY_KEYS.recent(from), query: async () => { return await $coreAPIClient( `/api/documents/recent?from=${from}`, { method: "GET" }, ) }, enabled: () => toValue(enabledRef), refetchOnWindowFocus: false, refetchOnReconnect: false, staleTime: 60 * 1000, // 3 min }) } // records that the user opened the branch. An open that names its // source also counts under "all", and changes that source's recent list. const recordBranchView = useMutation({ mutation: async ({ docId, branchId, from, }: { docId: string branchId: string from?: DocumentViewSource }) => { if (!isXid(docId) || !isXid(branchId)) { return } await $coreAPIClient( `/api/documents/${docId}/branches/${branchId}/views${from ? `?from=${from}` : ""}`, { method: "POST" }, ) }, async onSuccess(_data, { docId, branchId, from }) { if (!from || !isXid(docId) || !isXid(branchId)) { return } await queryCache.invalidateQueries({ key: DOCUMENT_QUERY_KEYS.recent(from), }) }, }) // runs a block, which core resolves from the stored branch and // dispatches on its kind. For a metric block that means probing whether // the data it simulates has arrived; core owns the answer because it // also removes the simulation from the block when it has, and it caches // the probe so several viewers do not each hit the data source. async function checkMetricBlockSimulation( documentId: string, branchId: string, blockUid: string, ): Promise { return await $coreAPIClient( `/api/documents/${documentId}/branches/${branchId}/blocks/${encodeURIComponent(blockUid)}/run`, { method: "POST", }, ) } const updateDocumentBranch = useMutation({ onMutate({ id, branchId, protectedMode, }: { id: string branchId: string protectedMode: boolean }) { if (!isXid(id) || !isXid(branchId)) { // optimisticInserts use nanoid return } const oldTree = clone( queryCache.getQueryData(DOCUMENT_QUERY_KEYS.root), ) const newTree = clone(oldTree) ?? [] const walkTree = (elems: DocumentTreeElement[]): boolean => { for (const elem of elems) { if (elem.id === id) { elem.protected = protectedMode return true } if (elem.children) { if (walkTree(elem.children)) { return true } } } return false } walkTree(newTree) queryCache.setQueryData(DOCUMENT_QUERY_KEYS.root, newTree) queryCache.cancelQueries({ key: DOCUMENT_QUERY_KEYS.root }) return { newTree, oldTree } }, mutation: async ({ id, branchId, protectedMode, }: { id: string branchId: string protectedMode: boolean }) => { if (!isXid(id) || !isXid(branchId)) { // optimisticInserts use nanoid return } // auth-realtime stores what the editor still holds before core // changes the branch, and resets its connections afterwards. await $authRealtimeAPIClient( `/api/documents/${id}/branches/${branchId}`, { method: "PUT", body: { protected: protectedMode, }, }, ) }, async onSuccess(_data, { id, branchId }) { if (!isXid(id) || !isXid(branchId)) { return } await queryCache.invalidateQueries({ key: DOCUMENT_QUERY_KEYS.root }) await queryCache.invalidateQueries({ key: DOCUMENT_QUERY_KEYS.branches(id), }) }, onError(_err, _title, { oldTree, newTree }) { const cachedTree = queryCache.getQueryData(DOCUMENT_QUERY_KEYS.root) if (!isDeepEqual(newTree, cachedTree)) { return } // rollback queryCache.setQueryData(DOCUMENT_QUERY_KEYS.root, oldTree) }, }) const createDocumentBranch = useMutation({ onMutate({ docId, req, }: { docId: string req: DocumentBranchCreateRequest }) { if (!isXid(docId) || !isXid(req.sourceBranchId)) { return } const key = DOCUMENT_QUERY_KEYS.branches(docId) const oldBranches = clone( queryCache.getQueryData(key), ) // Copy metadata from source branch for the optimistic insert const sourceBranch = oldBranches?.find( (b) => b.branchId === req.sourceBranchId, ) const newBranch: DocumentBranch = { branchId: nanoid(), branchName: req.branch, documentName: sourceBranch?.documentName || "", icon: sourceBranch?.icon || "", protected: false, default: false, createdAt: new Date(), updatedAt: new Date(), } const newBranches = [...(oldBranches ?? []), newBranch] queryCache.setQueryData(key, newBranches) queryCache.cancelQueries({ key }) return { newBranches, oldBranches, key } }, mutation: async ({ docId, req, }: { docId: string req: DocumentBranchCreateRequest }) => { if (!isXid(docId) || !isXid(req.sourceBranchId)) { return } // auth-realtime stores what the editor still holds before core // copies the source branch. return await $authRealtimeAPIClient( `/api/documents/${docId}/branches`, { method: "POST", body: req, }, ) }, async onSuccess(_data, { docId, req }) { if (!isXid(docId) || !isXid(req.sourceBranchId)) { return } await queryCache.invalidateQueries({ key: DOCUMENT_QUERY_KEYS.branches(docId), }) }, onError(_err, { docId, req }, ctx) { if (!isXid(docId) || !isXid(req.sourceBranchId) || !ctx.key) { return } const cachedBranches = queryCache.getQueryData( ctx.key, ) if (!isDeepEqual(ctx.newBranches, cachedBranches)) { return } // rollback queryCache.setQueryData(ctx.key, ctx.oldBranches) }, }) const deleteDocumentBranch = useMutation({ onMutate({ docId, branchId }: { docId: string; branchId: string }) { if (!isXid(docId) || !isXid(branchId)) { return } const key = DOCUMENT_QUERY_KEYS.branches(docId) const oldBranches = clone( queryCache.getQueryData(key), ) const newBranches = clone(oldBranches) ?? [] const index = newBranches.findIndex((b) => b.branchId === branchId) if (index !== -1) { newBranches.splice(index, 1) } queryCache.setQueryData(key, newBranches) queryCache.cancelQueries({ key }) return { newBranches, oldBranches, key } }, mutation: async ({ docId, branchId, }: { docId: string branchId: string }) => { if (!isXid(docId) || !isXid(branchId)) { return } // auth-realtime drops the connections still on the branch once // core has removed it, so nobody keeps editing a branch that is // gone. await $authRealtimeAPIClient( `/api/documents/${docId}/branches/${branchId}`, { method: "DELETE", }, ) }, async onSuccess(_data, { docId, branchId }) { if (!isXid(docId) || !isXid(branchId)) { return } await queryCache.invalidateQueries({ key: DOCUMENT_QUERY_KEYS.branches(docId), }) }, onError(_err, { docId, branchId }, ctx) { if (!isXid(docId) || !isXid(branchId) || !ctx.key) { return } const cachedBranches = queryCache.getQueryData( ctx.key, ) if (!isDeepEqual(ctx.newBranches, cachedBranches)) { return } // rollback queryCache.setQueryData(ctx.key, ctx.oldBranches) }, }) const mergeDocumentBranches = useMutation({ // merging is too complex for an optimistic update mutation: async ({ docId, fromBranchId, toBranchId, }: { docId: string fromBranchId: string toBranchId: string }) => { if (!isXid(docId) || !isXid(fromBranchId) || !isXid(toBranchId)) { return } await $authRealtimeAPIClient(`/api/documents/${docId}/merge`, { method: "PUT", body: { fromBranchId, toBranchId }, }) }, async onSuccess(_data, { docId, fromBranchId, toBranchId }) { if (!isXid(docId) || !isXid(fromBranchId) || !isXid(toBranchId)) { return } await queryCache.invalidateQueries({ key: DOCUMENT_QUERY_KEYS.branchReviewers(docId, fromBranchId), }) await queryCache.invalidateQueries({ key: DOCUMENT_QUERY_KEYS.branchReviewers(docId, toBranchId), }) // the target took the source's hooks and tags along with its // content. Every tag query goes, not just the target branch's: // when the target is the default branch the sidebar's tag tree // lists the document by it await queryCache.invalidateQueries({ key: DOCUMENT_HOOK_QUERY_KEYS.list(docId, toBranchId), }) await queryCache.invalidateQueries({ key: TAG_QUERY_KEYS.all }) }, }) function useFetchDocumentMaintainersByDocId( docIdRef: MaybeRefOrGetter, ) { return useQuery({ key: DOCUMENT_QUERY_KEYS.documentMaintainers(toValue(docIdRef) || ""), query: async () => { const docId = toValue(docIdRef) if (!docId) { return [] } return await $coreAPIClient( `/api/documents/${docId}/maintainers`, { method: "GET", }, ) }, refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, staleTime: 6 * 60 * 1000, // 6 mins autoRefetch: true, }) } function useFetchDocumentBranchesByDocId( docIdRef: MaybeRefOrGetter, ) { return useQuery({ key: () => DOCUMENT_QUERY_KEYS.branches(toValue(docIdRef) || ""), query: async () => { const docId = toValue(docIdRef) if (!docId) { return [] } return await $coreAPIClient( `/api/documents/${docId}/branches`, { method: "GET", }, ) }, enabled: () => toValue(docIdRef) !== null && toValue(docIdRef) !== undefined, refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, staleTime: 6 * 60 * 1000, // 6 mins autoRefetch: true, }) } function useFetchBranchReviewers( docIdRef: MaybeRefOrGetter, branchIdRef: MaybeRefOrGetter, ) { return useQuery({ key: () => DOCUMENT_QUERY_KEYS.branchReviewers( toValue(docIdRef) || "", toValue(branchIdRef) || "", ), query: async () => { const docId = toValue(docIdRef) const branchId = toValue(branchIdRef) if (!docId || !branchId) { return [] } // the core handler responds with the db slice unwrapped, and // a branch without reviewers yields a nil slice — JSON null return ( (await $coreAPIClient( `/api/documents/${docId}/branches/${branchId}/reviewers`, { method: "GET", }, )) ?? [] ) }, enabled: () => !!toValue(docIdRef) && !!toValue(branchIdRef), refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, staleTime: 6 * 60 * 1000, // 6 mins autoRefetch: true, }) } const updateBranchApproval = useMutation({ onMutate: ({ docId, branchId, approved, }: { docId: string branchId: string approved: boolean }) => { if (!isXid(docId) || !isXid(branchId)) { return } const userId = fetchAuthSession.data.value?.data?.user.id if (!userId) { return } const organizationId = fetchOrganization.data.value?.data?.id if (!organizationId) { return } const key = DOCUMENT_QUERY_KEYS.branchReviewers(docId, branchId) const oldReviewers = clone( queryCache.getQueryData(key), ) const newReviewers = clone(oldReviewers) ?? [] const found = newReviewers.find((r) => r.userId === userId) if (found) { found.currentlyApproved = approved } else { newReviewers.push({ branchId, userId, organizationId: organizationId, currentlyApproved: approved, previouslyApproved: false, }) } queryCache.setQueryData(key, newReviewers) queryCache.cancelQueries({ key }) return { newReviewers, oldReviewers, key } }, mutation: async ({ docId, branchId, approved, }: { docId: string branchId: string approved: boolean }) => { if (!isXid(docId) || !isXid(branchId)) { return } await $coreAPIClient( `/api/documents/${docId}/branches/${branchId}/review-approve`, { method: "PUT", body: { approved }, }, ) }, async onSuccess(_data, { docId, branchId }) { if (!isXid(docId) || !isXid(branchId)) { return } await queryCache.invalidateQueries({ key: DOCUMENT_QUERY_KEYS.branchReviewers(docId, branchId), }) }, onError(_err, { docId, branchId }, ctx) { if (!isXid(docId) || !isXid(branchId) || !ctx.key) { return } const cachedReviewers = queryCache.getQueryData( ctx.key, ) if (!isDeepEqual(ctx.newReviewers, cachedReviewers)) { return } queryCache.setQueryData(ctx.key, ctx.oldReviewers) }, }) const inviteBranchReviewer = useMutation({ onMutate: ({ docId, branchId, userId, }: { docId: string branchId: string userId: string }) => { if (!isXid(docId) || !isXid(branchId) || isOptimisticInsertId(userId)) { return } const organizationId = fetchOrganization.data.value?.data?.id if (!organizationId) { return } const key = DOCUMENT_QUERY_KEYS.branchReviewers(docId, branchId) const oldReviewers = clone( queryCache.getQueryData(key), ) const newReviewers = clone(oldReviewers) ?? [] const existing = newReviewers.find((r) => r.userId === userId) if (existing) { existing.previouslyApproved = existing.currentlyApproved existing.currentlyApproved = false } else { newReviewers.push({ branchId, userId, organizationId: organizationId, currentlyApproved: false, previouslyApproved: false, }) } queryCache.setQueryData(key, newReviewers) queryCache.cancelQueries({ key }) return { newReviewers, oldReviewers, key } }, mutation: async ({ docId, branchId, userId, }: { docId: string branchId: string userId: string }) => { if (!isXid(docId) || !isXid(branchId) || isOptimisticInsertId(userId)) { return } await $coreAPIClient( `/api/documents/${docId}/branches/${branchId}/reviewers`, { method: "POST", body: { userId }, }, ) }, async onSuccess(_data, { docId, branchId, userId }) { if (!isXid(docId) || !isXid(branchId) || isOptimisticInsertId(userId)) { return } // the key is rebuilt from the variables rather than read off the // context: onMutate skips the optimistic insert (and its context) // without an organization, but the request still went out await queryCache.invalidateQueries({ key: DOCUMENT_QUERY_KEYS.branchReviewers(docId, branchId), }) }, onError(_err, { docId, branchId, userId }, ctx) { if ( !isXid(docId) || !isXid(branchId) || isOptimisticInsertId(userId) || !ctx.key ) { return } const cachedReviewers = queryCache.getQueryData( ctx.key, ) if (!isDeepEqual(ctx.newReviewers, cachedReviewers)) { return } queryCache.setQueryData(ctx.key, ctx.oldReviewers) }, }) const removeBranchReviewer = useMutation({ onMutate: ({ docId, branchId, userId, }: { docId: string branchId: string userId: string }) => { if (!isXid(docId) || !isXid(branchId) || isOptimisticInsertId(userId)) { return } const key = DOCUMENT_QUERY_KEYS.branchReviewers(docId, branchId) const oldReviewers = clone( queryCache.getQueryData(key), ) const newReviewers = clone(oldReviewers) ?? [] const index = newReviewers.findIndex((h) => h.userId === userId) if (index !== -1) { newReviewers.splice(index, 1) } queryCache.setQueryData(key, newReviewers) queryCache.cancelQueries({ key }) return { newReviewers, oldReviewers, key } }, mutation: async ({ docId, branchId, userId, }: { docId: string branchId: string userId: string }) => { if (!isXid(docId) || !isXid(branchId) || isOptimisticInsertId(userId)) { return } await $coreAPIClient( `/api/documents/${docId}/branches/${branchId}/reviewers?userId=${encodeURIComponent(userId)}`, { method: "DELETE", }, ) }, async onSuccess(_data, { docId, branchId, userId }) { if (!isXid(docId) || !isXid(branchId) || isOptimisticInsertId(userId)) { return } await queryCache.invalidateQueries({ key: DOCUMENT_QUERY_KEYS.branchReviewers(docId, branchId), }) }, onError(_err, { docId, branchId, userId }, ctx) { if ( !isXid(docId) || !isXid(branchId) || isOptimisticInsertId(userId) || !ctx.key ) { return } const cachedReviewers = queryCache.getQueryData( ctx.key, ) if (!isDeepEqual(ctx.newReviewers, cachedReviewers)) { return } queryCache.setQueryData(ctx.key, ctx.oldReviewers) }, }) return { fetchDocumentTree, updateDocumentTree, createDocument, duplicateDocument, useSearchDocuments, searchDocumentBranch, useFetchRecentDocuments, recordBranchView, deleteDocument, updateDocumentTreeElementCache, useFetchDocumentMaintainersByDocId, useFetchBranchReviewers, inviteBranchReviewer, removeBranchReviewer, updateBranchApproval, useFetchDocumentBranchesByDocId, createDocumentBranch, deleteDocumentBranch, updateDocumentBranch, mergeDocumentBranches, checkMetricBlockSimulation, } }