diff --git a/client/package-lock.json b/client/package-lock.json index 294690959..9ae006da9 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -43,7 +43,7 @@ "date-and-time": "0.14.2", "date-fns": "2.30.0", "date-fns-tz": "2.0.1", - "dompurify": "3.4.11", + "dompurify": "3.4.13", "extended-eventsource": "2.1.0", "file-saver": "2.0.5", "focus-trap-react": "10.3.1", @@ -71,7 +71,7 @@ "react-redux": "7.2.9", "react-responsive": "9.0.2", "react-router-dom": "5.3.4", - "react-router-dom-v5-compat": "6.30.3", + "react-router-dom-v5-compat": "6.30.4", "react-select": "5.10.2", "react-simple-maps": "3.0.0", "react-timezone-select": "2.1.5", @@ -7846,9 +7846,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.23.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", - "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -12055,9 +12055,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -16052,14 +16052,14 @@ } }, "node_modules/react-router-dom-v5-compat": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router-dom-v5-compat/-/react-router-dom-v5-compat-6.30.3.tgz", - "integrity": "sha512-WWZtwGYyoaeUDNrhzzDkh4JvN5nU0MIz80Dxim6pznQrfS+dv0mvtVoHTA6HlUl/OiJl7WWjbsQwjTnYXejEHg==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom-v5-compat/-/react-router-dom-v5-compat-6.30.4.tgz", + "integrity": "sha512-lJP6Zl6DYQtmrnaOV7MW5s/Npe7mYOokwekaPAqjCgIMQmZFfdA8mfoe5kWJoT/xedSdgZLrfFVHx18RUIIcEw==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2", + "@remix-run/router": "1.23.3", "history": "^5.3.0", - "react-router": "6.30.3" + "react-router": "6.30.4" }, "engines": { "node": ">=14.0.0" @@ -16080,12 +16080,12 @@ } }, "node_modules/react-router-dom-v5-compat/node_modules/react-router": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", - "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2" + "@remix-run/router": "1.23.3" }, "engines": { "node": ">=14.0.0" diff --git a/client/package.json b/client/package.json index 072f4ce73..24141c310 100644 --- a/client/package.json +++ b/client/package.json @@ -46,7 +46,7 @@ "date-and-time": "0.14.2", "date-fns": "2.30.0", "date-fns-tz": "2.0.1", - "dompurify": "3.4.11", + "dompurify": "3.4.13", "extended-eventsource": "2.1.0", "file-saver": "2.0.5", "focus-trap-react": "10.3.1", @@ -74,7 +74,7 @@ "react-redux": "7.2.9", "react-responsive": "9.0.2", "react-router-dom": "5.3.4", - "react-router-dom-v5-compat": "6.30.3", + "react-router-dom-v5-compat": "6.30.4", "react-select": "5.10.2", "react-simple-maps": "3.0.0", "react-timezone-select": "2.1.5", diff --git a/client/src/api.ts b/client/src/api.ts index 728ef6e00..840b57e34 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -1,10 +1,12 @@ import axios, { AxiosRequestConfig } from "axios"; import { - Announcement, AssetSearchParams, + Announcement, + AssetSearchParams, AssetTagFramework, AssetTagFrameworkWithCampusDefault, Author, - Book, BookSearchParams, + Book, + BookSearchParams, CentralIdentityApp, CentralIdentityLicense, CentralIdentityOrg, @@ -19,7 +21,8 @@ import { Homework, HomeworkSearchParams, Library, - PageDetailsResponse, PeerReview, + PageDetailsResponse, + PeerReview, PeerReviewRubric, Project, ProjectFile, @@ -36,18 +39,20 @@ import { BaseInvitation, Sender, ProjectSummary, - InvitationsResponse, TableOfContentsDetailed, + InvitationsResponse, + TableOfContentsDetailed, Note, StoreProduct, StoreCheckoutSessionItem, ClientConfig, StoreGetShippingOptionsRes, - StoreCheckoutForm, StoreAddressFields, + StoreCheckoutForm, + StoreAddressFields, StoreValidateAddressRes, EditAcademyOnlineAccessFormValues, CentralIdentityUserLicenseResult, CentralIdentityAppLicense, - StoreDigitalDeliveryOption, StoreOrderWithStripeSession, + StoreDigitalDeliveryOption, StoreOrderWithStripeSession, StoreOrderListItem, OrderCharge, OrderSession, CentralIdentityOrgAdminResult, @@ -62,7 +67,7 @@ import { BookWithAutoMatched, Organization, GetCampusAdminResponse, - UserTask + UserTask, } from "./types"; import { AddableProjectTeamMember, @@ -118,7 +123,11 @@ class API { * Returns the parsed JSON response, or throws with a shape compatible with * the Axios error format so callers can handle it uniformly. */ - private async streamJson(method: "PUT" | "POST", path: string, data: unknown): Promise { + private async streamJson( + method: "PUT" | "POST", + path: string, + data: unknown, + ): Promise { const response = await fetch(`${this.BASE_URL}${path}`, { method, headers: { @@ -133,11 +142,17 @@ class API { const text = await response.text().catch(() => ""); let parsed: Record = {}; - try { parsed = JSON.parse(text); } catch { /* non-JSON error body */ } + try { + parsed = JSON.parse(text); + } catch { + /* non-JSON error body */ + } if (!response.ok) { throw Object.assign( - new Error(String(parsed.errMsg ?? parsed.message ?? `HTTP ${response.status}`)), + new Error( + String(parsed.errMsg ?? parsed.message ?? `HTTP ${response.status}`), + ), { response: { status: response.status, data: parsed } }, ); } @@ -745,12 +760,11 @@ class API { }: { shipping_address: StoreAddressFields; }) { - const res = await axios.post( - "/store/checkout/validate-address", - { - shipping_address, - } - ); + const res = await axios.post< + StoreValidateAddressRes & ConductorBaseResponse + >("/store/checkout/validate-address", { + shipping_address, + }); return res; } @@ -762,7 +776,7 @@ class API { query?: string; }) { const res = await axios.get< - ConductorInfiniteScrollResponse + ConductorInfiniteScrollResponse >("/store/admin/orders", { params, }); @@ -1301,7 +1315,7 @@ class API { */ async getCommonsDownloadFileURL(bookID: string, fileID: string) { const res = await axios.get<{ url: string } & ConductorBaseResponse>( - `/commons/book/${bookID}/files/${fileID}/download` + `/commons/book/${bookID}/files/${fileID}/download`, ); return res; } @@ -1763,12 +1777,13 @@ class API { originalPublisher?: { name?: string; url?: string }; }, ) { - return await axios.patch< - { updatedCount: number } & ConductorBaseResponse - >(`/project/${projectID}/files/bulk/metadata`, { - fileIDs, - ...data, - }); + return await axios.patch<{ updatedCount: number } & ConductorBaseResponse>( + `/project/${projectID}/files/bulk/metadata`, + { + fileIDs, + ...data, + }, + ); } async getPublicProjectFiles(params?: { page?: number; limit?: number }) { @@ -1835,7 +1850,7 @@ class API { async updateQueueAutoAssignConfig( id: string, - config: { auto_assign_enabled: boolean; auto_assign_uuids: string[] } + config: { auto_assign_enabled: boolean; auto_assign_uuids: string[] }, ) { const res = await axios.put< { @@ -2072,11 +2087,11 @@ class API { autoCloseSilenced?: boolean; category?: string; queue?: string; // queue slug - } + }, ) { return await axios.patch<{ ticket: SupportTicket } & ConductorBaseResponse>( `/support/ticket/${ticketID}`, - payload + payload, ); } @@ -2558,8 +2573,8 @@ class API { aliases?: string[]; imageSource?: string; }) { - - const { coverID, library, imageFile, removeImage, aliases, ...rest } = props; + const { coverID, library, imageFile, removeImage, aliases, ...rest } = + props; const formData = new FormData(); Object.entries(rest).forEach(([key, value]) => { if (value !== undefined && value !== "") { @@ -2616,35 +2631,36 @@ class API { library: string; coverID: string; glossaryID: string; - auxGlossaryID?:string, auxGlossaryParentID?:string; + auxGlossaryID?: string; + auxGlossaryParentID?: string; }) { - const { library, coverID, glossaryID, auxGlossaryID, auxGlossaryParentID } = props; + const { library, coverID, glossaryID, auxGlossaryID, auxGlossaryParentID } = + props; const res = await axios.patch( - `/commons/book/${library}/${coverID}/glossary`, { glossaryID, auxGlossaryID, auxGlossaryParentID }); + `/commons/book/${library}/${coverID}/glossary`, + { glossaryID, auxGlossaryID, auxGlossaryParentID }, + ); return res.data; } async getExistingGlossary(library: string, coverID: string) { - const res = await axios.put< - ConductorBaseResponse - >(`/commons/book/${library}/${coverID}/glossary/existing`); + const res = await axios.put( + `/commons/book/${library}/${coverID}/glossary/existing`, + ); return res.data; } - async removePageFromGlossary(props: { - pageId: string; - usageId: string; - }) { - const { pageId, usageId, } = props; + async removePageFromGlossary(props: { pageId: string; usageId: string }) { + const { pageId, usageId } = props; const res = await axios.delete( - `/commons/glossary/usage/${usageId}/page/${pageId}`); + `/commons/glossary/usage/${usageId}/page/${pageId}`, + ); return res.data; } - async removeGlossaryTerm(props: { - usageId: string; - }) { + async removeGlossaryTerm(props: { usageId: string }) { const { usageId } = props; const res = await axios.delete( - `/commons/glossary/usage/${usageId}`); + `/commons/glossary/usage/${usageId}`, + ); return res.data; } @@ -2696,7 +2712,11 @@ class API { async saveRemixerProjectState( id: string, currentBook: unknown[], - settings?: { autoNumbering?: boolean; copyModeState?: string; pathLevelFormats?: unknown[] }, + settings?: { + autoNumbering?: boolean; + copyModeState?: string; + pathLevelFormats?: unknown[]; + }, ) { return this.streamJson< { @@ -2712,7 +2732,11 @@ class API { async publishRemixerProject( id: string, currentBook: unknown[], - settings?: { autoNumbering?: boolean; copyModeState?: string; pathLevelFormats?: unknown[] }, + settings?: { + autoNumbering?: boolean; + copyModeState?: string; + pathLevelFormats?: unknown[]; + }, ) { return this.streamJson< { @@ -2773,7 +2797,6 @@ class API { currentbook: boolean = true, option: { includeMatter: boolean; linkTitle: boolean; full: boolean }, ) { - const res = await axios.post(`/remixer/${id}/page`, { path, subdomain, @@ -2784,6 +2807,15 @@ class API { return res.data; } + async getRemixerTreeFlattened(id: string, path: string, subdomain: string) { + const res = await axios.post(`/remixer/${id}/page/tree`, { + path, + subdomain, + flatten: true, + }); + return res.data; + } + async createPressbooksJob(values: ImportWorkbenchForm, projectID: string) { const response = axios.post("/commons/import-pressbooks", { ...values, @@ -2794,41 +2826,54 @@ class API { async getRestackerToc(id: string) { // /projects/:projectID/restacker/toc - const res = await axios.get<{ - toc: TableOfContents; - } & ConductorBaseResponse & { status: "pending" | "completed" | "failed" }>(`/projects/${id}/restacker/toc`); + const res = await axios.get< + { + toc: TableOfContents; + } & ConductorBaseResponse & { status: "pending" | "completed" | "failed" } + >(`/projects/${id}/restacker/toc`); return res.data; } async getRestacker(id: string) { - const res = await axios.get<{ - restacker: RestackerEntry[]; - } & ConductorBaseResponse>(`/projects/${id}/restacker`); + const res = await axios.get< + { + restacker: RestackerEntry[]; + } & ConductorBaseResponse + >(`/projects/${id}/restacker`); return res.data; } async getRestackerStatus(id: string) { - const res = await axios.get<{ - status: "pending" | "completed" | "failed" | "notfound"; - processing: boolean; - total: number; - completed: number; - failed: number; - pending: number; - } & ConductorBaseResponse>(`/projects/${id}/restacker/status`); + const res = await axios.get< + { + status: "pending" | "completed" | "failed" | "notfound"; + processing: boolean; + total: number; + completed: number; + failed: number; + pending: number; + } & ConductorBaseResponse + >(`/projects/${id}/restacker/status`); return res.data; } async reloadRestacker(id: string) { - const res = await axios.post<{ - toc: TableOfContents; - } & ConductorBaseResponse & { status: "pending" | "completed" | "failed" }>(`/projects/${id}/restacker/toc`); + const res = await axios.post< + { + toc: TableOfContents; + } & ConductorBaseResponse & { status: "pending" | "completed" | "failed" } + >(`/projects/${id}/restacker/toc`); return res.data; } async updateRestackerLicense( projectID: string, - data: { pageID: string; license: string; version?: string; force?: boolean }, + data: { + pageID: string; + license: string; + version?: string; + force?: boolean; + }, ) { const res = await axios.patch< { @@ -2839,8 +2884,6 @@ class API { >(`/projects/${projectID}/restacker/license`, data); return res.data; } - - } export default new API(); diff --git a/client/src/components/Home/PinnedProjects/PinProjectsModal.tsx b/client/src/components/Home/PinnedProjects/PinProjectsModal.tsx index f3f2eb725..9f9f77688 100644 --- a/client/src/components/Home/PinnedProjects/PinProjectsModal.tsx +++ b/client/src/components/Home/PinnedProjects/PinProjectsModal.tsx @@ -167,6 +167,7 @@ const PinProjectsModal: React.FC = ({ > onClose()} className="text-blue-800 hover:text-blue-900 hover:underline font-semibold text-sm" > {p.title} diff --git a/client/src/components/remixer/RemixerDashboard.tsx b/client/src/components/remixer/RemixerDashboard.tsx index 393e2ca54..dbfff15e4 100644 --- a/client/src/components/remixer/RemixerDashboard.tsx +++ b/client/src/components/remixer/RemixerDashboard.tsx @@ -457,119 +457,46 @@ const RemixerDashboard: React.FC = () => { // API loaders & tree expansion // ========================================================================== - /** BFS-load the entire remote book (cover + every descendant) into a flat node list. */ + /** Load the entire remote book (cover + every descendant) in one request. */ const loadEntireBook = async ( projectId: string, coverPageId: string, libreLibrary: string, ): Promise => { - const nodesById = new Map(); - const fetchedParentIds = new Set(); - const queue: string[] = []; - - const rootDetails = await api.getRemixerPage( + const res = await api.getRemixerTreeFlattened( projectId, coverPageId, libreLibrary, - true, - true, - { includeMatter: false, linkTitle: true, full: false }, ); - const rootNode: RemixerSubPage = { - ...rootDetails.response, + const nodes: RemixerSubPage[] = res.response ?? []; + return nodes.map((node) => ({ + ...node, addedItem: false, - }; - nodesById.set(rootNode["@id"], rootNode); - queue.push(rootNode["@id"]); - - while (queue.length > 0) { - const parentId = queue.shift(); - if (!parentId || fetchedParentIds.has(parentId)) { - continue; - } - fetchedParentIds.add(parentId); + })); + }; - const response = await api.getRemixerPage( + /** Load a library subtree rooted at `rootNode` (used for catalog-book extract imports). */ + const loadLibrarySubtree = useCallback( + async ( + projectId: string, + rootNode: RemixerSubPage, + libreLibrary: string, + ): Promise => { + const res = await api.getRemixerTreeFlattened( projectId, - parentId, + rootNode["@id"], libreLibrary, - false, - true, - { includeMatter: false, linkTitle: true, full: false }, ); - - const children: RemixerSubPage[] = (response.response ?? []).map( + const nodes: RemixerSubPage[] = (res.response ?? []).map( (node: RemixerSubPage) => ({ ...node, addedItem: false, }), ); - - children.forEach((child) => { - nodesById.set(child["@id"], child); - if (child["@subpages"]) { - queue.push(child["@id"]); - } - }); - } - - return Array.from(nodesById.values()); - }; - - /** BFS-load a library subtree rooted at `rootNode` (used for catalog-book extract imports). */ - const loadLibrarySubtree = useCallback( - async ( - projectId: string, - rootNode: RemixerSubPage, - libreLibrary: string, - ): Promise => { - const nodesById = new Map(); - const fetchedParentIds = new Set(); - const queue: string[] = []; - - nodesById.set(rootNode["@id"], { - ...rootNode, - addedItem: false, - }); - queue.push(rootNode["@id"]); - - while (queue.length > 0) { - const parentId = queue.shift(); - if (!parentId || fetchedParentIds.has(parentId)) { - continue; - } - fetchedParentIds.add(parentId); - - const parentNode = nodesById.get(parentId); - if (!parentNode?.["@subpages"]) { - continue; - } - - const response = await api.getRemixerPage( - projectId, - parentId, - libreLibrary, - false, - true, - { includeMatter: false, linkTitle: true, full: false }, - ); - - const children: RemixerSubPage[] = (response.response ?? []).map( - (node: RemixerSubPage) => ({ - ...node, - addedItem: false, - }), - ); - - children.forEach((child) => { - nodesById.set(child["@id"], child); - if (child["@subpages"]) { - queue.push(child["@id"]); - } - }); + if (!nodes.some((node) => node["@id"] === rootNode["@id"])) { + nodes.unshift({ ...rootNode, addedItem: false }); } - - return Array.from(nodesById.values()); + return nodes; }, [], ); @@ -579,77 +506,95 @@ const RemixerDashboard: React.FC = () => { * then scroll-reveal the target node with a brief highlight. */ const loadSelectedBook = async (bookID: string, lib: string) => { + if (!id) return; skipLibraryAutoLoadRef.current = true; - let expandedNodeIds = new Set(); setRemixerData((prev) => ({ ...prev, selectedLibrary: lib as Library })); - // fetch bookCoverID - let pageId = bookID.split("-")[1]; - const fetchingLibarary = [] as RemixerSubPage[]; - - while (true) { - if (fetchingLibarary.find((c) => c["@id"] === pageId)) { - expandedNodeIds.add(pageId); - const parent = fetchingLibarary.find((c) => c["@id"] === pageId); - pageId = parent?.parentID ?? ""; - if (!pageId || pageId === "-1") break; - continue; - } - const res = await api.getRemixerPage(id, pageId, lib, false, false, { - includeMatter: false, - linkTitle: true, - full: false, + + const targetNodeId = bookID.split("-")[1]; + const pageOptions = { + includeMatter: false, + linkTitle: true, + full: false, + }; + const nodesById = new Map(); + const expandedNodeIds = new Set([targetNodeId]); + + const upsert = (node: RemixerSubPage, idOverride?: string) => { + const nodeId = idOverride ?? node["@id"]; + if (!nodeId) return; + nodesById.set(nodeId, { + ...(nodesById.get(nodeId) ?? {}), + ...node, + "@id": nodeId, }); - const pagedetails = await api.getRemixerPage( + }; + + // Selected book's descendants in one request. + const treeRes = await api.getRemixerTreeFlattened(id, targetNodeId, lib); + for (const node of (treeRes.response ?? []) as RemixerSubPage[]) { + upsert(node); + } + + // Walk the ancestry with page details so the library tree can expand to the book. + let pageId: string | undefined = targetNodeId; + const visited = new Set(); + while (pageId && pageId !== "-1" && !visited.has(pageId)) { + visited.add(pageId); + expandedNodeIds.add(pageId); + + const details = await api.getRemixerPage( id, pageId, lib, true, false, - { - includeMatter: false, - linkTitle: true, - full: false, - }, + pageOptions, ); + if (details.err || !details.response) break; - if (res.err === false) { - fetchingLibarary.push(...res.response); - expandedNodeIds.add(pageId); - } + const node = details.response as RemixerSubPage; + upsert(node, node["@id"] || pageId); - pageId = pagedetails.response["parentID"]; - if (!pageId || pageId === "-1") { - console.debug("pageId not found", pageId); - break; + const parentId = node.parentID; + if (!parentId || parentId === "-1") break; + + // Direct children of each ancestor (siblings of the path) — one level only. + const siblings = await api.getRemixerPage( + id, + parentId, + lib, + false, + false, + pageOptions, + ); + if (siblings.err === false) { + for (const child of (siblings.response ?? []) as RemixerSubPage[]) { + upsert(child); + } } + + pageId = parentId; } - const libsubpages = await api.getRemixerPage(id, "0", lib, false, false, { - includeMatter: false, - linkTitle: true, - full: false, - }); - const libdetails = await api.getRemixerPage(id, "0", lib, true, false, { - includeMatter: false, - linkTitle: true, - full: false, - }); - try { - fetchingLibarary.push(...(libsubpages.response as RemixerSubPage[]), { - ...libdetails.response, - ["@id"]: "0", - }); - } catch { - console.debug("wrong push"); + + const [libsubpages, libdetails] = await Promise.all([ + api.getRemixerPage(id, "0", lib, false, false, pageOptions), + api.getRemixerPage(id, "0", lib, true, false, pageOptions), + ]); + if (libdetails.err === false && libdetails.response) { + upsert(libdetails.response as RemixerSubPage, "0"); + } + if (libsubpages.err === false) { + for (const child of (libsubpages.response ?? []) as RemixerSubPage[]) { + upsert(child); + } } setRemixerData((prev) => ({ ...prev, library: { ...(prev.library ?? {}), - [lib]: fetchingLibarary - + [lib]: Array.from(nodesById.values()), }, - // selectedLibrary: lib as Library, })); setExpandedNodeIdsLibrary( @@ -658,7 +603,6 @@ const RemixerDashboard: React.FC = () => { ), ); - const targetNodeId = bookID.split("-")[1]; setTimeout(() => { skipLibraryAutoLoadRef.current = false; const el = document.querySelector( diff --git a/client/src/screens/conductor/controlpanel/StoreManager/index.tsx b/client/src/screens/conductor/controlpanel/StoreManager/index.tsx index 9288222c3..7aa1a1732 100644 --- a/client/src/screens/conductor/controlpanel/StoreManager/index.tsx +++ b/client/src/screens/conductor/controlpanel/StoreManager/index.tsx @@ -1,20 +1,22 @@ import { Link } from "react-router-dom"; -import { Badge, Breadcrumb, Button, Heading, Select, Stack } from "@libretexts/davis-react"; +import { Badge, Breadcrumb, Button, Heading, Input, Select, Stack } from "@libretexts/davis-react"; import type { BadgeVariant } from "@libretexts/davis-react"; -import { StoreOrderWithStripeSession } from "../../../../types"; +import { StoreOrderListItem } from "../../../../types"; import useGlobalError from "../../../../components/error/ErrorHooks"; import SupportCenterTable from "../../../../components/support/SupportCenterTable"; import { useInfiniteQuery } from "@tanstack/react-query"; import api from "../../../../api"; import useDocumentTitle from "../../../../hooks/useDocumentTitle"; +import useDebounce from "../../../../hooks/useDebounce"; import { IconCloudComputing, IconDownload, IconEye, + IconSearch, } from "@tabler/icons-react"; import { useNotifications } from "../../../../context/NotificationContext"; import { formatPrice, truncateOrderId } from "../../../../utils/storeHelpers"; -import { useState } from "react"; +import { useMemo, useState } from "react"; function luluStatusVariant(status?: string | null): BadgeVariant { if (!status) return "default"; @@ -36,12 +38,21 @@ const StoreManager = () => { const limit = 25; const { addNotification } = useNotifications(); const { handleGlobalError } = useGlobalError(); + const { debounce } = useDebounce(); const [statusFilter, setStatusFilter] = useState("all"); const [luluStatusFilter, setLuluStatusFilter] = useState("all"); + const [searchInput, setSearchInput] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); + + // Debounce the value that actually drives the query so each keystroke doesn't refetch. + const debouncedSetQuery = useMemo( + () => debounce((value: string) => setSearchQuery(value.trim()), 400), + [] + ); const { data, isFetching, isInitialLoading, fetchNextPage } = useInfiniteQuery({ - queryKey: ["store-orders", limit, statusFilter, luluStatusFilter], + queryKey: ["store-orders", limit, statusFilter, luluStatusFilter, searchQuery], queryFn: async ({ pageParam = null }) => { const response = await api.adminGetStoreOrders({ limit, @@ -49,6 +60,7 @@ const StoreManager = () => { status: statusFilter === "all" ? undefined : statusFilter, lulu_status: luluStatusFilter === "all" ? undefined : luluStatusFilter, + query: searchQuery || undefined, }); if (response.data.err) { @@ -82,7 +94,21 @@ const StoreManager = () => {
-
+
+
+ } + onChange={(e) => { + setSearchInput(e.target.value); + debouncedSetQuery(e.target.value); + }} + /> +