diff --git a/AGENTS.md b/AGENTS.md index 8acc6b47..caea24ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,47 @@ Anything the frontend imports from a backend feature must be a leaf module — pure types and functions, no Worker-only imports — or it lands in the client bundle. +# Running the app + +Onshape launches the app at `/init`, which needs a real Onshape session and +https. Standalone mode is the same SPA without either, and is how to drive the +app locally — including headless. Don't build a stub server for the API; run the +real one. + +Put this in `.env` (git-ignored). It is the whole set needed to get a signed-in +admin; the OAuth keys in the README are only for talking to Onshape itself: + +``` +FORCE_SIGNED_IN=true # a fake user, so no OAuth round trip +VITE_ACCESS_LEVEL_OVERRIDE=admin # granted by the server, and viewed by the client +``` + +Then `npm run dev` (applies local D1 migrations, then serves +http://localhost:3000). The dev server goes https only when `localhost-key.pem` +and `localhost.pem` are present, so leave them out for a headless browser. + +The test Worker ignores `.env` (`vitest.config.ts` turns that off), so leaving +one in place does not rewrite what the auth tests assert. + +Where to point it: + +- `/` — redirects to the last library used, from `localStorage`. +- `/app/library/` — a library; ids are in `library-id.ts`. +- `/app/library//groups/` — one group. + +Insert and derive key off a full element path in the search params, which is +what `useIsConnectedToOnshape` tests, so standalone hides them. Append what +Onshape would send to exercise that UI: +`?elementType=PARTSTUDIO&documentId=…&instanceType=w&instanceId=…&elementId=…` + +Local D1 starts empty, so a library renders "No groups found". Import a cert +dump rather than reloading from Onshape, which spends the account's API +allocation: + +``` +npx wrangler d1 execute DB --local --file=.sql +``` + # Cloudflare Workers STOP. Your knowledge of Cloudflare Workers APIs and limits may be outdated. Always retrieve current documentation before any Workers, KV, R2, D1, Durable Objects, Queues, Vectorize, AI, or Agents SDK task. diff --git a/README.md b/README.md index 83000505..c2f28784 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,9 @@ OAUTH_CLIENT_ID= OAUTH_CLIENT_SECRET= SESSION_SECRET=gNSzdRbs4dJYz0obHfeRwaD+u5QbZgJx+V8/rgUH6AiOdoppP3wjeaM97nZmxeJa -# One of admin, editor, or user. Sets the max access level granted. Does nothing in production. -ACCESS_LEVEL_OVERRIDE=admin - -# One of admin, editor, or user. The level the app is viewed as by default (client-side). -VITE_DEFAULT_ACCESS_LEVEL=admin +# One of admin, editor, or user. Granted by the server and viewed by the client, +# so both sides agree. Ignored in production. +VITE_ACCESS_LEVEL_OVERRIDE=admin # Signs you in as a fake user, so signed-in UI can be tested without an Onshape # session. Onshape calls it reveals won't work, so leave it unset normally. diff --git a/src/backend/features/auth/caller.test.ts b/src/backend/features/auth/caller.test.ts new file mode 100644 index 00000000..8a5d0b82 --- /dev/null +++ b/src/backend/features/auth/caller.test.ts @@ -0,0 +1,45 @@ +import { env } from "cloudflare:workers"; +import { env as processEnv } from "process"; +import { afterEach, describe, expect, it } from "vitest"; +import { AccessLevel } from "./access-level"; +import { productionCaller } from "./caller"; +import { createApp } from "../../app"; +import { jsonRequest } from "../../../__test_utils__"; + +const app = createApp(productionCaller); + +/** What the real caller resolves for a request carrying no Onshape session. */ +async function getMaxAccessLevel(override?: AccessLevel): Promise { + const res = await app.request("/api/access-data", jsonRequest("GET"), { + ...env, + VITE_ACCESS_LEVEL_OVERRIDE: override + }); + const body: { maxAccessLevel: AccessLevel } = await res.json(); + return body.maxAccessLevel; +} + +describe("the dev access-level override", () => { + const nodeEnv = processEnv.NODE_ENV; + afterEach(() => { + processEnv.NODE_ENV = nodeEnv; + }); + + it("grants the level it names", async () => { + expect(await getMaxAccessLevel(AccessLevel.ADMIN)).toBe( + AccessLevel.ADMIN + ); + }); + + // It is the one thing standing between a stray env var and admin, so it + // must not survive a production build. + it("is ignored in production", async () => { + processEnv.NODE_ENV = "production"; + expect(await getMaxAccessLevel(AccessLevel.ADMIN)).toBe( + AccessLevel.USER + ); + }); + + it("leaves an unset override to the caller's own session", async () => { + expect(await getMaxAccessLevel()).toBe(AccessLevel.USER); + }); +}); diff --git a/src/backend/features/auth/caller.ts b/src/backend/features/auth/caller.ts index 1de61134..4ff2fc15 100644 --- a/src/backend/features/auth/caller.ts +++ b/src/backend/features/auth/caller.ts @@ -91,9 +91,21 @@ export async function isAuthenticated(c: AppContext): Promise { } } -/** FORCE_SIGNED_IN is a dev-only escape hatch, ignored in production. */ +/** The dev-only escape hatches are ignored in production. */ +function isDevelopment(): boolean { + return processEnv.NODE_ENV !== "production"; +} + export function isForceSignedIn(c: AppContext): boolean { - return !!c.env.FORCE_SIGNED_IN && processEnv.NODE_ENV !== "production"; + return !!c.env.FORCE_SIGNED_IN && isDevelopment(); +} + +/** The access level granted without asking Onshape, in dev only. */ +function getAccessLevelOverride(c: AppContext): AccessLevel | undefined { + if (!isDevelopment()) { + return undefined; + } + return c.env.VITE_ACCESS_LEVEL_OVERRIDE as AccessLevel | undefined; } /** @@ -157,8 +169,8 @@ export const productionCaller: CallerFactory = (c) => ({ return getCachedUserId(c); }, getAccessLevel: async () => { - const override = c.env.ACCESS_LEVEL_OVERRIDE; - if (override) return override as AccessLevel; + const override = getAccessLevelOverride(c); + if (override) return override; // getCachedAccessLevel needs a real Onshape session, so only call it // for a genuinely signed-in caller (not FORCE_SIGNED_IN). if (!isForceSignedIn(c) && (await isSignedIn(c))) { diff --git a/src/backend/features/auth/guards.test.ts b/src/backend/features/auth/guards.test.ts index 79d20848..3c2e9c6d 100644 --- a/src/backend/features/auth/guards.test.ts +++ b/src/backend/features/auth/guards.test.ts @@ -55,7 +55,7 @@ describe("requireEditorMiddleware", () => { }); // Access level alone would admit a signed-out caller wherever it is - // granted without a session, e.g. behind a dev ACCESS_LEVEL_OVERRIDE. + // granted without a session, e.g. behind a dev access-level override. it("401s an editor-level caller who is not signed in", async () => { const app = createTestApp({ signedIn: false, diff --git a/src/backend/features/auth/guards.ts b/src/backend/features/auth/guards.ts index afe15042..36fa1798 100644 --- a/src/backend/features/auth/guards.ts +++ b/src/backend/features/auth/guards.ts @@ -25,7 +25,7 @@ export const requireSignInMiddleware: MiddlewareHandler = async ( /** * Editing implies a session: access level alone would admit a signed-out caller - * under a dev `ACCESS_LEVEL_OVERRIDE`, and answer 403 rather than 401 otherwise. + * under a dev access-level override, and answer 403 rather than 401 otherwise. */ export const requireEditorMiddleware: MiddlewareHandler = async ( c, diff --git a/src/backend/features/configurations/combinations.test.ts b/src/backend/features/configurations/combinations.test.ts index 1b68ed1b..7c7286da 100644 --- a/src/backend/features/configurations/combinations.test.ts +++ b/src/backend/features/configurations/combinations.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { AUTO_INDEX_THRESHOLD, + countCombinations, countConfigurations, enumerateConfigurations, IndexingBand, @@ -178,6 +179,51 @@ describe("countConfigurations", () => { }); }); +describe("countCombinations", () => { + it("counts an insertable with nothing to vary as having none", () => { + expect(countCombinations([])).toBe(0); + expect( + countCombinations([ + enumParam("A", ["x", "y"], { isCosmetic: true }) + ]) + ).toBe(0); + }); + + it("agrees with countConfigurations under the index cap", () => { + for (const configs of [2, 7, AUTO_INDEX_THRESHOLD, 500]) { + const params = paramsWithConfigs(configs); + expect(countCombinations(params)).toBe( + countConfigurations(params).count + ); + } + }); + + it("counts on past the index cap, which countConfigurations stops at", () => { + const params = paramsWithConfigs(MAX_PART_NUMBER_CONFIGURATIONS * 4); + expect(countConfigurations(params).count).toBeNull(); + expect(countCombinations(params)).toBe( + MAX_PART_NUMBER_CONFIGURATIONS * 4 + ); + }); + + it("skips values a visibility condition hides, as enumeration does", () => { + const params: ConfigurationParameter[] = [ + enumParam("A", ["a1", "a2"]), + { + ...enumParam("B", ["b1", "b2", "b3"]), + condition: equals("A", "a1") + } + ]; + expect(countCombinations(params)).toBe( + enumerateConfigurations(params).configurations.length + ); + }); + + it("gives up past its own cap rather than counting forever", () => { + expect(countCombinations(paramsWithConfigs(64), 32)).toBeNull(); + }); +}); + describe("isIndexingEnabled", () => { it.each([ // Under the threshold everything indexes, custom included: a part with diff --git a/src/backend/features/configurations/combinations.ts b/src/backend/features/configurations/combinations.ts index f9cde0be..4df70d2c 100644 --- a/src/backend/features/configurations/combinations.ts +++ b/src/backend/features/configurations/combinations.ts @@ -103,6 +103,66 @@ export function isIndexedParameter( return !parameter.isCosmetic; } +/** The most combinations counted for display, far past the index cap on work. */ +export const MAX_COUNTED_CONFIGURATIONS = 100_000; + +/** The true count, which runs past the index cap so the admin card can show it. */ +export function countCombinations( + parameters: ConfigurationParameter[], + cap: number = MAX_COUNTED_CONFIGURATIONS +): number | null { + // Depth-first: only the count is wanted, so one path is held rather than all. + const indexed = parameters.filter(isIndexedParameter); + let count = 0; + let capped = false; + + const walk = (depth: number, configuration: ParameterValues) => { + if (depth === indexed.length) { + // The lone empty default is not a configuration of its own. + if (Object.keys(configuration).length > 0) { + count++; + capped = count > cap; + } + return; + } + const parameter = indexed[depth]; + const values = evaluateCondition( + parameter.condition, + configuration, + parameters + ) + ? parameterValues(parameter, configuration, parameters) + : []; + // Hidden here, or with nothing to pick: left unset for Onshape to default. + if (values.length === 0) { + walk(depth + 1, configuration); + return; + } + for (const value of values) { + walk(depth + 1, { ...configuration, [parameter.id]: value }); + if (capped) { + return; + } + } + }; + + walk(0, {}); + return capped ? null : count; +} + +function parameterValues( + parameter: EnumParameter | BooleanParameter, + configuration: ParameterValues, + parameters: ConfigurationParameter[] +): string[] { + if (parameter.type === ParameterType.BOOLEAN) { + return ["true", "false"]; + } + return getVisibleOptions(parameter, configuration, parameters).map( + (option) => option.id + ); +} + export interface EnumerateResult { /** The enumerated configurations, or empty when `capped`. */ configurations: ParameterValues[]; diff --git a/src/backend/lib/context.ts b/src/backend/lib/context.ts index 025817d6..172da95e 100644 --- a/src/backend/lib/context.ts +++ b/src/backend/lib/context.ts @@ -18,7 +18,8 @@ export interface AppBindings { /** Renders a configuration's thumbnails outside a request; see ThumbnailWorkflow. */ THUMBNAIL_WORKFLOW: Workflow; ADMIN_TEAM: string; - ACCESS_LEVEL_OVERRIDE?: string; + /** Dev-only: the access level granted, bypassing Onshape. */ + VITE_ACCESS_LEVEL_OVERRIDE?: string; /** Testing-only: treat requests as signed in with a fake user. Not for production. */ FORCE_SIGNED_IN?: string; } diff --git a/src/frontend/features/auth/access-level.tsx b/src/frontend/features/auth/access-level.tsx index c4ab96fd..32d58dbf 100644 --- a/src/frontend/features/auth/access-level.tsx +++ b/src/frontend/features/auth/access-level.tsx @@ -16,9 +16,9 @@ const DEFAULT_ACCESS_DATA: AccessData = { signedIn: false }; -/** The level the app is viewed as by default; overridable in dev via a Vite var. */ +/** The level the app is viewed as by default; the dev override grants it too. */ const DEFAULT_ACCESS_LEVEL = - (import.meta.env.VITE_DEFAULT_ACCESS_LEVEL as AccessLevel | undefined) ?? + (import.meta.env.VITE_ACCESS_LEVEL_OVERRIDE as AccessLevel | undefined) ?? AccessLevel.USER; export function getAccessDataQuery() { diff --git a/src/frontend/features/build-status/components/build-status.tsx b/src/frontend/features/build-status/components/build-status.tsx index eec7b08f..d216342a 100644 --- a/src/frontend/features/build-status/components/build-status.tsx +++ b/src/frontend/features/build-status/components/build-status.tsx @@ -14,6 +14,7 @@ import { import { Check, Clock, + FileX, Info, Warning, WarningOctagon, @@ -44,6 +45,7 @@ import { InsertableBuildStatus } from "@backend/features/build-checker/contract"; import { getVendorName, Vendor } from "@backend/features/library/vendors"; +import { ElementType } from "@backend/lib/onshape/element-type"; import { ConfigurationParameter, ParameterType @@ -51,9 +53,10 @@ import { import { AUTO_INDEX_THRESHOLD, type ConfigurationCount, + countCombinations, countConfigurations, IndexingBand, - isIndexedParameter, + MAX_COUNTED_CONFIGURATIONS, MAX_PART_NUMBER_CONFIGURATIONS } from "@backend/features/configurations/combinations"; import { FontWeight, IconSize } from "../../../lib/style-constants"; @@ -533,10 +536,7 @@ function InsertableHoverMenu({ status={status} configurationCount={configurationCount} /> - + @@ -703,11 +703,18 @@ function IndexingRow({ const mutation = useIndexConfigurationsMutation(insertableId); let control: ReactNode; - if (band === IndexingBand.EXCEEDED) { + if (status.elementType === ElementType.ASSEMBLY) { + control = ( + + ); + } else if (band === IndexingBand.EXCEEDED) { control = ( ); } else if (band === IndexingBand.AUTOMATIC) { @@ -788,12 +795,20 @@ function useConfigurationCount( return useMemo(() => countConfigurations(parameters ?? []), [parameters]); } -/** Open-ended past the cap, where enumeration stops before reaching a total. */ +/** The true total, which runs past the index cap the band is decided by. */ +function useDisplayedConfigurationCount( + status: InsertableBuildStatus +): number | null { + const parameters = status.configuration?.parameters; + return useMemo(() => countCombinations(parameters ?? []), [parameters]); +} + +/** Open-ended only past the counting cap, which nothing real reaches. */ function configurationCountValue(count: number | null): StateRowValue { if (count === null) { return { kind: "text", - text: `Over ${MAX_PART_NUMBER_CONFIGURATIONS}` + text: `Over ${MAX_COUNTED_CONFIGURATIONS.toLocaleString()}` }; } if (count === 0) { @@ -804,12 +819,11 @@ function configurationCountValue(count: number | null): StateRowValue { /** The read-only auto-detected facts for an insertable. */ function InsertableParsedSection({ - status, - count + status }: { status: InsertableBuildStatus; - count: number | null; }): ReactNode { + const count = useDisplayedConfigurationCount(status); return ( <> @@ -851,7 +865,9 @@ function ConfigurationSection({ > {parameter.name} - + @@ -863,42 +879,32 @@ function ConfigurationSection({ ); } -/** Why a parameter is or isn't varied when indexing, shown on hover. */ -function getIndexedDescription(parameter: ConfigurationParameter): string { - if (isIndexedParameter(parameter)) { - return "Varied when indexing part numbers, so it multiplies this insertable's configuration count."; - } - if ( - parameter.type === ParameterType.QUANTITY || - parameter.type === ParameterType.STRING - ) { - return "Quantity and text parameters are never varied — they stay at their Onshape default."; - } - return "Excluded from properties, so it stays at its default instead of multiplying the configuration count."; -} - -/** Whether indexing varies this parameter — the lever on the configuration count. */ -function IndexedBadge({ +/** + * Onshape's "exclude from affecting configured properties", the lever on the + * count. Part studios only, which Onshape itself enforces. + */ +function ExcludedFromPropertiesIcon({ parameter }: { parameter: ConfigurationParameter; }): ReactNode { - const isIndexed = isIndexedParameter(parameter); + if (!parameter.isCosmetic) { + return null; + } return ( - - {isIndexed ? "Indexed" : "Not indexed"} - + ); } diff --git a/src/frontend/features/library/components/group-card.tsx b/src/frontend/features/library/components/group-card.tsx index b4f1e3f8..1a37c44e 100644 --- a/src/frontend/features/library/components/group-card.tsx +++ b/src/frontend/features/library/components/group-card.tsx @@ -21,7 +21,7 @@ import { GroupStatusBadge } from "../../build-status/components/build-status"; import { useRefreshLibrary } from "../../../lib/refresh"; import { useBuildStatusQuery } from "../../build-status/queries"; import { useCacheVersion, useLibraryQuery } from "../queries"; -import { libraryQueryKey } from "../../../lib/query-keys"; +import { libraryDataQueryKey } from "../../../lib/query-keys"; import { toLibraryPath, useIsHome, useLibraryId } from "../library-path"; import { getQueryUpdater } from "../../../lib/query-cache"; @@ -187,7 +187,7 @@ function useSetGroupOrderMutation() { const libraryId = useLibraryId(); const cacheVersion = useCacheVersion(); const refreshLibrary = useRefreshLibrary(); - const key = libraryQueryKey(libraryId, cacheVersion); + const key = libraryDataQueryKey(libraryId, cacheVersion); return useMutation({ mutationKey: ["group-order"], diff --git a/src/frontend/features/library/queries.ts b/src/frontend/features/library/queries.ts index 53d6e83b..4309efda 100644 --- a/src/frontend/features/library/queries.ts +++ b/src/frontend/features/library/queries.ts @@ -9,13 +9,13 @@ import { useAccessData } from "../auth/access-level"; import { toLibraryPath, useLibraryId } from "./library-path"; import { jobStatusQueryKey, - libraryQueryKey, + libraryDataQueryKey, libraryVersionQueryKey } from "../../lib/query-keys"; export function getLibraryQuery(libraryId: LibraryId, cacheVersion: number) { return queryOptions({ - queryKey: libraryQueryKey(libraryId, cacheVersion), + queryKey: libraryDataQueryKey(libraryId, cacheVersion), queryFn: async () => apiGet("/library-data/library/" + libraryId, { cacheId: cacheVersion diff --git a/src/frontend/features/thumbnails/components/thumbnail.tsx b/src/frontend/features/thumbnails/components/thumbnail.tsx index d346f342..8c9b946b 100644 --- a/src/frontend/features/thumbnails/components/thumbnail.tsx +++ b/src/frontend/features/thumbnails/components/thumbnail.tsx @@ -1,12 +1,16 @@ import { useQuery } from "@tanstack/react-query"; -import { loadImage, loadImageResult } from "../../../lib/api-client"; +import { + loadImage, + loadImageResult, + type LoadedImage +} from "../../../lib/api-client"; import { ElementType } from "@backend/lib/onshape/element-type"; import { ThumbnailSize } from "@backend/features/thumbnails/types"; import { ElementPath } from "@backend/lib/onshape/path"; import { Box, Card, Center, HoverCard, Loader } from "@mantine/core"; import { Question } from "@phosphor-icons/react"; -import { ComponentPropsWithRef, ReactNode } from "react"; +import { ComponentPropsWithRef, ReactNode, useState } from "react"; import { DEFAULT_CANONICAL_CONFIGURATION } from "@backend/features/configurations/canonical"; import { thumbnailUrl } from "@backend/features/thumbnails/keys"; import { SectionError } from "../../../components/app-zero-state"; @@ -179,6 +183,18 @@ const PREVIEW_POLL_MS = 4000; */ const WARM_EVERY_POLLS = 15; +/** + * The last render actually produced, kept across configuration changes: the + * worker stands the element default in until a new one lands. + */ +function useLastRenderedUrl(image?: LoadedImage): string | undefined { + const [lastRendered, setLastRendered] = useState(); + if (image && !image.isFallback && image.url !== lastRendered) { + setLastRendered(image.url); + } + return lastRendered; +} + export function PreviewImage(props: PreviewImageProps): ReactNode { const { path, @@ -228,6 +244,7 @@ export function PreviewImage(props: PreviewImageProps): ReactNode { retry: 2, enabled: !isFetchingConfiguration && isSignedIn === true }); + const lastRenderedUrl = useLastRenderedUrl(thumbnailQuery.data); const heightAndWidth = getHeightAndWidth(size, 0.7); @@ -281,6 +298,12 @@ export function PreviewImage(props: PreviewImageProps): ReactNode { ); } + // A stand-in must not displace a render the user already has. + const previewUrl = + thumbnailQuery.data.isFallback && lastRenderedUrl + ? lastRenderedUrl + : thumbnailQuery.data.url; + return ( <> diff --git a/src/frontend/lib/query-keys.ts b/src/frontend/lib/query-keys.ts index 427973d7..170be178 100644 --- a/src/frontend/lib/query-keys.ts +++ b/src/frontend/lib/query-keys.ts @@ -1,6 +1,6 @@ /** - * Every query key in one place: features read their own keys here, and the - * cross-feature refresh flows invalidate by the match keys. + * Every query key in one place. Everything scoped to a library hangs off + * {@link libraryQueryKey}, so the refresh flows invalidate that one prefix. */ import { LibraryId } from "@backend/features/library/library-id"; import { InstancePath } from "@backend/lib/onshape/path"; @@ -20,49 +20,37 @@ export function unitInfoQueryKey(instancePath: InstancePath) { return ["unit-info", instancePath]; } -export function libraryQueryMatchKey() { - return ["library"]; +/** The prefix every library-scoped query extends with its endpoint. */ +export function libraryQueryKey(libraryId: LibraryId) { + return ["library", libraryId]; } -export function libraryQueryKey(libraryId: LibraryId, cacheVersion: number) { - return ["library", libraryId, cacheVersion]; -} - -export function libraryVersionQueryMatchKey() { - return ["library-version"]; +export function libraryDataQueryKey( + libraryId: LibraryId, + cacheVersion: number +) { + return [...libraryQueryKey(libraryId), "library-data", cacheVersion]; } export function libraryVersionQueryKey(libraryId: LibraryId) { - return ["library-version", libraryId]; -} - -export function searchDbQueryMatchKey() { - return ["search-db"]; + return [...libraryQueryKey(libraryId), "library-version"]; } export function searchDbQueryKey(libraryId: LibraryId, cacheVersion: number) { - return ["search-db", libraryId, cacheVersion]; + return [...libraryQueryKey(libraryId), "search-db", cacheVersion]; } export function favoritesQueryKey(libraryId: LibraryId) { - return ["favorites", libraryId]; -} - -export function buildStatusQueryMatchKey() { - return ["build-status"]; + return [...libraryQueryKey(libraryId), "favorites"]; } export function buildStatusQueryKey( libraryId: LibraryId, cacheVersion: number ) { - return ["build-status", libraryId, cacheVersion]; -} - -export function jobStatusQueryMatchKey() { - return ["job-status"]; + return [...libraryQueryKey(libraryId), "build-status", cacheVersion]; } export function jobStatusQueryKey(libraryId: LibraryId) { - return ["job-status", libraryId]; + return [...libraryQueryKey(libraryId), "job-status"]; } diff --git a/src/frontend/lib/refresh.ts b/src/frontend/lib/refresh.ts index c7953f12..d347e0ab 100644 --- a/src/frontend/lib/refresh.ts +++ b/src/frontend/lib/refresh.ts @@ -3,51 +3,38 @@ import { useRouter } from "@tanstack/react-router"; import { queryClient } from "./query-client"; import { useJobStatusQuery } from "../features/library/queries"; import { - buildStatusQueryMatchKey, + accessDataQueryKey, favoritesQueryKey, - libraryQueryMatchKey, - libraryVersionQueryMatchKey + libraryQueryKey } from "./query-keys"; -import { accessDataQueryKey } from "./query-keys"; import { useLibraryId } from "../features/library/library-path"; -import type { LibraryId } from "@backend/features/library/library-id"; - -/** Refetches the current user's favorites, which aren't version-keyed. */ -function refetchFavorites(libraryId: LibraryId): Promise { - return queryClient.invalidateQueries({ - queryKey: favoritesQueryKey(libraryId) - }); -} /** - * Refreshes the library view (and favorites). Invalidating the snapshot queries - * also rolls a failed optimistic update back to server truth on the refetch. + * Refreshes the current library and the caller's access. Invalidating the + * snapshot queries also rolls a failed optimistic update back to server truth. */ export function useRefreshLibrary(): () => Promise { const router = useRouter(); const libraryId = useLibraryId(); return useCallback(async () => { - await queryClient.refetchQueries({ - queryKey: libraryVersionQueryMatchKey() - }); - await queryClient.refetchQueries({ queryKey: accessDataQueryKey() }); - await queryClient.invalidateQueries({ - queryKey: libraryQueryMatchKey() - }); - await queryClient.invalidateQueries({ - queryKey: buildStatusQueryMatchKey() - }); - await refetchFavorites(libraryId); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: libraryQueryKey(libraryId) + }), + queryClient.invalidateQueries({ queryKey: accessDataQueryKey() }) + ]); await router.invalidate(); }, [router, libraryId]); } -/** Refreshes just the current user's favorites. */ +/** Refreshes just the current user's favorites, which aren't version-keyed. */ export function useRefreshFavorites(): () => Promise { const router = useRouter(); const libraryId = useLibraryId(); return useCallback(async () => { - await refetchFavorites(libraryId); + await queryClient.invalidateQueries({ + queryKey: favoritesQueryKey(libraryId) + }); await router.invalidate(); }, [router, libraryId]); } diff --git a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx index cf9a3388..acf80caa 100644 --- a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx +++ b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx @@ -52,7 +52,7 @@ function GroupList(): ReactNode { const uiState = useUiState()[0]; if (libraryQuery.isPending) { - return ; + return ; } else if (libraryQuery.isError) { return ; } diff --git a/src/frontend/vite-env.d.ts b/src/frontend/vite-env.d.ts index 892a08a5..c51c8a0f 100644 --- a/src/frontend/vite-env.d.ts +++ b/src/frontend/vite-env.d.ts @@ -1,8 +1,8 @@ /// interface ImportMetaEnv { - /** Dev-only: the access level the app is viewed as by default. */ - readonly VITE_DEFAULT_ACCESS_LEVEL?: string; + /** Dev-only: the access level granted, and so the one viewed by default. */ + readonly VITE_ACCESS_LEVEL_OVERRIDE?: string; } interface ImportMeta { diff --git a/vitest.config.ts b/vitest.config.ts index bb858c06..d88075f6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,10 @@ import { import { defineConfig } from "vitest/config"; import { alias } from "./vite.config"; +// `.env` is for driving the app locally (see AGENTS.md); letting it reach the +// test Worker would make FORCE_SIGNED_IN rewrite what the auth tests assert. +process.env.CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV = "false"; + export default defineConfig({ test: { projects: [