From c8789b3fb674f0ca3ad8d1faa9ed533121be874e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 18:21:41 +0000 Subject: [PATCH 01/56] refactor: split oversized modules into focused ones Breaks up shared/types.ts, shared/api-models.ts, backend/app.ts, backend/auth.ts and frontend/queries.ts along their seams so the following move to a feature layout is a pure relocation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- src/__test_utils__/insertable-fixtures.ts | 2 +- src/__test_utils__/seed.ts | 3 +- src/__test_utils__/test-app.ts | 2 +- src/backend/access-level-utils.ts | 7 +- src/backend/app.ts | 166 ----------- src/backend/{auth.ts => auth-oauth.ts} | 206 ++++---------- src/backend/auth-routes.ts | 33 +++ src/backend/auth-session.ts | 94 +++++++ src/backend/cache.ts | 72 +++++ src/backend/context.ts | 61 +++++ src/backend/create-app.test.ts | 3 +- src/backend/create-app.ts | 14 +- src/backend/library-data.ts | 9 +- src/backend/load/job-tracker.ts | 6 +- src/backend/load/load-common.ts | 7 +- src/backend/load/load-group.ts | 4 +- src/backend/load/load-insertable.ts | 10 +- src/backend/load/load-steps.ts | 2 +- src/backend/load/workflows.ts | 4 +- .../onshape-api/endpoints/thumbnails.ts | 2 +- src/backend/onshape-api/endpoints/users.ts | 2 +- src/backend/parse/build-checks.test.ts | 3 +- src/backend/parse/build-checks.ts | 3 +- src/backend/parse/insert-and-fasten.test.ts | 3 +- src/backend/parse/insert-and-fasten.ts | 3 +- .../parse/parse-configuration-records.test.ts | 2 +- .../parse/parse-configuration-records.ts | 2 +- src/backend/parse/parse-document-contents.ts | 2 +- src/backend/parse/parse-vendors.test.ts | 2 +- src/backend/parse/parse-vendors.ts | 2 +- src/backend/route-params.ts | 41 +++ src/backend/routes/build-status.test.ts | 2 +- src/backend/routes/build-status.ts | 16 +- src/backend/routes/configurations.ts | 3 +- src/backend/routes/favorites.ts | 8 +- src/backend/routes/groups.test.ts | 2 +- src/backend/routes/groups.ts | 6 +- src/backend/routes/insertables.test.ts | 3 +- src/backend/routes/insertables.ts | 5 +- src/backend/routes/library.test.ts | 4 +- src/backend/routes/library.ts | 10 +- src/backend/routes/not-signed-in.test.ts | 4 +- src/backend/routes/thumbnails.test.ts | 2 +- src/backend/routes/thumbnails.ts | 18 +- src/backend/routes/user.test.ts | 3 +- src/backend/routes/user.ts | 6 +- src/backend/services.ts | 6 +- src/backend/sign-in-utils.ts | 2 +- src/frontend/api-utils/access-level.tsx | 16 +- src/frontend/api-utils/library.ts | 2 +- src/frontend/api-utils/onshape-params.ts | 4 +- src/frontend/api-utils/refresh.ts | 13 +- src/frontend/api-utils/ui-state.ts | 3 +- src/frontend/app/app-navbar.tsx | 4 +- src/frontend/app/root-error.tsx | 2 +- src/frontend/build-status-queries.ts | 31 +++ src/frontend/cards/build-status.tsx | 10 +- src/frontend/cards/card-components.tsx | 4 +- src/frontend/cards/card-hooks.ts | 8 +- src/frontend/cards/insertable-card.tsx | 9 +- src/frontend/configuration-queries.ts | 25 ++ src/frontend/favorites-queries.ts | 26 ++ src/frontend/favorites/favorite-button.tsx | 11 +- src/frontend/favorites/favorite-card.tsx | 6 +- src/frontend/favorites/favorite-menu.tsx | 10 +- src/frontend/favorites/favorites-list.tsx | 16 +- src/frontend/groups/add-group-menu.tsx | 4 +- src/frontend/groups/group-card.tsx | 11 +- src/frontend/insert/configurations.tsx | 5 +- src/frontend/insert/insert-hooks.ts | 4 +- src/frontend/insert/insert-menu.tsx | 10 +- src/frontend/insert/thumbnail.tsx | 7 +- src/frontend/library-queries.ts | 101 +++++++ src/frontend/queries.ts | 257 ------------------ src/frontend/query-keys.ts | 72 +++++ src/frontend/routes/__root.tsx | 3 +- .../library/$libraryId/groups/$groupId.tsx | 6 +- .../routes/app/library/$libraryId/index.tsx | 2 +- .../routes/app/library/$libraryId/route.tsx | 11 +- src/frontend/search-queries.ts | 34 +++ src/frontend/search/filter.ts | 4 +- src/frontend/search/search-results.tsx | 5 +- src/frontend/search/search.test.ts | 4 +- src/frontend/search/search.ts | 2 +- src/frontend/settings/local-settings.ts | 9 +- .../settings/reload-groups-button.tsx | 4 +- src/frontend/settings/settings-menu.tsx | 10 +- src/frontend/settings/settings.ts | 2 +- src/frontend/settings/vendor-filters.tsx | 4 +- src/frontend/theme.ts | 2 +- src/shared/access-level.ts | 43 +++ src/shared/api-models.ts | 102 ------- src/shared/build-status-dto.ts | 34 +++ src/shared/element-type.ts | 7 + src/shared/fasten.ts | 12 + src/shared/favorites-dto.ts | 24 ++ src/shared/library-dto.ts | 49 ++++ src/shared/library-id.ts | 8 + src/shared/schema.ts | 14 +- src/shared/search.ts | 4 +- src/shared/settings.ts | 21 ++ src/shared/thumbnail-types.ts | 14 + src/shared/thumbnails.ts | 2 +- src/shared/types.ts | 155 ----------- src/shared/vendors.ts | 47 ++++ 105 files changed, 1101 insertions(+), 1080 deletions(-) delete mode 100644 src/backend/app.ts rename src/backend/{auth.ts => auth-oauth.ts} (57%) create mode 100644 src/backend/auth-routes.ts create mode 100644 src/backend/auth-session.ts create mode 100644 src/backend/cache.ts create mode 100644 src/backend/context.ts create mode 100644 src/backend/route-params.ts create mode 100644 src/frontend/build-status-queries.ts create mode 100644 src/frontend/configuration-queries.ts create mode 100644 src/frontend/favorites-queries.ts create mode 100644 src/frontend/library-queries.ts delete mode 100644 src/frontend/queries.ts create mode 100644 src/frontend/query-keys.ts create mode 100644 src/frontend/search-queries.ts create mode 100644 src/shared/access-level.ts delete mode 100644 src/shared/api-models.ts create mode 100644 src/shared/build-status-dto.ts create mode 100644 src/shared/element-type.ts create mode 100644 src/shared/fasten.ts create mode 100644 src/shared/favorites-dto.ts create mode 100644 src/shared/library-dto.ts create mode 100644 src/shared/library-id.ts create mode 100644 src/shared/settings.ts create mode 100644 src/shared/thumbnail-types.ts delete mode 100644 src/shared/types.ts create mode 100644 src/shared/vendors.ts diff --git a/src/__test_utils__/insertable-fixtures.ts b/src/__test_utils__/insertable-fixtures.ts index 717c70827..22319d21f 100644 --- a/src/__test_utils__/insertable-fixtures.ts +++ b/src/__test_utils__/insertable-fixtures.ts @@ -4,7 +4,7 @@ */ import type { InsertableTarget } from "../backend/load/load-common"; import type { ParsedInsertable } from "../backend/load/load-insertable"; -import { ElementType } from "../shared/types"; +import { ElementType } from "../shared/element-type"; import { TEST_GROUP_ID, TEST_LIBRARY_ID, diff --git a/src/__test_utils__/seed.ts b/src/__test_utils__/seed.ts index 72630ba0d..b95906d6b 100644 --- a/src/__test_utils__/seed.ts +++ b/src/__test_utils__/seed.ts @@ -12,7 +12,8 @@ import { type ConfigurationParameter } from "../shared/configuration-models"; import { type ElementPath, type InstancePath } from "../shared/onshape-path"; -import { ElementType, LibraryId } from "../shared/types"; +import { ElementType } from "../shared/element-type"; +import { LibraryId } from "../shared/library-id"; export const TEST_LIBRARY_ID = LibraryId.FRC_DESIGN_LIB; export const TEST_USER_ID = "test-user"; // matches createTestApp's default userId diff --git a/src/__test_utils__/test-app.ts b/src/__test_utils__/test-app.ts index a2b0e4f62..7e13eb718 100644 --- a/src/__test_utils__/test-app.ts +++ b/src/__test_utils__/test-app.ts @@ -1,5 +1,5 @@ import { createApp } from "../backend/create-app"; -import { AccessLevel } from "../shared/types"; +import { AccessLevel } from "../shared/access-level"; import { MOCK_ONSHAPE_API, MockOnshapeApi } from "./mock-onshape-api"; export interface TestAppOptions { diff --git a/src/backend/access-level-utils.ts b/src/backend/access-level-utils.ts index e646edecc..749c001de 100644 --- a/src/backend/access-level-utils.ts +++ b/src/backend/access-level-utils.ts @@ -1,10 +1,11 @@ import { HttpStatus } from "http-status-ts"; import type { MiddlewareHandler } from "hono"; import { HTTPException } from "hono/http-exception"; -import { type AppContext, type AppContextEnv } from "./app"; -import { getOnshapeApi, getSessionId } from "./auth"; +import type { AppContext, AppContextEnv } from "./context"; +import { getOnshapeApi } from "./auth-oauth"; +import { getSessionId } from "./auth-session"; import { getAccessLevel } from "./onshape-api/endpoints/users"; -import { hasEditorAccess, type AccessLevel } from "../shared/types"; +import { hasEditorAccess, type AccessLevel } from "../shared/access-level"; /** How long a resolved access level is cached in KV. */ const ACCESS_LEVEL_TTL_SECONDS = 60 * 60; diff --git a/src/backend/app.ts b/src/backend/app.ts deleted file mode 100644 index 4ba4af115..000000000 --- a/src/backend/app.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { type Context, type MiddlewareHandler, Hono } from "hono"; -import type { - AddGroupParams, - LoadLibraryParams, - ThumbnailWorkflowParams -} from "./load/workflows"; -import { LibraryId, type AccessLevel } from "../shared/types"; -import { type OAuthApi } from "./onshape-api/onshape-api"; -import z from "zod"; -import { HTTPException } from "hono/http-exception"; -import { HttpStatus } from "http-status-ts"; - -export interface AppBindings { - DB: D1Database; - KV: KVNamespace; - ASSETS: Fetcher; - /** Thumbnails and search indexes; prefixes keep them apart. */ - BLOB: R2Bucket; - LOAD_LIBRARY_WORKFLOW: Workflow; - ADD_GROUP_WORKFLOW: Workflow; - /** Renders a configuration's thumbnails outside a request; see ThumbnailWorkflow. */ - THUMBNAIL_WORKFLOW: Workflow; - ADMIN_TEAM: string; - ACCESS_LEVEL_OVERRIDE?: string; - /** Testing-only: treat requests as signed in with a fake user. Not for production. */ - FORCE_SIGNED_IN?: string; -} - -interface AppVariables { - /** Internal cache for {@link getOnshapeApi} in auth.ts. */ - onshapeApi?: OAuthApi; - /** Internal cache for isSignedIn in sign-in-utils.ts. */ - signedIn?: boolean; - /** Set by {@link setCacheTtl}; read by {@link cacheMiddleware}. */ - cacheTtl?: number; - /** Injected getters — see {@link AppServices} / `createApp`. */ - getOnshapeApi: () => Promise; - getUserId: () => Promise; - getAccessLevel: () => Promise; - isAuthenticated: () => Promise; -} - -export interface AppContextEnv { - Bindings: AppBindings; - Variables: AppVariables; -} - -export type AppContext = Context; - -/** - * Per-request dependencies injected into the app. - */ -export interface AppServices { - getOnshapeApi: () => Promise; - getUserId: () => Promise; - getAccessLevel: () => Promise; - isAuthenticated: () => Promise; -} - -export type AppServicesFactory = (c: AppContext) => AppServices; - -export function getApp() { - return new Hono(); -} - -export function libraryRoute(): string { - return "/library/:libraryId"; -} - -export function getLibraryParam(c: AppContext): LibraryId { - const libraryId = c.req.param("libraryId"); - const parsed = z.enum(LibraryId).safeParse(libraryId); - if (!parsed.success) { - throw new HTTPException(HttpStatus.BAD_REQUEST, { - message: "Invalid libraryId" - }); - } - return parsed.data; -} - -/** A year — a versioned url's content never changes, only its version does. */ -const IMMUTABLE_CACHE_TTL = 365 * 24 * 3600; - -const NO_STORE = "private, no-store"; - -export enum CachePolicy { - /** Never stored, anywhere. */ - NO_CACHE = "no-cache", - /** Immutable, but kept out of shared caches. */ - PRIVATE_CACHE = "private", - /** Immutable and the same for every caller. */ - PUBLIC_CACHE = "public" -} - -export function immutableCacheControl( - policy: CachePolicy.PRIVATE_CACHE | CachePolicy.PUBLIC_CACHE -): string { - return `${policy}, max-age=${IMMUTABLE_CACHE_TTL}, immutable`; -} - -const cacheVersionSchema = z.object({ v: z.string().min(1) }); - -interface CacheOptions { - /** Pass false only when the url is immutable without a `?v=`. */ - versioned?: boolean; -} - -/** Overrides the route's immutable default for a body its url does not pin. */ -export function setCacheTtl(c: AppContext, maxAge: number): void { - c.set("cacheTtl", maxAge); -} - -/** Declares how a route's response may be cached, and enforces what that takes. */ -export function cacheMiddleware( - policy: CachePolicy = CachePolicy.NO_CACHE, - options: CacheOptions = {} -): MiddlewareHandler { - if (policy === CachePolicy.NO_CACHE) { - return async (c, next) => { - await next(); - c.header("Cache-Control", NO_STORE); - }; - } - - const cacheControl = immutableCacheControl(policy); - const versioned = options.versioned ?? true; - - return async (c, next) => { - if (versioned && !cacheVersionSchema.safeParse(c.req.query()).success) { - throw new HTTPException(HttpStatus.BAD_REQUEST, { - message: "Missing cache version" - }); - } - await next(); - // A miss must stay retryable, so only store what succeeded. - if (!c.res.ok) { - c.header("Cache-Control", NO_STORE); - return; - } - const ttl = c.get("cacheTtl"); - c.header( - "Cache-Control", - ttl === undefined ? cacheControl : `${policy}, max-age=${ttl}` - ); - }; -} - -export function insertableRoute(): string { - return "/insertable/:insertableId"; -} - -export function getInsertableParam(c: AppContext): string { - const id = c.req.param("insertableId"); - if (!id) throw new Error("Missing insertableId route param"); - return id; -} - -export function groupRoute(): string { - return "/group/:groupId"; -} - -export function getGroupParam(c: AppContext): string { - const id = c.req.param("groupId"); - if (!id) throw new Error("Missing groupId route param"); - return id; -} diff --git a/src/backend/auth.ts b/src/backend/auth-oauth.ts similarity index 57% rename from src/backend/auth.ts rename to src/backend/auth-oauth.ts index 4b3ab51e9..af803d17d 100644 --- a/src/backend/auth.ts +++ b/src/backend/auth-oauth.ts @@ -1,40 +1,35 @@ +/** The Onshape OAuth flow, and the API client a stored session produces. */ import { HttpStatus } from "http-status-ts"; import { generateState, OAuth2Client, OAuth2Tokens } from "arctic"; -import { OAuthApi } from "./onshape-api/onshape-api"; -import { type AppContext, getApp } from "./app"; import { HTTPException } from "hono/http-exception"; -import { getCookie, setCookie } from "hono/cookie"; import { env } from "cloudflare:workers"; +import { OAuthApi } from "./onshape-api/onshape-api"; import { getSessionInfo, getUserId } from "./onshape-api/endpoints/users"; +import { type AppContext } from "./context"; +import { + type AuthTokens, + SESSION_TTL, + getSessionCompanyId, + getSessionId, + getTokens, + saveTokens, + startLoginSession, + takeLoginSession +} from "./auth-session"; -const SESSION_COOKIE = "frc-design-app-cookie"; -const LOGIN_TTL = 600; // 10 minutes -const SESSION_TTL = 30 * 24 * 3600; // 30 days - -export function getSessionId(c: AppContext): string { - const sessionId = getCookie(c, SESSION_COOKIE); - if (!sessionId) { - throw new HTTPException(HttpStatus.UNAUTHORIZED, { - message: "Failed to find a valid session" - }); - } - return sessionId; -} +const AUTH_ENDPOINT = "https://oauth.onshape.com/oauth/authorize"; +const TOKEN_ENDPOINT = "https://oauth.onshape.com/oauth/token"; -function userIdKey(sessionId: string): string { - return `user-id:${sessionId}`; +function getOauthClient(): OAuth2Client { + return new OAuth2Client(env.OAUTH_CLIENT_ID, env.OAUTH_CLIENT_SECRET, null); } -/** Returns the caller's Onshape user id, memoized in KV by session. */ -export async function getCachedUserId(c: AppContext): Promise { - const key = userIdKey(getSessionId(c)); - - const cached = await c.env.KV.get(key); - if (cached) return cached; - - const userId = await getUserId(await getOnshapeApi(c)); - await c.env.KV.put(key, userId, { expirationTtl: SESSION_TTL }); - return userId; +function makeAuthTokens(tokens: OAuth2Tokens): AuthTokens { + return { + accessToken: tokens.accessToken(), + refreshToken: tokens.refreshToken(), + expiresAt: tokens.accessTokenExpiresAt().getTime() + }; } export async function getOnshapeApiFromSessionId( @@ -63,22 +58,6 @@ export async function getOnshapeApiFromSessionId( return new OAuthApi(accessToken, refreshCallback); } -export function getSessionCompanyId(c: AppContext) { - return c.req.query("sessionCompanyId") ?? "cad"; -} - -export async function isAuthenticated(c: AppContext): Promise { - try { - const onshapeApi = await c.var.getOnshapeApi(); - const sessionInfo = await getSessionInfo(onshapeApi); - const tokenCompanyId = sessionInfo.company?.id ?? "cad"; - const requestCompanyId = getSessionCompanyId(c); - return requestCompanyId === tokenCompanyId; - } catch { - return false; - } -} - /** * Creates/caches an Onshape API instance from the AppContext. * @@ -87,47 +66,37 @@ export async function isAuthenticated(c: AppContext): Promise { export async function getOnshapeApi(c: AppContext): Promise { const cached = c.get("onshapeApi"); if (cached) return cached; - const sessionId = getSessionId(c); - const api = await getOnshapeApiFromSessionId(c.env.KV, sessionId); + const api = await getOnshapeApiFromSessionId(c.env.KV, getSessionId(c)); c.set("onshapeApi", api); return api; } -function getOauthClient(): OAuth2Client { - return new OAuth2Client(env.OAUTH_CLIENT_ID, env.OAUTH_CLIENT_SECRET, null); +function userIdKey(sessionId: string): string { + return `user-id:${sessionId}`; } -const AUTH_ENDPOINT = "https://oauth.onshape.com/oauth/authorize"; -const TOKEN_ENDPOINT = "https://oauth.onshape.com/oauth/token"; - -export const authRoutes = getApp(); +/** Returns the caller's Onshape user id, memoized in KV by session. */ +export async function getCachedUserId(c: AppContext): Promise { + const key = userIdKey(getSessionId(c)); -authRoutes.get("/sign-in", async (c) => { - const query = c.req.query(); + const cached = await c.env.KV.get(key); + if (cached) return cached; - // If Onshape hits this endpoint from, e.g., the user sign in page, they will populate redirectOnshapeUri with that page - let redirectUrl = query.redirectOnshapeUri; - if (!redirectUrl) { - // Otherwise we should have one passed in - redirectUrl = query.redirectUrl; - } + const userId = await getUserId(await getOnshapeApi(c)); + await c.env.KV.put(key, userId, { expirationTtl: SESSION_TTL }); + return userId; +} - if (!redirectUrl) { - throw new HTTPException(HttpStatus.BAD_REQUEST, { - message: "Failed to find valid redirectUrl" - }); +export async function isAuthenticated(c: AppContext): Promise { + try { + const onshapeApi = await c.var.getOnshapeApi(); + const sessionInfo = await getSessionInfo(onshapeApi); + const tokenCompanyId = sessionInfo.company?.id ?? "cad"; + return getSessionCompanyId(c) === tokenCompanyId; + } catch { + return false; } - - // Standalone sign-in omits sessionCompanyId; leave companyId undefined so the - // user can pick their account on Onshape. - const companyId = query.sessionCompanyId; - const authorizationUrl = await doSignIn(c, redirectUrl, companyId); - return c.redirect(authorizationUrl); -}); - -authRoutes.get("/callback", async (c) => { - return doCallback(c); -}); +} /** * Stores the redirectUrl and state. @@ -144,7 +113,7 @@ export async function doSignIn( const state = generateState(); // Store the state and redirectUrl so the callback can complete sign-in. - await initSession(c, { state, redirectUrl }); + await startLoginSession(c, { state, redirectUrl }); const authorizationUrl = oauthClient.createAuthorizationURL( AUTH_ENDPOINT, @@ -167,7 +136,7 @@ export async function doCallback(c: AppContext): Promise { return c.redirect("/grant-denied"); } - const session = await getSession(c); + const session = await takeLoginSession(c); // There was a problem with the cookie used to store redirect information if (!session) { @@ -205,86 +174,3 @@ function isSafari(request: Request): boolean { !userAgent.includes("OPR/") ); } - -interface OAuthSessionData { - state: string; - redirectUrl: string; -} - -async function getSession( - c: AppContext -): Promise<(OAuthSessionData & { sessionId: string }) | null> { - const sessionId = getCookie(c, SESSION_COOKIE); - if (!sessionId) return null; - const raw = await c.env.KV.get(`login-session:${sessionId}`); - if (!raw) return null; - - const session = JSON.parse(raw); - session.sessionId = sessionId; - - void c.env.KV.delete(`login-session:${sessionId}`); - return session; -} - -async function initSession( - c: AppContext, - data: OAuthSessionData -): Promise { - const sessionId = crypto.randomUUID(); - // SameSite=none + secure required because the app runs embedded in an Onshape iframe - setCookie(c, SESSION_COOKIE, sessionId, { - httpOnly: true, - secure: true, - sameSite: "None", - path: "/", - maxAge: SESSION_TTL - }); - - await c.env.KV.put(`login-session:${sessionId}`, JSON.stringify(data), { - expirationTtl: LOGIN_TTL - }); - return sessionId; -} -interface AuthTokens { - accessToken: string; - refreshToken: string; - expiresAt: number; -} - -function makeAuthTokens(tokens: OAuth2Tokens): AuthTokens { - return { - accessToken: tokens.accessToken(), - refreshToken: tokens.refreshToken(), - expiresAt: tokens.accessTokenExpiresAt().getTime() - }; -} - -/** - * Saves a set of tokens into KV. - */ -async function saveTokens( - kv: KVNamespace, - sessionId: string, - tokens: AuthTokens -) { - const tokenString = JSON.stringify(tokens); - await kv.put(`tokens:${sessionId}`, tokenString, { - expirationTtl: SESSION_TTL - }); -} - -/** - * Retrieves a set of tokens from KV. - */ -async function getTokens( - kv: KVNamespace, - sessionId: string -): Promise { - const raw = await kv.get(`tokens:${sessionId}`); - if (!raw) { - throw new HTTPException(HttpStatus.UNAUTHORIZED, { - message: "Failed to find valid auth tokens to use" - }); - } - return JSON.parse(raw) as AuthTokens; -} diff --git a/src/backend/auth-routes.ts b/src/backend/auth-routes.ts new file mode 100644 index 000000000..367359d0c --- /dev/null +++ b/src/backend/auth-routes.ts @@ -0,0 +1,33 @@ +import { HttpStatus } from "http-status-ts"; +import { HTTPException } from "hono/http-exception"; +import { getApp } from "./context"; +import { doCallback, doSignIn } from "./auth-oauth"; + +export const authRoutes = getApp(); + +authRoutes.get("/sign-in", async (c) => { + const query = c.req.query(); + + // If Onshape hits this endpoint from, e.g., the user sign in page, they will populate redirectOnshapeUri with that page + let redirectUrl = query.redirectOnshapeUri; + if (!redirectUrl) { + // Otherwise we should have one passed in + redirectUrl = query.redirectUrl; + } + + if (!redirectUrl) { + throw new HTTPException(HttpStatus.BAD_REQUEST, { + message: "Failed to find valid redirectUrl" + }); + } + + // Standalone sign-in omits sessionCompanyId; leave companyId undefined so the + // user can pick their account on Onshape. + const companyId = query.sessionCompanyId; + const authorizationUrl = await doSignIn(c, redirectUrl, companyId); + return c.redirect(authorizationUrl); +}); + +authRoutes.get("/callback", async (c) => { + return doCallback(c); +}); diff --git a/src/backend/auth-session.ts b/src/backend/auth-session.ts new file mode 100644 index 000000000..c55f67084 --- /dev/null +++ b/src/backend/auth-session.ts @@ -0,0 +1,94 @@ +/** Session cookie plus the KV records it keys: OAuth tokens and login state. */ +import { HttpStatus } from "http-status-ts"; +import { HTTPException } from "hono/http-exception"; +import { getCookie, setCookie } from "hono/cookie"; +import { type AppContext } from "./context"; + +const SESSION_COOKIE = "frc-design-app-cookie"; +const LOGIN_TTL = 600; // 10 minutes +export const SESSION_TTL = 30 * 24 * 3600; // 30 days + +export function getSessionId(c: AppContext): string { + const sessionId = getCookie(c, SESSION_COOKIE); + if (!sessionId) { + throw new HTTPException(HttpStatus.UNAUTHORIZED, { + message: "Failed to find a valid session" + }); + } + return sessionId; +} + +export function getSessionCompanyId(c: AppContext) { + return c.req.query("sessionCompanyId") ?? "cad"; +} + +export interface AuthTokens { + accessToken: string; + refreshToken: string; + expiresAt: number; +} + +export async function saveTokens( + kv: KVNamespace, + sessionId: string, + tokens: AuthTokens +) { + await kv.put(`tokens:${sessionId}`, JSON.stringify(tokens), { + expirationTtl: SESSION_TTL + }); +} + +export async function getTokens( + kv: KVNamespace, + sessionId: string +): Promise { + const raw = await kv.get(`tokens:${sessionId}`); + if (!raw) { + throw new HTTPException(HttpStatus.UNAUTHORIZED, { + message: "Failed to find valid auth tokens to use" + }); + } + return JSON.parse(raw) as AuthTokens; +} + +/** What the callback needs to finish a sign-in it did not start. */ +export interface LoginSession { + state: string; + redirectUrl: string; +} + +/** Single-use: reading it also clears it, so a state cannot be replayed. */ +export async function takeLoginSession( + c: AppContext +): Promise<(LoginSession & { sessionId: string }) | null> { + const sessionId = getCookie(c, SESSION_COOKIE); + if (!sessionId) return null; + const raw = await c.env.KV.get(`login-session:${sessionId}`); + if (!raw) return null; + + const session = JSON.parse(raw); + session.sessionId = sessionId; + + void c.env.KV.delete(`login-session:${sessionId}`); + return session; +} + +export async function startLoginSession( + c: AppContext, + data: LoginSession +): Promise { + const sessionId = crypto.randomUUID(); + // SameSite=none + secure required because the app runs embedded in an Onshape iframe + setCookie(c, SESSION_COOKIE, sessionId, { + httpOnly: true, + secure: true, + sameSite: "None", + path: "/", + maxAge: SESSION_TTL + }); + + await c.env.KV.put(`login-session:${sessionId}`, JSON.stringify(data), { + expirationTtl: LOGIN_TTL + }); + return sessionId; +} diff --git a/src/backend/cache.ts b/src/backend/cache.ts new file mode 100644 index 000000000..9790e333f --- /dev/null +++ b/src/backend/cache.ts @@ -0,0 +1,72 @@ +import { type MiddlewareHandler } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { HttpStatus } from "http-status-ts"; +import z from "zod"; +import { type AppContext, type AppContextEnv } from "./context"; + +/** A year — a versioned url's content never changes, only its version does. */ +const IMMUTABLE_CACHE_TTL = 365 * 24 * 3600; + +const NO_STORE = "private, no-store"; + +export enum CachePolicy { + /** Never stored, anywhere. */ + NO_CACHE = "no-cache", + /** Immutable, but kept out of shared caches. */ + PRIVATE_CACHE = "private", + /** Immutable and the same for every caller. */ + PUBLIC_CACHE = "public" +} + +export function immutableCacheControl( + policy: CachePolicy.PRIVATE_CACHE | CachePolicy.PUBLIC_CACHE +): string { + return `${policy}, max-age=${IMMUTABLE_CACHE_TTL}, immutable`; +} + +const cacheVersionSchema = z.object({ v: z.string().min(1) }); + +interface CacheOptions { + /** Pass false only when the url is immutable without a `?v=`. */ + versioned?: boolean; +} + +/** Overrides the route's immutable default for a body its url does not pin. */ +export function setCacheTtl(c: AppContext, maxAge: number): void { + c.set("cacheTtl", maxAge); +} + +/** Declares how a route's response may be cached, and enforces what that takes. */ +export function cacheMiddleware( + policy: CachePolicy = CachePolicy.NO_CACHE, + options: CacheOptions = {} +): MiddlewareHandler { + if (policy === CachePolicy.NO_CACHE) { + return async (c, next) => { + await next(); + c.header("Cache-Control", NO_STORE); + }; + } + + const cacheControl = immutableCacheControl(policy); + const versioned = options.versioned ?? true; + + return async (c, next) => { + if (versioned && !cacheVersionSchema.safeParse(c.req.query()).success) { + throw new HTTPException(HttpStatus.BAD_REQUEST, { + message: "Missing cache version" + }); + } + await next(); + // A miss must stay retryable, so only store what succeeded. + if (!c.res.ok) { + c.header("Cache-Control", NO_STORE); + return; + } + const ttl = c.get("cacheTtl"); + c.header( + "Cache-Control", + ttl === undefined ? cacheControl : `${policy}, max-age=${ttl}` + ); + }; +} diff --git a/src/backend/context.ts b/src/backend/context.ts new file mode 100644 index 000000000..c9a360b6d --- /dev/null +++ b/src/backend/context.ts @@ -0,0 +1,61 @@ +import { type Context, Hono } from "hono"; +import type { + AddGroupParams, + LoadLibraryParams, + ThumbnailWorkflowParams +} from "./load/workflows"; +import { type AccessLevel } from "../shared/access-level"; +import { type OAuthApi } from "./onshape-api/onshape-api"; + +export interface AppBindings { + DB: D1Database; + KV: KVNamespace; + ASSETS: Fetcher; + /** Thumbnails and search indexes; prefixes keep them apart. */ + BLOB: R2Bucket; + LOAD_LIBRARY_WORKFLOW: Workflow; + ADD_GROUP_WORKFLOW: Workflow; + /** Renders a configuration's thumbnails outside a request; see ThumbnailWorkflow. */ + THUMBNAIL_WORKFLOW: Workflow; + ADMIN_TEAM: string; + ACCESS_LEVEL_OVERRIDE?: string; + /** Testing-only: treat requests as signed in with a fake user. Not for production. */ + FORCE_SIGNED_IN?: string; +} + +interface AppVariables { + /** Internal cache for {@link getOnshapeApi} in features/auth/onshape-oauth.ts. */ + onshapeApi?: OAuthApi; + /** Internal cache for isSignedIn in features/auth/sign-in.ts. */ + signedIn?: boolean; + /** Set by `setCacheTtl`; read by `cacheMiddleware`. */ + cacheTtl?: number; + /** Injected getters — see {@link AppServices} / `createApp`. */ + getOnshapeApi: () => Promise; + getUserId: () => Promise; + getAccessLevel: () => Promise; + isAuthenticated: () => Promise; +} + +export interface AppContextEnv { + Bindings: AppBindings; + Variables: AppVariables; +} + +export type AppContext = Context; + +/** + * Per-request dependencies injected into the app. + */ +export interface AppServices { + getOnshapeApi: () => Promise; + getUserId: () => Promise; + getAccessLevel: () => Promise; + isAuthenticated: () => Promise; +} + +export type AppServicesFactory = (c: AppContext) => AppServices; + +export function getApp() { + return new Hono(); +} diff --git a/src/backend/create-app.test.ts b/src/backend/create-app.test.ts index 87b41df09..93ff582d4 100644 --- a/src/backend/create-app.test.ts +++ b/src/backend/create-app.test.ts @@ -2,7 +2,8 @@ import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it } from "vitest"; import { eq } from "drizzle-orm"; import { users } from "../shared/schema"; -import { LibraryId, Theme } from "../shared/types"; +import { LibraryId } from "../shared/library-id"; +import { Theme } from "../shared/settings"; import { TEST_USER_ID, createTestApp, diff --git a/src/backend/create-app.ts b/src/backend/create-app.ts index 409d58405..72de109da 100644 --- a/src/backend/create-app.ts +++ b/src/backend/create-app.ts @@ -3,14 +3,12 @@ import { HttpStatus } from "http-status-ts"; import { eq } from "drizzle-orm"; import { getDb } from "./db"; import { users } from "../shared/schema"; -import { DEFAULT_LIBRARY_ID, DEFAULT_SETTINGS } from "../shared/types"; -import { authRoutes, getSessionCompanyId } from "./auth"; -import { - cacheMiddleware, - getApp, - type AppContext, - type AppServicesFactory -} from "./app"; +import { DEFAULT_LIBRARY_ID } from "../shared/library-id"; +import { DEFAULT_SETTINGS } from "../shared/settings"; +import { authRoutes } from "./auth-routes"; +import { getSessionCompanyId } from "./auth-session"; +import { cacheMiddleware } from "./cache"; +import { getApp, type AppContext, type AppServicesFactory } from "./context"; import { OnshapeRateLimitError } from "./onshape-api/onshape-api"; import { userRoutes } from "./routes/user"; import { libraryRoutes } from "./routes/library"; diff --git a/src/backend/library-data.ts b/src/backend/library-data.ts index 5b0950510..4e60135fa 100644 --- a/src/backend/library-data.ts +++ b/src/backend/library-data.ts @@ -6,13 +6,8 @@ import { insertables, configurations } from "../shared/schema"; -import { LibraryId } from "../shared/types"; -import { - InsertableOut, - LibraryOut, - Insertables, - Groups -} from "../shared/api-models"; +import { LibraryId } from "../shared/library-id"; +import { InsertableOut, LibraryOut, Insertables, Groups } from "../shared/library-dto"; import { ConfigurationRecord } from "../shared/configuration-models"; import { buildSearchDb } from "../shared/search"; diff --git a/src/backend/load/job-tracker.ts b/src/backend/load/job-tracker.ts index d48632de6..377c0fba8 100644 --- a/src/backend/load/job-tracker.ts +++ b/src/backend/load/job-tracker.ts @@ -1,6 +1,6 @@ -import type { AppBindings } from "../app"; -import type { LibraryId } from "../../shared/types"; -import type { JobStatus } from "../../shared/api-models"; +import type { AppBindings } from "../context"; +import type { LibraryId } from "../../shared/library-id"; +import type { JobStatus } from "../../shared/library-dto"; /** * Backstop for a job that crashes before untracking itself; must outlast the diff --git a/src/backend/load/load-common.ts b/src/backend/load/load-common.ts index 69e39d27f..edf5e58af 100644 --- a/src/backend/load/load-common.ts +++ b/src/backend/load/load-common.ts @@ -1,8 +1,9 @@ import type { WorkflowStep } from "cloudflare:workers"; -import type { AppBindings } from "../app"; -import { getOnshapeApiFromSessionId } from "../auth"; +import type { AppBindings } from "../context"; +import { getOnshapeApiFromSessionId } from "../auth-oauth"; import type { OnshapeApi } from "../onshape-api/onshape-api"; -import type { ElementType, LibraryId } from "../../shared/types"; +import type { ElementType } from "../../shared/element-type"; +import type { LibraryId } from "../../shared/library-id"; import type { ElementPath, InstancePath } from "../../shared/onshape-path"; /** How many insertables a load reads from Onshape at once. */ diff --git a/src/backend/load/load-group.ts b/src/backend/load/load-group.ts index c7a9a7a73..0047caea4 100644 --- a/src/backend/load/load-group.ts +++ b/src/backend/load/load-group.ts @@ -1,8 +1,8 @@ import { eq, inArray } from "drizzle-orm"; import type { BatchItem } from "drizzle-orm/batch"; import { type Db, getDb } from "../db"; -import { ElementType } from "../../shared/types"; -import type { ThumbnailUrls } from "../../shared/types"; +import { ElementType } from "../../shared/element-type"; +import type { ThumbnailUrls } from "../../shared/thumbnail-types"; import { addBuildIssue, type BuildIssue, diff --git a/src/backend/load/load-insertable.ts b/src/backend/load/load-insertable.ts index 19bf1a4d0..24268bea7 100644 --- a/src/backend/load/load-insertable.ts +++ b/src/backend/load/load-insertable.ts @@ -9,12 +9,10 @@ import { type BuildIssue, BuildIssueType } from "../../shared/build-issues"; -import { - ElementType, - type FastenInfo, - type ThumbnailUrls, - type Vendor -} from "../../shared/types"; +import { ElementType } from "../../shared/element-type"; +import type { FastenInfo } from "../../shared/fasten"; +import type { ThumbnailUrls } from "../../shared/thumbnail-types"; +import type { Vendor } from "../../shared/vendors"; import { configurations, insertables } from "../../shared/schema"; import { uploadThumbnails } from "../routes/thumbnails"; import { getConfiguration } from "../onshape-api/endpoints/configurations"; diff --git a/src/backend/load/load-steps.ts b/src/backend/load/load-steps.ts index 1ef2ed18a..cc4a4526d 100644 --- a/src/backend/load/load-steps.ts +++ b/src/backend/load/load-steps.ts @@ -1,5 +1,5 @@ import { OnshapeRateLimitError } from "../onshape-api/onshape-api"; -import type { ThumbnailUrls } from "../../shared/types"; +import type { ThumbnailUrls } from "../../shared/thumbnail-types"; import { NoSuchConfigurationError } from "../onshape-api/endpoints/thumbnails"; import type { LoadContext } from "./load-common"; diff --git a/src/backend/load/workflows.ts b/src/backend/load/workflows.ts index 678065270..aba99ea4b 100644 --- a/src/backend/load/workflows.ts +++ b/src/backend/load/workflows.ts @@ -4,9 +4,9 @@ import { type WorkflowStep } from "cloudflare:workers"; import { eq } from "drizzle-orm"; -import type { AppBindings } from "../app"; +import type { AppBindings } from "../context"; import { getDb } from "../db"; -import type { LibraryId } from "../../shared/types"; +import type { LibraryId } from "../../shared/library-id"; import { bumpLibraryVersion, placeNewGroup, diff --git a/src/backend/onshape-api/endpoints/thumbnails.ts b/src/backend/onshape-api/endpoints/thumbnails.ts index 48c21b2a6..0c5e8ff9b 100644 --- a/src/backend/onshape-api/endpoints/thumbnails.ts +++ b/src/backend/onshape-api/endpoints/thumbnails.ts @@ -7,7 +7,7 @@ import { toInstanceApiPath } from "../../../shared/onshape-path"; import { apiPath } from "../api-path"; -import { ThumbnailSize } from "../../../shared/types"; +import { ThumbnailSize } from "../../../shared/thumbnail-types"; /** Returns the thumbnail of a given document instance. */ export function getInstanceThumbnail( diff --git a/src/backend/onshape-api/endpoints/users.ts b/src/backend/onshape-api/endpoints/users.ts index 990d64c4d..61f8b34bf 100644 --- a/src/backend/onshape-api/endpoints/users.ts +++ b/src/backend/onshape-api/endpoints/users.ts @@ -1,7 +1,7 @@ import { OnshapeApi } from "../onshape-api"; import { OAuthApi } from "../onshape-api"; import { apiPath } from "../api-path"; -import { AccessLevel } from "../../../shared/types"; +import { AccessLevel } from "../../../shared/access-level"; export interface SessionInfo { id: string; diff --git a/src/backend/parse/build-checks.test.ts b/src/backend/parse/build-checks.test.ts index 28cf538b7..374a64157 100644 --- a/src/backend/parse/build-checks.test.ts +++ b/src/backend/parse/build-checks.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { ThumbnailSize, ThumbnailUrls, Vendor } from "../../shared/types"; +import { ThumbnailSize, ThumbnailUrls } from "../../shared/thumbnail-types"; +import { Vendor } from "../../shared/vendors"; import { BuildIssueType } from "../../shared/build-issues"; import { DEFAULT_CANONICAL_CONFIGURATION } from "../../shared/canonical-configuration"; import { thumbnailUrl } from "../../shared/thumbnails"; diff --git a/src/backend/parse/build-checks.ts b/src/backend/parse/build-checks.ts index 808fb93fb..00f46c52f 100644 --- a/src/backend/parse/build-checks.ts +++ b/src/backend/parse/build-checks.ts @@ -1,4 +1,5 @@ -import { ThumbnailUrls, Vendor, isCustomPart } from "../../shared/types"; +import { ThumbnailUrls } from "../../shared/thumbnail-types"; +import { Vendor, isCustomPart } from "../../shared/vendors"; import { addBuildIssue, BuildIssue, diff --git a/src/backend/parse/insert-and-fasten.test.ts b/src/backend/parse/insert-and-fasten.test.ts index 22c90cfb4..9bbf9afe1 100644 --- a/src/backend/parse/insert-and-fasten.test.ts +++ b/src/backend/parse/insert-and-fasten.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { ElementType, FastenInfo, MateLocation } from "../../shared/types"; +import { ElementType } from "../../shared/element-type"; +import { FastenInfo, MateLocation } from "../../shared/fasten"; import { getFastenQuery, parseFastenInfoFromPartStudio, diff --git a/src/backend/parse/insert-and-fasten.ts b/src/backend/parse/insert-and-fasten.ts index fe3abbd45..ff34e69d4 100644 --- a/src/backend/parse/insert-and-fasten.ts +++ b/src/backend/parse/insert-and-fasten.ts @@ -1,4 +1,5 @@ -import { ElementType, FastenInfo, MateLocation } from "../../shared/types"; +import { ElementType } from "../../shared/element-type"; +import { FastenInfo, MateLocation } from "../../shared/fasten"; import { type ElementPath } from "../../shared/onshape-path"; import { getAssembly } from "../onshape-api/endpoints/assemblies"; import { getFeatures } from "../onshape-api/endpoints/part-studios"; diff --git a/src/backend/parse/parse-configuration-records.test.ts b/src/backend/parse/parse-configuration-records.test.ts index 6f264a2a6..1059aa392 100644 --- a/src/backend/parse/parse-configuration-records.test.ts +++ b/src/backend/parse/parse-configuration-records.test.ts @@ -13,7 +13,7 @@ import { ConfigurationParameter } from "../../shared/configuration-models"; import { enumParam } from "../../__test_utils__/configuration-fixtures"; -import { ElementType } from "../../shared/types"; +import { ElementType } from "../../shared/element-type"; import { BuildIssueType } from "../../shared/build-issues"; import { decideIndexing, diff --git a/src/backend/parse/parse-configuration-records.ts b/src/backend/parse/parse-configuration-records.ts index 92befcfab..dfe7bab08 100644 --- a/src/backend/parse/parse-configuration-records.ts +++ b/src/backend/parse/parse-configuration-records.ts @@ -4,7 +4,7 @@ */ import { OnshapeApi } from "../onshape-api/onshape-api"; import { ElementPath } from "../../shared/onshape-path"; -import { ElementType } from "../../shared/types"; +import { ElementType } from "../../shared/element-type"; import { ParameterValues, ConfigurationParameter, diff --git a/src/backend/parse/parse-document-contents.ts b/src/backend/parse/parse-document-contents.ts index 33ef27354..c987211d0 100644 --- a/src/backend/parse/parse-document-contents.ts +++ b/src/backend/parse/parse-document-contents.ts @@ -1,7 +1,7 @@ /** * Extracts the insertable tabs from a document's contents listing. */ -import { ElementType } from "../../shared/types"; +import { ElementType } from "../../shared/element-type"; import { type OnshapeDocumentContents, type OnshapeElement, diff --git a/src/backend/parse/parse-vendors.test.ts b/src/backend/parse/parse-vendors.test.ts index d7879bb05..08266fe43 100644 --- a/src/backend/parse/parse-vendors.test.ts +++ b/src/backend/parse/parse-vendors.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { Vendor } from "../../shared/types"; +import { Vendor } from "../../shared/vendors"; import { ParameterType } from "../../shared/configuration-models"; import { QuantityType, Unit } from "../../shared/configuration-enums"; import { parseNameVendor, parseVendors } from "./parse-vendors"; diff --git a/src/backend/parse/parse-vendors.ts b/src/backend/parse/parse-vendors.ts index 6e4a285db..7fafd3886 100644 --- a/src/backend/parse/parse-vendors.ts +++ b/src/backend/parse/parse-vendors.ts @@ -1,4 +1,4 @@ -import { Vendor, getVendorName } from "../../shared/types"; +import { Vendor, getVendorName } from "../../shared/vendors"; import { ParameterType, type ConfigurationParameter diff --git a/src/backend/route-params.ts b/src/backend/route-params.ts new file mode 100644 index 000000000..c05d2533a --- /dev/null +++ b/src/backend/route-params.ts @@ -0,0 +1,41 @@ +/** Route patterns and their param readers, so mounts and lookups stay in sync. */ +import { HTTPException } from "hono/http-exception"; +import { HttpStatus } from "http-status-ts"; +import z from "zod"; +import { LibraryId } from "../shared/library-id"; +import { type AppContext } from "./context"; + +export function libraryRoute(): string { + return "/library/:libraryId"; +} + +export function getLibraryParam(c: AppContext): LibraryId { + const libraryId = c.req.param("libraryId"); + const parsed = z.enum(LibraryId).safeParse(libraryId); + if (!parsed.success) { + throw new HTTPException(HttpStatus.BAD_REQUEST, { + message: "Invalid libraryId" + }); + } + return parsed.data; +} + +export function insertableRoute(): string { + return "/insertable/:insertableId"; +} + +export function getInsertableParam(c: AppContext): string { + const id = c.req.param("insertableId"); + if (!id) throw new Error("Missing insertableId route param"); + return id; +} + +export function groupRoute(): string { + return "/group/:groupId"; +} + +export function getGroupParam(c: AppContext): string { + const id = c.req.param("groupId"); + if (!id) throw new Error("Missing groupId route param"); + return id; +} diff --git a/src/backend/routes/build-status.test.ts b/src/backend/routes/build-status.test.ts index c4bc6abfc..0752117dd 100644 --- a/src/backend/routes/build-status.test.ts +++ b/src/backend/routes/build-status.test.ts @@ -11,7 +11,7 @@ import { seedPartStudio } from "../../__test_utils__"; import { getDb } from "../db"; -import type { LibraryBuildStatus } from "../../shared/api-models"; +import type { LibraryBuildStatus } from "../../shared/build-status-dto"; const db = getDb(env.DB); diff --git a/src/backend/routes/build-status.ts b/src/backend/routes/build-status.ts index 877175abd..d05c84885 100644 --- a/src/backend/routes/build-status.ts +++ b/src/backend/routes/build-status.ts @@ -1,19 +1,11 @@ import { asc, eq, inArray } from "drizzle-orm"; -import { - CachePolicy, - cacheMiddleware, - getApp, - getLibraryParam, - libraryRoute -} from "../app"; +import { CachePolicy, cacheMiddleware } from "../cache"; +import { getApp } from "../context"; +import { getLibraryParam, libraryRoute } from "../route-params"; import { getDb } from "../db"; import { requireEditorMiddleware } from "../access-level-utils"; import { group, insertables, configurations } from "../../shared/schema"; -import { - type LibraryBuildStatus, - type GroupBuildStatus, - type InsertableBuildStatus -} from "../../shared/api-models"; +import type { LibraryBuildStatus, GroupBuildStatus, InsertableBuildStatus } from "../../shared/build-status-dto"; export const buildStatusRoutes = getApp(); diff --git a/src/backend/routes/configurations.ts b/src/backend/routes/configurations.ts index 971744603..6307a3872 100644 --- a/src/backend/routes/configurations.ts +++ b/src/backend/routes/configurations.ts @@ -1,5 +1,6 @@ import { eq } from "drizzle-orm"; -import { CachePolicy, cacheMiddleware, getApp } from "../app"; +import { CachePolicy, cacheMiddleware } from "../cache"; +import { getApp } from "../context"; import { getDb } from "../db"; import { getUnitInfo } from "../onshape-api/endpoints/documents"; import { configurations } from "../../shared/schema"; diff --git a/src/backend/routes/favorites.ts b/src/backend/routes/favorites.ts index cc60f3105..b7ecef8f5 100644 --- a/src/backend/routes/favorites.ts +++ b/src/backend/routes/favorites.ts @@ -1,9 +1,11 @@ import { and, asc, eq } from "drizzle-orm"; -import { cacheMiddleware, getApp, getLibraryParam, libraryRoute } from "../app"; +import { cacheMiddleware } from "../cache"; +import { getApp } from "../context"; +import { getLibraryParam, libraryRoute } from "../route-params"; import { type Db, getDb } from "../db"; import { users, favorites } from "../../shared/schema"; -import { type Favorite, type FavoritesData } from "../../shared/api-models"; -import { type LibraryId } from "../../shared/types"; +import type { Favorite, FavoritesData } from "../../shared/favorites-dto"; +import type { LibraryId } from "../../shared/library-id"; import { HttpStatus } from "http-status-ts"; import { type ParameterValues } from "../../shared/configuration-models"; import { requireSignInMiddleware } from "../sign-in-utils"; diff --git a/src/backend/routes/groups.test.ts b/src/backend/routes/groups.test.ts index d38a7e5b0..10bd8d7a0 100644 --- a/src/backend/routes/groups.test.ts +++ b/src/backend/routes/groups.test.ts @@ -14,7 +14,7 @@ import { } from "../../__test_utils__"; import MiniSearch from "minisearch"; import { getDb } from "../db"; -import type { JobStatus } from "../../shared/api-models"; +import type { JobStatus } from "../../shared/library-dto"; import { searchIndexKey } from "../library-data"; import { SEARCH_OPTIONS, type SearchDocument } from "../../shared/search"; import * as DocumentsEndpoint from "../onshape-api/endpoints/documents"; diff --git a/src/backend/routes/groups.ts b/src/backend/routes/groups.ts index cecf6b7a1..1f4d3dad4 100644 --- a/src/backend/routes/groups.ts +++ b/src/backend/routes/groups.ts @@ -1,7 +1,9 @@ import { and, eq, inArray } from "drizzle-orm"; -import { cacheMiddleware, getApp, getLibraryParam, libraryRoute } from "../app"; +import { cacheMiddleware } from "../cache"; +import { getApp } from "../context"; +import { getLibraryParam, libraryRoute } from "../route-params"; import { getDb } from "../db"; -import { getSessionId } from "../auth"; +import { getSessionId } from "../auth-session"; import { getDocument } from "../onshape-api/endpoints/documents"; import { requireEditorMiddleware } from "../access-level-utils"; import { type DocumentPath } from "../../shared/onshape-path"; diff --git a/src/backend/routes/insertables.test.ts b/src/backend/routes/insertables.test.ts index e334dfdaa..272679510 100644 --- a/src/backend/routes/insertables.test.ts +++ b/src/backend/routes/insertables.test.ts @@ -2,7 +2,8 @@ import { eq } from "drizzle-orm"; import { env } from "cloudflare:workers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { configurations, insertables } from "../../shared/schema"; -import { ElementType, Vendor } from "../../shared/types"; +import { ElementType } from "../../shared/element-type"; +import { Vendor } from "../../shared/vendors"; import { BuildIssueType } from "../../shared/build-issues"; import { MOCK_ONSHAPE_API, diff --git a/src/backend/routes/insertables.ts b/src/backend/routes/insertables.ts index 3c083dc94..8b25e6c83 100644 --- a/src/backend/routes/insertables.ts +++ b/src/backend/routes/insertables.ts @@ -3,7 +3,8 @@ import { HTTPException } from "hono/http-exception"; import { zValidator } from "@hono/zod-validator"; import { HttpStatus } from "http-status-ts"; import z from "zod"; -import { getApp, getInsertableParam, insertableRoute } from "../app"; +import { getApp } from "../context"; +import { getInsertableParam, insertableRoute } from "../route-params"; import { getDb, type Db } from "../db"; import { requireEditorMiddleware } from "../access-level-utils"; import { requireSignInMiddleware } from "../sign-in-utils"; @@ -22,7 +23,7 @@ import { type ConfigurationRecordsResult } from "../parse/parse-configuration-records"; import { type OnshapeApi } from "../onshape-api/onshape-api"; -import { ElementType } from "../../shared/types"; +import { ElementType } from "../../shared/element-type"; import { DerivedFeature } from "../onshape-api/objects/derive-feature"; import { addPartStudioFeature } from "../onshape-api/endpoints/part-studios"; import { diff --git a/src/backend/routes/library.test.ts b/src/backend/routes/library.test.ts index 3c41f655a..3aea6f649 100644 --- a/src/backend/routes/library.test.ts +++ b/src/backend/routes/library.test.ts @@ -13,8 +13,8 @@ import { } from "../../__test_utils__"; import { getDb } from "../db"; import { rebuildSearchDb, searchIndexKey } from "../library-data"; -import { LibraryOut } from "../../shared/api-models"; -import { LibraryId } from "../../shared/types"; +import { LibraryOut } from "../../shared/library-dto"; +import { LibraryId } from "../../shared/library-id"; const db = getDb(env.DB); diff --git a/src/backend/routes/library.ts b/src/backend/routes/library.ts index 4493a48e7..9c4d267bc 100644 --- a/src/backend/routes/library.ts +++ b/src/backend/routes/library.ts @@ -1,11 +1,7 @@ import { eq } from "drizzle-orm"; -import { - CachePolicy, - cacheMiddleware, - getApp, - getLibraryParam, - libraryRoute -} from "../app"; +import { CachePolicy, cacheMiddleware } from "../cache"; +import { getApp } from "../context"; +import { getLibraryParam, libraryRoute } from "../route-params"; import { getDb } from "../db"; import { libraries } from "../../shared/schema"; import { getLibraryOut, searchIndexKey } from "../library-data"; diff --git a/src/backend/routes/not-signed-in.test.ts b/src/backend/routes/not-signed-in.test.ts index 038bafead..a9fc10d7e 100644 --- a/src/backend/routes/not-signed-in.test.ts +++ b/src/backend/routes/not-signed-in.test.ts @@ -1,6 +1,8 @@ import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it } from "vitest"; -import { AccessLevel, LibraryId, Theme } from "../../shared/types"; +import { AccessLevel } from "../../shared/access-level"; +import { LibraryId } from "../../shared/library-id"; +import { Theme } from "../../shared/settings"; import { createTestApp, jsonRequest, diff --git a/src/backend/routes/thumbnails.test.ts b/src/backend/routes/thumbnails.test.ts index a70287e4b..007c47160 100644 --- a/src/backend/routes/thumbnails.test.ts +++ b/src/backend/routes/thumbnails.test.ts @@ -1,7 +1,7 @@ import { env } from "cloudflare:workers"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createTestApp, jsonRequest } from "../../__test_utils__"; -import { ThumbnailSize } from "../../shared/types"; +import { ThumbnailSize } from "../../shared/thumbnail-types"; import { THUMBNAIL_FALLBACK_CACHE_TTL, THUMBNAIL_FALLBACK_HEADER, diff --git a/src/backend/routes/thumbnails.ts b/src/backend/routes/thumbnails.ts index ae5d788ea..79dd190de 100644 --- a/src/backend/routes/thumbnails.ts +++ b/src/backend/routes/thumbnails.ts @@ -1,15 +1,9 @@ import { eq } from "drizzle-orm"; import { z } from "zod"; import { zValidator } from "@hono/zod-validator"; -import { - CachePolicy, - cacheMiddleware, - getApp, - getInsertableParam, - immutableCacheControl, - insertableRoute, - setCacheTtl -} from "../app"; +import { CachePolicy, cacheMiddleware, immutableCacheControl, setCacheTtl } from "../cache"; +import { getApp } from "../context"; +import { getInsertableParam, insertableRoute } from "../route-params"; import { getInsertableElementPath } from "./insertables"; import { getDb } from "../db"; import { requireEditorMiddleware } from "../access-level-utils"; @@ -24,7 +18,7 @@ import { type ElementPath, type InstancePath } from "../../shared/onshape-path"; import { group, insertables } from "../../shared/schema"; import { HTTPException } from "hono/http-exception"; import { HttpStatus } from "http-status-ts"; -import { ThumbnailSize, ThumbnailUrls } from "../../shared/types"; +import { ThumbnailSize, ThumbnailUrls } from "../../shared/thumbnail-types"; import { THUMBNAIL_FALLBACK_CACHE_TTL, THUMBNAIL_FALLBACK_HEADER, @@ -37,9 +31,9 @@ import { canonicalConfigurationKey } from "../../shared/canonical-configuration"; import { OnshapeApi } from "../onshape-api/onshape-api"; -import type { AppContext } from "../app"; +import type { AppContext } from "../context"; import type { ThumbnailWorkflowParams } from "../load/workflows"; -import { getSessionId } from "../auth"; +import { getSessionId } from "../auth-session"; import { BuildIssueType, clearBuildIssue } from "../../shared/build-issues"; /** Stores one rendered thumbnail, tagging it with what produced it. */ diff --git a/src/backend/routes/user.test.ts b/src/backend/routes/user.test.ts index b4cc6b06d..180e650ad 100644 --- a/src/backend/routes/user.test.ts +++ b/src/backend/routes/user.test.ts @@ -2,7 +2,8 @@ import { eq } from "drizzle-orm"; import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it } from "vitest"; import { users } from "../../shared/schema"; -import { AccessLevel, Theme } from "../../shared/types"; +import { AccessLevel } from "../../shared/access-level"; +import { Theme } from "../../shared/settings"; import { TEST_USER_ID, createTestApp, diff --git a/src/backend/routes/user.ts b/src/backend/routes/user.ts index e8336619a..c7ef100c1 100644 --- a/src/backend/routes/user.ts +++ b/src/backend/routes/user.ts @@ -1,8 +1,10 @@ import { eq } from "drizzle-orm"; -import { cacheMiddleware, getApp } from "../app"; +import { cacheMiddleware } from "../cache"; +import { getApp } from "../context"; import { getDb } from "../db"; import { users } from "../../shared/schema"; -import { type AccessData, type SettingsUpdate } from "../../shared/types"; +import type { AccessData } from "../../shared/access-level"; +import type { SettingsUpdate } from "../../shared/settings"; import { isSignedIn, requireSignInMiddleware } from "../sign-in-utils"; export const userRoutes = getApp(); diff --git a/src/backend/services.ts b/src/backend/services.ts index c01090fd1..15c39f500 100644 --- a/src/backend/services.ts +++ b/src/backend/services.ts @@ -1,8 +1,8 @@ -import { type AppServicesFactory } from "./app"; -import { getCachedUserId, getOnshapeApi, isAuthenticated } from "./auth"; +import type { AppServicesFactory } from "./context"; +import { getCachedUserId, getOnshapeApi, isAuthenticated } from "./auth-oauth"; import { getCachedAccessLevel } from "./access-level-utils"; import { isForceSignedIn, isSignedIn } from "./sign-in-utils"; -import { AccessLevel } from "../shared/types"; +import { AccessLevel } from "../shared/access-level"; /** Stable fake user id used for FORCE_SIGNED_IN testing sessions. */ export const FORCE_SIGNED_IN_USER_ID = "force-signed-in-user"; diff --git a/src/backend/sign-in-utils.ts b/src/backend/sign-in-utils.ts index 61e394f0b..f57adb57b 100644 --- a/src/backend/sign-in-utils.ts +++ b/src/backend/sign-in-utils.ts @@ -2,7 +2,7 @@ import type { MiddlewareHandler } from "hono"; import { HTTPException } from "hono/http-exception"; import { HttpStatus } from "http-status-ts"; import { env } from "process"; -import { type AppContext, type AppContextEnv } from "./app"; +import type { AppContext, AppContextEnv } from "./context"; /** FORCE_SIGNED_IN is a dev-only escape hatch, ignored in production. */ export function isForceSignedIn(c: AppContext): boolean { diff --git a/src/frontend/api-utils/access-level.tsx b/src/frontend/api-utils/access-level.tsx index 7db66d572..0a9f821f7 100644 --- a/src/frontend/api-utils/access-level.tsx +++ b/src/frontend/api-utils/access-level.tsx @@ -1,9 +1,13 @@ import { PropsWithChildren, useMemo } from "react"; import { queryOptions, useQuery } from "@tanstack/react-query"; -import { hasEditorAccess } from "../../shared/types"; -import { hasAdminAccess } from "../../shared/types"; -import { isWithinAccessLevel } from "../../shared/types"; -import { AccessLevel, type AccessData } from "../../shared/types"; +import { + AccessLevel, + type AccessData, + hasAdminAccess, + hasEditorAccess, + isWithinAccessLevel +} from "../../shared/access-level"; +import { accessDataQueryKey } from "../query-keys"; import { apiGet } from "./api"; import { useUiState } from "./ui-state"; @@ -17,10 +21,6 @@ const DEFAULT_ACCESS_LEVEL = (import.meta.env.VITE_DEFAULT_ACCESS_LEVEL as AccessLevel | undefined) ?? AccessLevel.USER; -export function accessDataQueryKey() { - return ["access-data"]; -} - export function getAccessDataQuery() { return queryOptions({ queryKey: accessDataQueryKey(), diff --git a/src/frontend/api-utils/library.ts b/src/frontend/api-utils/library.ts index 411385a4e..a87edafe8 100644 --- a/src/frontend/api-utils/library.ts +++ b/src/frontend/api-utils/library.ts @@ -1,5 +1,5 @@ import { useParams } from "@tanstack/react-router"; -import { DEFAULT_LIBRARY_ID, LibraryId } from "../../shared/types"; +import { DEFAULT_LIBRARY_ID, LibraryId } from "../../shared/library-id"; /** Returns the library being displayed, which the url is the source of truth for. */ export function useLibraryId(): LibraryId { diff --git a/src/frontend/api-utils/onshape-params.ts b/src/frontend/api-utils/onshape-params.ts index 85638cc2f..71337e75c 100644 --- a/src/frontend/api-utils/onshape-params.ts +++ b/src/frontend/api-utils/onshape-params.ts @@ -1,6 +1,6 @@ import { useSearch } from "@tanstack/react-router"; -import { ElementType } from "../../shared/types"; -import { Theme } from "../../shared/types"; +import { ElementType } from "../../shared/element-type"; +import { Theme } from "../../shared/settings"; import { ElementPath, isElementPath } from "../../shared/onshape-path"; /** diff --git a/src/frontend/api-utils/refresh.ts b/src/frontend/api-utils/refresh.ts index e8d0c46a3..b37fc0553 100644 --- a/src/frontend/api-utils/refresh.ts +++ b/src/frontend/api-utils/refresh.ts @@ -1,16 +1,11 @@ import { useCallback, useEffect, useRef } from "react"; import { useRouter } from "@tanstack/react-router"; import { queryClient } from "../query-client"; -import { - buildStatusQueryMatchKey, - favoritesQueryKey, - libraryQueryMatchKey, - libraryVersionQueryMatchKey, - useJobStatusQuery -} from "../queries"; -import { accessDataQueryKey } from "./access-level"; +import { useJobStatusQuery } from "../library-queries"; +import { buildStatusQueryMatchKey, favoritesQueryKey, libraryQueryMatchKey, libraryVersionQueryMatchKey } from "../query-keys"; +import { accessDataQueryKey } from "../query-keys"; import { useLibraryId } from "./library"; -import { type LibraryId } from "../../shared/types"; +import type { LibraryId } from "../../shared/library-id"; /** Refetches the current user's favorites, which aren't version-keyed. */ function refetchFavorites(libraryId: LibraryId): Promise { diff --git a/src/frontend/api-utils/ui-state.ts b/src/frontend/api-utils/ui-state.ts index b09057be6..58fca6142 100644 --- a/src/frontend/api-utils/ui-state.ts +++ b/src/frontend/api-utils/ui-state.ts @@ -1,6 +1,7 @@ import { useSyncExternalStore } from "react"; import * as z from "zod"; -import { AccessLevel, Vendor } from "../../shared/types"; +import { AccessLevel } from "../../shared/access-level"; +import { Vendor } from "../../shared/vendors"; // Increment this when a breaking change is made to the schema const LATEST_VERSION = 3; diff --git a/src/frontend/app/app-navbar.tsx b/src/frontend/app/app-navbar.tsx index fd5928000..1170e769c 100644 --- a/src/frontend/app/app-navbar.tsx +++ b/src/frontend/app/app-navbar.tsx @@ -23,9 +23,9 @@ import { useSaveSettings } from "../settings/settings"; import { useIsSignedIn } from "../api-utils/access-level"; import { startSignIn } from "../api-utils/sign-in"; import { useJobStatus } from "../api-utils/refresh"; -import { LibraryId } from "../../shared/types"; +import { LibraryId } from "../../shared/library-id"; import { queryClient } from "../query-client"; -import { getLibraryVersionQuery } from "../queries"; +import { getLibraryVersionQuery } from "../library-queries"; /** * Provides top-level navigation for the app. A single colored control row holds diff --git a/src/frontend/app/root-error.tsx b/src/frontend/app/root-error.tsx index c79ab1242..2a255aab9 100644 --- a/src/frontend/app/root-error.tsx +++ b/src/frontend/app/root-error.tsx @@ -6,7 +6,7 @@ import { Button } from "@mantine/core"; import { IconHome } from "@tabler/icons-react"; import { IconSize } from "../common/style-constants"; import { ReloadGroupsButton } from "../settings/reload-groups-button"; -import { DEFAULT_LIBRARY_ID } from "../../shared/types"; +import { DEFAULT_LIBRARY_ID } from "../../shared/library-id"; /** * Catch-all error state for when a route below the root fails to load. diff --git a/src/frontend/build-status-queries.ts b/src/frontend/build-status-queries.ts new file mode 100644 index 000000000..3516e7fbc --- /dev/null +++ b/src/frontend/build-status-queries.ts @@ -0,0 +1,31 @@ +import { keepPreviousData, queryOptions, useQuery } from "@tanstack/react-query"; +import { apiGet } from "./api-utils/api"; +import { type LibraryBuildStatus } from "../shared/build-status-dto"; +import { LibraryId } from "../shared/library-id"; +import { useLibraryId } from "./api-utils/library"; +import { useCacheVersion } from "./library-queries"; +import { buildStatusQueryKey } from "./query-keys"; + +export function getBuildStatusQuery( + libraryId: LibraryId, + cacheVersion: number +) { + return queryOptions({ + queryKey: buildStatusQueryKey(libraryId, cacheVersion), + queryFn: () => + apiGet("/build-status/library/" + libraryId, { + cacheId: cacheVersion + }), + // A toggle bumps cacheVersion (and thus this key); keep the old data on + // screen while the new version refetches so the hover card doesn't close. + placeholderData: keepPreviousData, + staleTime: Infinity, + gcTime: Infinity + }); +} + +export function useBuildStatusQuery() { + const libraryId = useLibraryId(); + const cacheVersion = useCacheVersion(); + return useQuery(getBuildStatusQuery(libraryId, cacheVersion)); +} diff --git a/src/frontend/cards/build-status.tsx b/src/frontend/cards/build-status.tsx index 99371f00c..337336b87 100644 --- a/src/frontend/cards/build-status.tsx +++ b/src/frontend/cards/build-status.tsx @@ -37,11 +37,8 @@ import { getIssueSeverity, getMaxSeverity } from "../../shared/build-issues"; -import { - GroupBuildStatus, - InsertableBuildStatus -} from "../../shared/api-models"; -import { getVendorName, Vendor } from "../../shared/types"; +import { GroupBuildStatus, InsertableBuildStatus } from "../../shared/build-status-dto"; +import { getVendorName, Vendor } from "../../shared/vendors"; import { ConfigurationParameter, ParameterType @@ -56,7 +53,8 @@ import { } from "../../shared/configuration-combinations"; import { FontWeight, IconColor, IconSize } from "../common/style-constants"; import { RequireAccessLevel } from "../api-utils/access-level"; -import { useBuildStatusQuery, useJobStatusQuery } from "../queries"; +import { useBuildStatusQuery } from "../build-status-queries"; +import { useJobStatusQuery } from "../library-queries"; import { useSetVisibilityMutation, useToggleInsertAndFastenMutation, diff --git a/src/frontend/cards/card-components.tsx b/src/frontend/cards/card-components.tsx index 2355c64d5..53a8ebf85 100644 --- a/src/frontend/cards/card-components.tsx +++ b/src/frontend/cards/card-components.tsx @@ -20,8 +20,8 @@ import { useInsertMutation, useIsAssemblyInPartStudio } from "../insert/insert-hooks"; -import { InsertableOut } from "../../shared/api-models"; -import { ElementType } from "../../shared/types"; +import { InsertableOut } from "../../shared/library-dto"; +import { ElementType } from "../../shared/element-type"; import { ParameterValues } from "../../shared/configuration-models"; import { useSearch } from "@tanstack/react-router"; diff --git a/src/frontend/cards/card-hooks.ts b/src/frontend/cards/card-hooks.ts index 740d2af25..c5cec3664 100644 --- a/src/frontend/cards/card-hooks.ts +++ b/src/frontend/cards/card-hooks.ts @@ -2,8 +2,9 @@ import { useAccessData } from "../api-utils/access-level"; import { useMutation } from "@tanstack/react-query"; import { modals } from "@mantine/modals"; import { apiPost } from "../api-utils/api"; -import { InsertableOut, LibraryBuildStatus } from "../../shared/api-models"; -import { hasUserAccess } from "../../shared/types"; +import { LibraryBuildStatus } from "../../shared/build-status-dto"; +import { InsertableOut } from "../../shared/library-dto"; +import { hasUserAccess } from "../../shared/access-level"; import { useCallback, useMemo } from "react"; import { showErrorToast, @@ -16,7 +17,8 @@ import { useLibraryId } from "../api-utils/library"; import { getAppErrorHandler } from "../api-utils/errors"; -import { buildStatusQueryKey, useCacheVersion } from "../queries"; +import { useCacheVersion } from "../library-queries"; +import { buildStatusQueryKey } from "../query-keys"; import { useRefreshLibrary } from "../api-utils/refresh"; import { patchQuery } from "../common/utils"; import { useCloseBuildCard } from "./build-status"; diff --git a/src/frontend/cards/insertable-card.tsx b/src/frontend/cards/insertable-card.tsx index 58eb60d0f..9cc8fe36c 100644 --- a/src/frontend/cards/insertable-card.tsx +++ b/src/frontend/cards/insertable-card.tsx @@ -1,11 +1,8 @@ import { encodeCanonicalConfiguration } from "../../shared/canonical-configuration"; import { Menu } from "@mantine/core"; import { PropsWithChildren, ReactNode } from "react"; -import { - Favorite, - getFavoriteForInsertable, - InsertableOut -} from "../../shared/api-models"; +import { Favorite, getFavoriteForInsertable } from "../../shared/favorites-dto"; +import { InsertableOut } from "../../shared/library-dto"; import { ParameterValues } from "../../shared/configuration-models"; import { SearchHit } from "../search/search"; import { @@ -25,7 +22,7 @@ import { import { openCannotDeriveAssemblyAlert } from "../app/alerts"; import { useIsAssemblyInPartStudio } from "../insert/insert-hooks"; import { openInsertMenu } from "../insert/insert-menu"; -import { useFavoritesQuery } from "../queries"; +import { useFavoritesQuery } from "../favorites-queries"; import { RequireSignIn } from "../api-utils/access-level"; import { useIsConnectedToOnshape } from "../api-utils/onshape-params"; diff --git a/src/frontend/configuration-queries.ts b/src/frontend/configuration-queries.ts new file mode 100644 index 000000000..138923676 --- /dev/null +++ b/src/frontend/configuration-queries.ts @@ -0,0 +1,25 @@ +import { useQuery } from "@tanstack/react-query"; +import { apiGet } from "./api-utils/api"; +import { EMPTY_UNIT_INFO, type UnitInfo } from "../shared/configuration-models"; +import { InstancePath } from "../shared/onshape-path"; +import { unitInfoQueryKey } from "./query-keys"; + +/** + * The current document's units. Disabled when not connected to a document, and + * each quantity then falls back to its own unit. + */ +export function useUnitInfoQuery(instancePath: InstancePath, enabled = true) { + return useQuery({ + queryKey: unitInfoQueryKey(instancePath), + queryFn: () => + apiGet("/unit-info", { + query: { + documentId: instancePath.documentId, + instanceId: instancePath.instanceId, + instanceType: instancePath.instanceType + } + }), + enabled, + placeholderData: EMPTY_UNIT_INFO + }); +} diff --git a/src/frontend/favorites-queries.ts b/src/frontend/favorites-queries.ts new file mode 100644 index 000000000..b5948edbf --- /dev/null +++ b/src/frontend/favorites-queries.ts @@ -0,0 +1,26 @@ +import { queryOptions, useQuery } from "@tanstack/react-query"; +import { apiGet } from "./api-utils/api"; +import { type FavoritesData } from "../shared/favorites-dto"; +import { LibraryId } from "../shared/library-id"; +import { useAccessData } from "./api-utils/access-level"; +import { useLibraryId } from "./api-utils/library"; +import { favoritesQueryKey } from "./query-keys"; + +const EMPTY_FAVORITES: FavoritesData = { favorites: {}, favoriteOrder: [] }; + +export function getFavoritesQuery(libraryId: LibraryId, enabled = true) { + return queryOptions({ + queryKey: favoritesQueryKey(libraryId), + queryFn: () => apiGet("/favorites/library/" + libraryId), + enabled, + // Not signed in: the endpoint 401s, so present no favorites. + placeholderData: EMPTY_FAVORITES + }); +} + +export function useFavoritesQuery() { + const libraryId = useLibraryId(); + // Favorites require sign-in; don't fetch (or display) them otherwise. + const signedIn = useAccessData().signedIn; + return useQuery(getFavoritesQuery(libraryId, signedIn)); +} diff --git a/src/frontend/favorites/favorite-button.tsx b/src/frontend/favorites/favorite-button.tsx index 2ea8fd126..ce2d763eb 100644 --- a/src/frontend/favorites/favorite-button.tsx +++ b/src/frontend/favorites/favorite-button.tsx @@ -8,18 +8,15 @@ import { HeartIconColor, IconSize } from "../common/style-constants"; import { useMutation } from "@tanstack/react-query"; import { ReactNode, useState } from "react"; import { apiDelete, apiPost } from "../api-utils/api"; -import { - type Favorite, - type FavoritesData, - type InsertableOut -} from "../../shared/api-models"; -import { LibraryId } from "../../shared/types"; +import type { Favorite, FavoritesData } from "../../shared/favorites-dto"; +import type { InsertableOut } from "../../shared/library-dto"; +import { LibraryId } from "../../shared/library-id"; import { queryClient } from "../query-client"; import { useRouter } from "@tanstack/react-router"; import { handleAppError, HandledError } from "../api-utils/errors"; import { getQueryUpdater } from "../common/utils"; import { toLibraryPath, useLibraryId } from "../api-utils/library"; -import { favoritesQueryKey } from "../queries"; +import { favoritesQueryKey } from "../query-keys"; import { useRefreshFavorites } from "../api-utils/refresh"; enum Operation { diff --git a/src/frontend/favorites/favorite-card.tsx b/src/frontend/favorites/favorite-card.tsx index 9b14c88dd..3b3896c02 100644 --- a/src/frontend/favorites/favorite-card.tsx +++ b/src/frontend/favorites/favorite-card.tsx @@ -1,6 +1,7 @@ import { encodeCanonicalConfiguration } from "../../shared/canonical-configuration"; import { ReactNode } from "react"; -import { InsertableOut, Favorite } from "../../shared/api-models"; +import { Favorite } from "../../shared/favorites-dto"; +import { InsertableOut } from "../../shared/library-dto"; import { useMutation } from "@tanstack/react-query"; import { apiPost } from "../api-utils/api"; import { queryClient } from "../query-client"; @@ -28,7 +29,8 @@ import { openCannotReorderAlert } from "../app/alerts"; import { getAppErrorHandler } from "../api-utils/errors"; -import { favoritesQueryKey, useFavoritesQuery } from "../queries"; +import { useFavoritesQuery } from "../favorites-queries"; +import { favoritesQueryKey } from "../query-keys"; import { useRefreshFavorites } from "../api-utils/refresh"; import { produce } from "immer"; import { SearchHit } from "../search/search"; diff --git a/src/frontend/favorites/favorite-menu.tsx b/src/frontend/favorites/favorite-menu.tsx index da18b8cef..a2b1147c0 100644 --- a/src/frontend/favorites/favorite-menu.tsx +++ b/src/frontend/favorites/favorite-menu.tsx @@ -9,7 +9,7 @@ import { apiPost } from "../api-utils/api"; import { showErrorToast, showSuccessToast } from "../common/notifications"; import { PreviewImageCard } from "../insert/thumbnail"; import { ConfigurationWrapper } from "../insert/configurations"; -import { type FavoritesData } from "../../shared/api-models"; +import type { FavoritesData } from "../../shared/favorites-dto"; import { HeartIcon } from "./favorite-button"; import { queryClient } from "../query-client"; import { @@ -17,11 +17,9 @@ import { SearchRecord } from "../../shared/configuration-models"; import { encodeCanonicalConfiguration } from "../../shared/canonical-configuration"; -import { - favoritesQueryKey, - useFavoritesQuery, - useLibraryQuery -} from "../queries"; +import { useFavoritesQuery } from "../favorites-queries"; +import { useLibraryQuery } from "../library-queries"; +import { favoritesQueryKey } from "../query-keys"; import { getQueryUpdater } from "../common/utils"; import { useLibraryId } from "../api-utils/library"; import { useRefreshFavorites } from "../api-utils/refresh"; diff --git a/src/frontend/favorites/favorites-list.tsx b/src/frontend/favorites/favorites-list.tsx index 0c46e6489..dc1e2745f 100644 --- a/src/frontend/favorites/favorites-list.tsx +++ b/src/frontend/favorites/favorites-list.tsx @@ -3,22 +3,18 @@ import { IconHeartBroken } from "@tabler/icons-react"; import { HeartIconColor, IconSize } from "../common/style-constants"; import { ReactNode } from "react"; import { filterInsertables } from "../search/filter"; -import { - getFavoriteForInsertable, - InsertableOut -} from "../../shared/api-models"; +import { getFavoriteForInsertable } from "../../shared/favorites-dto"; +import { InsertableOut } from "../../shared/library-dto"; import { useUiState } from "../api-utils/ui-state"; import { SectionError, SectionLoading } from "../app-common/app-zero-state"; import { NoSearchResultError, SearchCallout } from "../search/search-errors"; import { FavoriteCard } from "./favorite-card"; import { ItemTable } from "../cards/card-components"; -import { - useFavoritesQuery, - useLibraryQuery, - useSearchDbQuery -} from "../queries"; +import { useFavoritesQuery } from "../favorites-queries"; +import { useLibraryQuery } from "../library-queries"; +import { useSearchDbQuery } from "../search-queries"; import { doSearch, FilterResult, SearchHit } from "../search/search"; -import { hasEditorAccess } from "../../shared/types"; +import { hasEditorAccess } from "../../shared/access-level"; /** * A list of current favorite cards. diff --git a/src/frontend/groups/add-group-menu.tsx b/src/frontend/groups/add-group-menu.tsx index 892ee2131..9fc0c30b4 100644 --- a/src/frontend/groups/add-group-menu.tsx +++ b/src/frontend/groups/add-group-menu.tsx @@ -10,8 +10,8 @@ import { getAppErrorHandler, HandledError } from "../api-utils/errors"; import { showInfoToast, showLoadingToast } from "../common/notifications"; import { queryClient } from "../query-client"; import { toLibraryPath, useLibraryId } from "../api-utils/library"; -import { jobStatusQueryKey } from "../queries"; -import { type JobStatus } from "../../shared/api-models"; +import { jobStatusQueryKey } from "../query-keys"; +import type { JobStatus } from "../../shared/library-dto"; function openAddGroupMenu(selectedGroupId?: string) { modals.open({ diff --git a/src/frontend/groups/group-card.tsx b/src/frontend/groups/group-card.tsx index c399da06e..41c276b77 100644 --- a/src/frontend/groups/group-card.tsx +++ b/src/frontend/groups/group-card.tsx @@ -8,7 +8,7 @@ import { import { IconSize } from "../common/style-constants"; import { useNavigate } from "@tanstack/react-router"; import { PropsWithChildren, ReactNode } from "react"; -import { GroupOut, LibraryOut } from "../../shared/api-models"; +import { GroupOut, LibraryOut } from "../../shared/library-dto"; import { useMutation } from "@tanstack/react-query"; import { apiPost, apiDelete } from "../api-utils/api"; import { showErrorToast } from "../common/notifications"; @@ -25,12 +25,9 @@ import { import { AddGroupItem } from "./add-group-menu"; import { GroupStatusBadge } from "../cards/build-status"; import { useRefreshLibrary } from "../api-utils/refresh"; -import { - libraryQueryKey, - useBuildStatusQuery, - useCacheVersion, - useLibraryQuery -} from "../queries"; +import { useBuildStatusQuery } from "../build-status-queries"; +import { useCacheVersion, useLibraryQuery } from "../library-queries"; +import { libraryQueryKey } from "../query-keys"; import { toLibraryPath, useLibraryId } from "../api-utils/library"; import { getQueryUpdater, useIsHome } from "../common/utils"; diff --git a/src/frontend/insert/configurations.tsx b/src/frontend/insert/configurations.tsx index ceb2e2226..8d1d77a2b 100644 --- a/src/frontend/insert/configurations.tsx +++ b/src/frontend/insert/configurations.tsx @@ -46,7 +46,8 @@ import { valueWithUnits, evaluateExpression } from "../../shared/input-parser"; -import { getConfigurationKey, useUnitInfoQuery } from "../queries"; +import { useUnitInfoQuery } from "../configuration-queries"; +import { configurationQueryKey } from "../query-keys"; import { showErrorToast } from "../common/notifications"; import { SectionError } from "../app-common/app-zero-state"; import { useIsConnectedToOnshape } from "../api-utils/onshape-params"; @@ -78,7 +79,7 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { } = props; const query = useQuery({ - queryKey: getConfigurationKey(configurationId, microversionId), + queryKey: configurationQueryKey(configurationId, microversionId), queryFn: async () => { return apiGet("/configuration/" + configurationId, { cacheId: microversionId diff --git a/src/frontend/insert/insert-hooks.ts b/src/frontend/insert/insert-hooks.ts index 55bdd8ab4..3e01a59d4 100644 --- a/src/frontend/insert/insert-hooks.ts +++ b/src/frontend/insert/insert-hooks.ts @@ -1,8 +1,8 @@ import { useMutation } from "@tanstack/react-query"; import { useSearch } from "@tanstack/react-router"; import { apiPost } from "../api-utils/api"; -import { InsertableOut } from "../../shared/api-models"; -import { ElementType } from "../../shared/types"; +import { InsertableOut } from "../../shared/library-dto"; +import { ElementType } from "../../shared/element-type"; import { type ElementPath } from "../../shared/onshape-path"; import { showLoadingToast, showSuccessToast } from "../common/notifications"; import { queryClient } from "../query-client"; diff --git a/src/frontend/insert/insert-menu.tsx b/src/frontend/insert/insert-menu.tsx index 366938d5a..0da54226e 100644 --- a/src/frontend/insert/insert-menu.tsx +++ b/src/frontend/insert/insert-menu.tsx @@ -1,10 +1,8 @@ import { useSearch } from "@tanstack/react-router"; import { ReactNode, useCallback, useEffect, useState } from "react"; -import { - getFavoriteForInsertable, - InsertableOut -} from "../../shared/api-models"; -import { ElementType } from "../../shared/types"; +import { getFavoriteForInsertable } from "../../shared/favorites-dto"; +import { InsertableOut } from "../../shared/library-dto"; +import { ElementType } from "../../shared/element-type"; import { Button, Checkbox, Group, Stack, Text } from "@mantine/core"; import { IconInfoCircle, IconPlus } from "@tabler/icons-react"; import { FontWeight, IconSize } from "../common/style-constants"; @@ -25,7 +23,7 @@ import { SearchRecord } from "../../shared/configuration-models"; import { encodeCanonicalConfiguration } from "../../shared/canonical-configuration"; -import { useFavoritesQuery } from "../queries"; +import { useFavoritesQuery } from "../favorites-queries"; import { useUiState } from "../api-utils/ui-state"; import { notifications } from "@mantine/notifications"; import { RequireSignIn, useIsSignedIn } from "../api-utils/access-level"; diff --git a/src/frontend/insert/thumbnail.tsx b/src/frontend/insert/thumbnail.tsx index a0b7c157d..cdbb60eab 100644 --- a/src/frontend/insert/thumbnail.tsx +++ b/src/frontend/insert/thumbnail.tsx @@ -1,6 +1,7 @@ import { useIsFetching, useQuery } from "@tanstack/react-query"; import { loadImage, loadImageResult } from "../api-utils/api"; -import { ThumbnailSize, ElementType } from "../../shared/types"; +import { ElementType } from "../../shared/element-type"; +import { ThumbnailSize } from "../../shared/thumbnail-types"; import { ElementPath } from "../../shared/onshape-path"; import { Box, Card, Center, HoverCard, Loader } from "@mantine/core"; import { IconHelp } from "@tabler/icons-react"; @@ -8,7 +9,7 @@ import { IconHelp } from "@tabler/icons-react"; import { ComponentPropsWithRef, ReactNode } from "react"; import { DEFAULT_CANONICAL_CONFIGURATION } from "../../shared/canonical-configuration"; import { thumbnailUrl } from "../../shared/thumbnails"; -import { getConfigurationMatchKey } from "../queries"; +import { configurationQueryMatchKey } from "../query-keys"; import { SectionError } from "../app-common/app-zero-state"; import { useTargetElementType } from "./insert-hooks"; import { useIsSignedIn } from "../api-utils/access-level"; @@ -183,7 +184,7 @@ export function PreviewImage(props: PreviewImageProps): ReactNode { const isSignedIn = useIsSignedIn(); const isConnected = useIsConnectedToOnshape(); const isFetchingConfiguration = - useIsFetching({ queryKey: getConfigurationMatchKey() }) > 0; + useIsFetching({ queryKey: configurationQueryMatchKey() }) > 0; const targetElementType = useTargetElementType(); const url = thumbnailUrl({ diff --git a/src/frontend/library-queries.ts b/src/frontend/library-queries.ts new file mode 100644 index 000000000..a845eb5ef --- /dev/null +++ b/src/frontend/library-queries.ts @@ -0,0 +1,101 @@ +/** Queries for the library snapshot, its cache version, and its load jobs. */ +import { queryOptions, useQuery } from "@tanstack/react-query"; +import { apiGet } from "./api-utils/api"; +import { type JobStatus, type LibraryOut } from "../shared/library-dto"; +import { hasEditorAccess } from "../shared/access-level"; +import { LibraryId } from "../shared/library-id"; +import { useAccessData } from "./api-utils/access-level"; +import { toLibraryPath, useLibraryId } from "./api-utils/library"; +import { + jobStatusQueryKey, + libraryQueryKey, + libraryVersionQueryKey +} from "./query-keys"; + +export function getLibraryQuery(libraryId: LibraryId, cacheVersion: number) { + return queryOptions({ + queryKey: libraryQueryKey(libraryId, cacheVersion), + queryFn: async () => + apiGet("/library-data/library/" + libraryId, { + cacheId: cacheVersion + }), + staleTime: Infinity, + gcTime: Infinity + }); +} + +export function useLibraryQuery() { + const libraryId = useLibraryId(); + const cacheVersion = useCacheVersion(); + return useQuery(getLibraryQuery(libraryId, cacheVersion)); +} + +/** A library's cache version, which keys the `?v=` on every request for it. */ +export function getLibraryVersionQuery(libraryId: LibraryId) { + return queryOptions({ + queryKey: libraryVersionQueryKey(libraryId), + queryFn: () => + apiGet("/library-version" + toLibraryPath(libraryId)).then( + (result: { version: number }) => result.version + ), + // Bumps arrive through the explicit refresh flows, which refetch this. + staleTime: Infinity + }); +} + +/** The displayed library's cache version, which keys its immutable responses. */ +export function useCacheVersion(): number { + const libraryId = useLibraryId(); + // Loaded by the library route before anything reading this renders. + return useQuery(getLibraryVersionQuery(libraryId)).data ?? 0; +} + +/** Poll a fresh job often, then back off: a full reload runs for hours. */ +const FASTEST_POLL_MS = 3_000; +const POLL_STEPS = [ + { untilMs: 15_000, intervalMs: FASTEST_POLL_MS }, + { untilMs: 75_000, intervalMs: 5_000 } +]; +const SLOWEST_POLL_MS = 10_000; + +function jobPollInterval(runningForMs: number): number { + const step = POLL_STEPS.find(({ untilMs }) => runningForMs < untilMs); + return step?.intervalMs ?? SLOWEST_POLL_MS; +} + +/** + * Checked once on load, then polled while something runs and left alone when a + * check comes back idle. `canPoll` is the caller's gate: the route is editor-only. + */ +export function getJobStatusQuery(libraryId: LibraryId, canPoll: boolean) { + return queryOptions({ + queryKey: jobStatusQueryKey(libraryId), + queryFn: () => apiGet("/job-status/library/" + libraryId), + enabled: canPoll, + // Every status badge observes this, so rows mounting as the user scrolls + // would each trigger a fetch. Only the poll should set the pace. + staleTime: FASTEST_POLL_MS, + refetchInterval: (query) => { + const status = query.state.data; + if (!status?.running) { + return false; + } + return jobPollInterval(status.runningForMs); + } + }); +} + +/** + * Job status for the current library. The endpoint is editor-only and needs an + * Onshape session, so callers who have neither don't poll it at all. + */ +export function useJobStatusQuery() { + const libraryId = useLibraryId(); + const { signedIn, currentAccessLevel } = useAccessData(); + return useQuery( + getJobStatusQuery( + libraryId, + signedIn && hasEditorAccess(currentAccessLevel) + ) + ); +} diff --git a/src/frontend/queries.ts b/src/frontend/queries.ts deleted file mode 100644 index 300b19a99..000000000 --- a/src/frontend/queries.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * Queries for getting data from various endpoints on the backend. - */ -import { - keepPreviousData, - queryOptions, - useQuery -} from "@tanstack/react-query"; -import { apiGet, apiGetText } from "./api-utils/api"; -import { - type FavoritesData, - type JobStatus, - type LibraryBuildStatus, - type LibraryOut -} from "../shared/api-models"; -import { hasEditorAccess, LibraryId } from "../shared/types"; -import { useAccessData } from "./api-utils/access-level"; -import { toLibraryPath, useLibraryId } from "./api-utils/library"; -import { EMPTY_UNIT_INFO, type UnitInfo } from "../shared/configuration-models"; -import MiniSearch from "minisearch"; -import { SEARCH_OPTIONS } from "../shared/search"; -import { InstancePath } from "../shared/onshape-path"; - -export function getConfigurationMatchKey() { - return ["configuration"]; -} - -export function getConfigurationKey( - configurationId?: string, - microversionId?: string -) { - return ["configuration", configurationId, microversionId]; -} - -export function libraryQueryKey(libraryId: LibraryId, cacheVersion: number) { - return ["library", libraryId, cacheVersion]; -} - -export function libraryQueryMatchKey() { - return ["library"]; -} - -export function getLibraryQuery(libraryId: LibraryId, cacheVersion: number) { - return queryOptions({ - queryKey: libraryQueryKey(libraryId, cacheVersion), - queryFn: async () => - apiGet("/library-data/library/" + libraryId, { - cacheId: cacheVersion - }), - staleTime: Infinity, - gcTime: Infinity - }); -} - -export function useLibraryQuery() { - const libraryId = useLibraryId(); - const cacheVersion = useCacheVersion(); - return useQuery(getLibraryQuery(libraryId, cacheVersion)); -} - -export function libraryVersionQueryMatchKey() { - return ["library-version"]; -} - -export function libraryVersionQueryKey(libraryId: LibraryId) { - return ["library-version", libraryId]; -} - -/** A library's cache version, which keys the `?v=` on every request for it. */ -export function getLibraryVersionQuery(libraryId: LibraryId) { - return queryOptions({ - queryKey: libraryVersionQueryKey(libraryId), - queryFn: () => - apiGet("/library-version" + toLibraryPath(libraryId)).then( - (result: { version: number }) => result.version - ), - // Bumps arrive through the explicit refresh flows, which refetch this. - staleTime: Infinity - }); -} - -/** The displayed library's cache version, which keys its immutable responses. */ -export function useCacheVersion(): number { - const libraryId = useLibraryId(); - // Loaded by the library route before anything reading this renders. - return useQuery(getLibraryVersionQuery(libraryId)).data ?? 0; -} - -/** - * The current document's units. Disabled when not connected to a document, and - * each quantity then falls back to its own unit. - */ -export function useUnitInfoQuery(instancePath: InstancePath, enabled = true) { - return useQuery({ - queryKey: ["unit-info", instancePath], - queryFn: () => - apiGet("/unit-info", { - query: { - documentId: instancePath.documentId, - instanceId: instancePath.instanceId, - instanceType: instancePath.instanceType - } - }), - enabled, - placeholderData: EMPTY_UNIT_INFO - }); -} - -export function searchDbQueryMatchKey() { - return ["search-db"]; -} - -export function searchDbQueryKey(libraryId: LibraryId, cacheVersion: number) { - return ["search-db", libraryId, cacheVersion]; -} - -export function getSearchDbQuery(libraryId: LibraryId, cacheVersion: number) { - return queryOptions({ - queryKey: searchDbQueryKey(libraryId, cacheVersion), - queryFn: async () => { - const searchDb = await apiGetText( - "/search-db" + toLibraryPath(libraryId), - { - cacheId: cacheVersion - } - ); - if (!searchDb) { - return null; - } - return MiniSearch.loadJSON(searchDb, SEARCH_OPTIONS); - }, - staleTime: Infinity, - gcTime: Infinity - }); -} - -export function useSearchDbQuery() { - const libraryId = useLibraryId(); - const cacheVersion = useCacheVersion(); - return useQuery(getSearchDbQuery(libraryId, cacheVersion)); -} - -export function favoritesQueryKey(libraryId: LibraryId) { - return ["favorites", libraryId]; -} - -const EMPTY_FAVORITES: FavoritesData = { favorites: {}, favoriteOrder: [] }; - -export function getFavoritesQuery(libraryId: LibraryId, enabled = true) { - return queryOptions({ - queryKey: favoritesQueryKey(libraryId), - queryFn: () => apiGet("/favorites/library/" + libraryId), - enabled, - // Not signed in: the endpoint 401s, so present no favorites. - placeholderData: EMPTY_FAVORITES - }); -} - -export function buildStatusQueryMatchKey() { - return ["build-status"]; -} - -export function buildStatusQueryKey( - libraryId: LibraryId, - cacheVersion: number -) { - return ["build-status", libraryId, cacheVersion]; -} - -export function getBuildStatusQuery( - libraryId: LibraryId, - cacheVersion: number -) { - return queryOptions({ - queryKey: buildStatusQueryKey(libraryId, cacheVersion), - queryFn: () => - apiGet("/build-status/library/" + libraryId, { - cacheId: cacheVersion - }), - // A toggle bumps cacheVersion (and thus this key); keep the old data on - // screen while the new version refetches so the hover card doesn't close. - placeholderData: keepPreviousData, - staleTime: Infinity, - gcTime: Infinity - }); -} - -export function useBuildStatusQuery() { - const libraryId = useLibraryId(); - const cacheVersion = useCacheVersion(); - return useQuery(getBuildStatusQuery(libraryId, cacheVersion)); -} - -export function useFavoritesQuery() { - const libraryId = useLibraryId(); - // Favorites require sign-in; don't fetch (or display) them otherwise. - const signedIn = useAccessData().signedIn; - return useQuery(getFavoritesQuery(libraryId, signedIn)); -} - -export function jobStatusQueryMatchKey() { - return ["job-status"]; -} - -export function jobStatusQueryKey(libraryId: LibraryId) { - return ["job-status", libraryId]; -} - -/** Poll a fresh job often, then back off: a full reload runs for hours. */ -const FASTEST_POLL_MS = 3_000; -const POLL_STEPS = [ - { untilMs: 15_000, intervalMs: FASTEST_POLL_MS }, - { untilMs: 75_000, intervalMs: 5_000 } -]; -const SLOWEST_POLL_MS = 10_000; - -function jobPollInterval(runningForMs: number): number { - const step = POLL_STEPS.find(({ untilMs }) => runningForMs < untilMs); - return step?.intervalMs ?? SLOWEST_POLL_MS; -} - -/** - * Checked once on load, then polled while something runs and left alone when a - * check comes back idle. `canPoll` is the caller's gate: the route is editor-only. - */ -export function getJobStatusQuery(libraryId: LibraryId, canPoll: boolean) { - return queryOptions({ - queryKey: jobStatusQueryKey(libraryId), - queryFn: () => apiGet("/job-status/library/" + libraryId), - enabled: canPoll, - // Every status badge observes this, so rows mounting as the user scrolls - // would each trigger a fetch. Only the poll should set the pace. - staleTime: FASTEST_POLL_MS, - refetchInterval: (query) => { - const status = query.state.data; - if (!status?.running) { - return false; - } - return jobPollInterval(status.runningForMs); - } - }); -} - -/** - * Job status for the current library. The endpoint is editor-only and needs an - * Onshape session, so callers who have neither don't poll it at all. - */ -export function useJobStatusQuery() { - const libraryId = useLibraryId(); - const { signedIn, currentAccessLevel } = useAccessData(); - return useQuery( - getJobStatusQuery( - libraryId, - signedIn && hasEditorAccess(currentAccessLevel) - ) - ); -} diff --git a/src/frontend/query-keys.ts b/src/frontend/query-keys.ts new file mode 100644 index 000000000..0b1071729 --- /dev/null +++ b/src/frontend/query-keys.ts @@ -0,0 +1,72 @@ +/** + * Every query key in one place: features read their own keys here, and the + * cross-feature refresh flows invalidate by the match keys. + */ +import { LibraryId } from "../shared/library-id"; +import { InstancePath } from "../shared/onshape-path"; + +export function accessDataQueryKey() { + return ["access-data"]; +} + +export function configurationQueryMatchKey() { + return ["configuration"]; +} + +export function configurationQueryKey( + configurationId?: string, + microversionId?: string +) { + return ["configuration", configurationId, microversionId]; +} + +export function unitInfoQueryKey(instancePath: InstancePath) { + return ["unit-info", instancePath]; +} + +export function libraryQueryMatchKey() { + return ["library"]; +} + +export function libraryQueryKey(libraryId: LibraryId, cacheVersion: number) { + return ["library", libraryId, cacheVersion]; +} + +export function libraryVersionQueryMatchKey() { + return ["library-version"]; +} + +export function libraryVersionQueryKey(libraryId: LibraryId) { + return ["library-version", libraryId]; +} + +export function searchDbQueryMatchKey() { + return ["search-db"]; +} + +export function searchDbQueryKey(libraryId: LibraryId, cacheVersion: number) { + return ["search-db", libraryId, cacheVersion]; +} + +export function favoritesQueryKey(libraryId: LibraryId) { + return ["favorites", libraryId]; +} + +export function buildStatusQueryMatchKey() { + return ["build-status"]; +} + +export function buildStatusQueryKey( + libraryId: LibraryId, + cacheVersion: number +) { + return ["build-status", libraryId, cacheVersion]; +} + +export function jobStatusQueryMatchKey() { + return ["job-status"]; +} + +export function jobStatusQueryKey(libraryId: LibraryId) { + return ["job-status", libraryId]; +} diff --git a/src/frontend/routes/__root.tsx b/src/frontend/routes/__root.tsx index 33ca68e6a..822a5d300 100644 --- a/src/frontend/routes/__root.tsx +++ b/src/frontend/routes/__root.tsx @@ -12,7 +12,8 @@ import { ReactNode, useMemo } from "react"; import { queryClient } from "../query-client"; import { createAppTheme } from "../theme"; import { getColorTheme } from "../api-utils/onshape-params"; -import { DEFAULT_LIBRARY_ID, DEFAULT_SETTINGS } from "../../shared/types"; +import { DEFAULT_LIBRARY_ID } from "../../shared/library-id"; +import { DEFAULT_SETTINGS } from "../../shared/settings"; import { NotFoundError, RootCrash } from "../app/root-error"; export const Route = createRootRoute({ diff --git a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx index 9386c2cf8..8e36ac94a 100644 --- a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx +++ b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx @@ -19,8 +19,8 @@ import { } from "../../../../../common/style-constants"; import { ReactNode } from "react"; import { SearchResults } from "../../../../../search/search-results"; -import { GroupOut, Insertables } from "../../../../../../shared/api-models"; -import { hasEditorAccess } from "../../../../../../shared/types"; +import { GroupOut, Insertables } from "../../../../../../shared/library-dto"; +import { hasEditorAccess } from "../../../../../../shared/access-level"; import { filterInsertables } from "../../../../../search/filter"; import { GroupMenuItems } from "../../../../../groups/group-card"; import { InsertableCard } from "../../../../../cards/insertable-card"; @@ -33,7 +33,7 @@ import { SectionLoading } from "../../../../../app-common/app-zero-state"; import { ClearFiltersButton } from "../../../../../settings/vendor-filters"; -import { useLibraryQuery } from "../../../../../queries"; +import { useLibraryQuery } from "../../../../../library-queries"; import { useLibraryId } from "../../../../../api-utils/library"; import { useUiState, updateUiState } from "../../../../../api-utils/ui-state"; diff --git a/src/frontend/routes/app/library/$libraryId/index.tsx b/src/frontend/routes/app/library/$libraryId/index.tsx index 3b0085db9..7d1781dba 100644 --- a/src/frontend/routes/app/library/$libraryId/index.tsx +++ b/src/frontend/routes/app/library/$libraryId/index.tsx @@ -18,7 +18,7 @@ import { import { RequireAccessLevel } from "../../../../api-utils/access-level"; import { AddGroupButton } from "../../../../groups/add-group-menu"; import { FavoritesList } from "../../../../favorites/favorites-list"; -import { useLibraryQuery } from "../../../../queries"; +import { useLibraryQuery } from "../../../../library-queries"; import { getLibraryName, useLibraryId } from "../../../../api-utils/library"; import { updateUiState, useUiState } from "../../../../api-utils/ui-state"; import { useIsSignedIn } from "../../../../api-utils/access-level"; diff --git a/src/frontend/routes/app/library/$libraryId/route.tsx b/src/frontend/routes/app/library/$libraryId/route.tsx index 6433d627a..2487c8d87 100644 --- a/src/frontend/routes/app/library/$libraryId/route.tsx +++ b/src/frontend/routes/app/library/$libraryId/route.tsx @@ -1,13 +1,10 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { queryClient } from "../../../../query-client"; import { getAccessDataQuery } from "../../../../api-utils/access-level"; -import { - getFavoritesQuery, - getLibraryQuery, - getLibraryVersionQuery, - getSearchDbQuery -} from "../../../../queries"; -import { DEFAULT_LIBRARY_ID, LibraryId } from "../../../../../shared/types"; +import { getFavoritesQuery } from "../../../../favorites-queries"; +import { getLibraryQuery, getLibraryVersionQuery } from "../../../../library-queries"; +import { getSearchDbQuery } from "../../../../search-queries"; +import { DEFAULT_LIBRARY_ID, LibraryId } from "../../../../../shared/library-id"; import { getUiState } from "../../../../api-utils/ui-state"; /** Restoring the last group is an entry behavior, so it happens once per load. */ diff --git a/src/frontend/search-queries.ts b/src/frontend/search-queries.ts new file mode 100644 index 000000000..1fec0d017 --- /dev/null +++ b/src/frontend/search-queries.ts @@ -0,0 +1,34 @@ +import { queryOptions, useQuery } from "@tanstack/react-query"; +import MiniSearch from "minisearch"; +import { apiGetText } from "./api-utils/api"; +import { LibraryId } from "../shared/library-id"; +import { SEARCH_OPTIONS } from "../shared/search"; +import { toLibraryPath, useLibraryId } from "./api-utils/library"; +import { useCacheVersion } from "./library-queries"; +import { searchDbQueryKey } from "./query-keys"; + +export function getSearchDbQuery(libraryId: LibraryId, cacheVersion: number) { + return queryOptions({ + queryKey: searchDbQueryKey(libraryId, cacheVersion), + queryFn: async () => { + const searchDb = await apiGetText( + "/search-db" + toLibraryPath(libraryId), + { + cacheId: cacheVersion + } + ); + if (!searchDb) { + return null; + } + return MiniSearch.loadJSON(searchDb, SEARCH_OPTIONS); + }, + staleTime: Infinity, + gcTime: Infinity + }); +} + +export function useSearchDbQuery() { + const libraryId = useLibraryId(); + const cacheVersion = useCacheVersion(); + return useQuery(getSearchDbQuery(libraryId, cacheVersion)); +} diff --git a/src/frontend/search/filter.ts b/src/frontend/search/filter.ts index 01a658d31..62d347d87 100644 --- a/src/frontend/search/filter.ts +++ b/src/frontend/search/filter.ts @@ -1,5 +1,5 @@ -import { InsertableOut } from "../../shared/api-models"; -import { Vendor } from "../../shared/types"; +import { InsertableOut } from "../../shared/library-dto"; +import { Vendor } from "../../shared/vendors"; import { FilterResult } from "./search"; export interface FilterArgs { diff --git a/src/frontend/search/search-results.tsx b/src/frontend/search/search-results.tsx index 9291db8ba..4c94076e7 100644 --- a/src/frontend/search/search-results.tsx +++ b/src/frontend/search/search-results.tsx @@ -5,8 +5,9 @@ import { InsertableCard } from "../cards/insertable-card"; import { ItemTable } from "../cards/card-components"; import { SectionError, SectionLoading } from "../app-common/app-zero-state"; import { NoSearchResultError, SearchCallout } from "./search-errors"; -import { useLibraryQuery, useSearchDbQuery } from "../queries"; -import { hasEditorAccess } from "../../shared/types"; +import { useLibraryQuery } from "../library-queries"; +import { useSearchDbQuery } from "../search-queries"; +import { hasEditorAccess } from "../../shared/access-level"; interface SearchResultsProps { query: string; diff --git a/src/frontend/search/search.test.ts b/src/frontend/search/search.test.ts index 5e3772e37..5ffebaaa0 100644 --- a/src/frontend/search/search.test.ts +++ b/src/frontend/search/search.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; import { buildSearchDb, processTerm, tokenize } from "../../shared/search"; import { doSearch, type Position } from "./search"; -import { LibraryOut } from "../../shared/api-models"; -import { ElementType } from "../../shared/types"; +import { LibraryOut } from "../../shared/library-dto"; +import { ElementType } from "../../shared/element-type"; import { ConfigurationRecord, ParameterValues diff --git a/src/frontend/search/search.ts b/src/frontend/search/search.ts index 344136417..a2eff1df3 100644 --- a/src/frontend/search/search.ts +++ b/src/frontend/search/search.ts @@ -1,5 +1,5 @@ import MiniSearch, { SearchResult as MiniSearchResult } from "minisearch"; -import { Vendor } from "../../shared/types"; +import { Vendor } from "../../shared/vendors"; import { SearchDocument, normalizeForMatch } from "../../shared/search"; import { ParameterValues, diff --git a/src/frontend/settings/local-settings.ts b/src/frontend/settings/local-settings.ts index 2f8fd6d88..b7cc8f5aa 100644 --- a/src/frontend/settings/local-settings.ts +++ b/src/frontend/settings/local-settings.ts @@ -1,10 +1,5 @@ -import { - DEFAULT_LIBRARY_ID, - DEFAULT_SETTINGS, - type LibraryId, - type SettingsUpdate, - type Theme -} from "../../shared/types"; +import { DEFAULT_LIBRARY_ID, type LibraryId } from "../../shared/library-id"; +import { DEFAULT_SETTINGS, type SettingsUpdate, type Theme } from "../../shared/settings"; const SETTINGS_STORAGE_KEY = "frc-design-app-settings"; diff --git a/src/frontend/settings/reload-groups-button.tsx b/src/frontend/settings/reload-groups-button.tsx index 8a3ff90df..c648638f7 100644 --- a/src/frontend/settings/reload-groups-button.tsx +++ b/src/frontend/settings/reload-groups-button.tsx @@ -9,8 +9,8 @@ import { apiPost } from "../api-utils/api"; import { queryClient } from "../query-client"; import { getAppErrorHandler } from "../api-utils/errors"; import { toLibraryPath, useLibraryId } from "../api-utils/library"; -import { jobStatusQueryKey } from "../queries"; -import { type JobStatus } from "../../shared/api-models"; +import { jobStatusQueryKey } from "../query-keys"; +import type { JobStatus } from "../../shared/library-dto"; interface ReloadGroupsButtonProps { reloadAll?: boolean; diff --git a/src/frontend/settings/settings-menu.tsx b/src/frontend/settings/settings-menu.tsx index 97654ea5b..6ee47a512 100644 --- a/src/frontend/settings/settings-menu.tsx +++ b/src/frontend/settings/settings-menu.tsx @@ -1,13 +1,13 @@ import { useNavigate, useRouterState } from "@tanstack/react-router"; -import { DEFAULT_SETTINGS } from "../../shared/types"; +import { DEFAULT_SETTINGS } from "../../shared/settings"; import { Divider, Group, Text, Title } from "@mantine/core"; import { modals } from "@mantine/modals"; import { FontWeight } from "../common/style-constants"; import { Dispatch, ReactNode, useMemo } from "react"; -import { Theme } from "../../shared/types"; -import { hasEditorAccess } from "../../shared/types"; -import { isWithinAccessLevel } from "../../shared/types"; -import { AccessLevel } from "../../shared/types"; +import { Theme } from "../../shared/settings"; +import { hasEditorAccess } from "../../shared/access-level"; +import { isWithinAccessLevel } from "../../shared/access-level"; +import { AccessLevel } from "../../shared/access-level"; import { useSaveSettings } from "./settings"; import { capitalize } from "../common/utils"; import { OpenUrlButton } from "../common/open-url-button"; diff --git a/src/frontend/settings/settings.ts b/src/frontend/settings/settings.ts index c2b56cfc8..fb2d2ad22 100644 --- a/src/frontend/settings/settings.ts +++ b/src/frontend/settings/settings.ts @@ -1,5 +1,5 @@ import { useMutation } from "@tanstack/react-query"; -import { type SettingsUpdate } from "../../shared/types"; +import type { SettingsUpdate } from "../../shared/settings"; import { showErrorToast } from "../common/notifications"; import { apiPost } from "../api-utils/api"; import { useIsSignedIn } from "../api-utils/access-level"; diff --git a/src/frontend/settings/vendor-filters.tsx b/src/frontend/settings/vendor-filters.tsx index a7648270a..ffedfac2e 100644 --- a/src/frontend/settings/vendor-filters.tsx +++ b/src/frontend/settings/vendor-filters.tsx @@ -2,8 +2,8 @@ import { ActionIcon, Button, Menu } from "@mantine/core"; import { IconFilter, IconFilterOff } from "@tabler/icons-react"; import { HEADER_CONTROL_COLOR, IconSize } from "../common/style-constants"; import { ReactNode } from "react"; -import { getVendorName } from "../../shared/types"; -import { Vendor } from "../../shared/types"; +import { getVendorName } from "../../shared/vendors"; +import { Vendor } from "../../shared/vendors"; import { useUiState } from "../api-utils/ui-state"; import { AppContextMenu } from "../app-common/app-menu"; diff --git a/src/frontend/theme.ts b/src/frontend/theme.ts index 6935409f8..206e8094c 100644 --- a/src/frontend/theme.ts +++ b/src/frontend/theme.ts @@ -1,5 +1,5 @@ import { createTheme, type MantineColorsTuple } from "@mantine/core"; -import { LibraryId } from "../shared/types"; +import { LibraryId } from "../shared/library-id"; /** * FRCDesign brand green ramp (index 6 = #4cae4f, the brand color). diff --git a/src/shared/access-level.ts b/src/shared/access-level.ts new file mode 100644 index 000000000..4693853f7 --- /dev/null +++ b/src/shared/access-level.ts @@ -0,0 +1,43 @@ +/** The permission tiers the app grants, and the predicates routes gate on. */ +export enum AccessLevel { + ADMIN = "admin", + EDITOR = "editor", + USER = "user" +} + +export function hasAdminAccess(accessLevel: AccessLevel) { + return accessLevel === AccessLevel.ADMIN; +} + +export function hasEditorAccess(accessLevel: AccessLevel) { + return ( + accessLevel === AccessLevel.ADMIN || accessLevel === AccessLevel.EDITOR + ); +} + +export function hasUserAccess(accessLevel: AccessLevel) { + return accessLevel === AccessLevel.USER; +} + +const ACCESS_LEVEL_RANK: Record = { + [AccessLevel.USER]: 0, + [AccessLevel.EDITOR]: 1, + [AccessLevel.ADMIN]: 2 +}; + +/** Whether `accessLevel` grants no more than `maxAccessLevel` does. */ +export function isWithinAccessLevel( + accessLevel: AccessLevel, + maxAccessLevel: AccessLevel +): boolean { + return ACCESS_LEVEL_RANK[accessLevel] <= ACCESS_LEVEL_RANK[maxAccessLevel]; +} + +/** + * Server-provided access: the highest level granted plus sign-in state. The + * level the app is currently viewed as is client-side (see useAccessData). + */ +export interface AccessData { + maxAccessLevel: AccessLevel; + signedIn: boolean; +} diff --git a/src/shared/api-models.ts b/src/shared/api-models.ts deleted file mode 100644 index 802df5856..000000000 --- a/src/shared/api-models.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { ElementPath, InstancePath } from "./onshape-path"; -import { - ParameterValues, - ConfigurationParameter -} from "./configuration-models"; -import { ElementType, LibraryId, Vendor } from "./types"; -import { BuildIssue } from "./build-issues"; - -export interface InsertableOut { - id: string; - elementId: string; - groupId: string; - documentId: string; - versionId: string; - path: ElementPath; - name: string; - microversionId: string; - isVisible: boolean; - supportsFasten: boolean; - elementType: ElementType; - smallThumbnailUrl?: string; - largeThumbnailUrl?: string; - configurationId?: string; - vendors: Vendor[]; -} - -export interface GroupOut { - id: string; - documentId: string; - path: InstancePath; - name: string; - smallThumbnailUrl?: string; - largeThumbnailUrl?: string; - insertableOrder: string[]; -} - -export interface ConfigurationBuildStatus { - buildIssues: BuildIssue[]; - parameters: ConfigurationParameter[]; -} - -export interface GroupBuildStatus { - buildIssues: BuildIssue[]; - sortAlphabetically: boolean; - insertableOrder: string[]; - /** When this group was last successfully loaded (epoch ms); null if never. */ - lastLoadedAt: number | null; -} - -export interface InsertableBuildStatus { - buildIssues: BuildIssue[]; - elementType: ElementType; - isVisible: boolean; - supportsFasten: boolean; - indexConfigurations: boolean; - vendors: Vendor[]; - configuration?: ConfigurationBuildStatus; - /** When this insertable was last successfully loaded (epoch ms); null if never. */ - lastLoadedAt: number | null; -} - -/** Whether a library-load job is running, and how long it has been going. */ -/** Milliseconds since the oldest running job started paces the client's polling. */ -export type JobStatus = - | { running: false } - | { running: true; runningForMs: number }; - -export interface LibraryBuildStatus { - groups: Record; - insertables: Record; -} - -export type Insertables = Record; -export type Groups = Record; - -export interface LibraryOut { - groupOrder: string[]; - groups: Groups; - insertables: Insertables; -} - -export interface Favorite { - id: string; - insertableId: string; - libraryId: LibraryId; - defaultConfiguration?: ParameterValues; -} - -export interface FavoritesData { - favorites: Record; - favoriteOrder: string[]; -} - -export function getFavoriteForInsertable( - favorites: Record, - insertableId: string -): Favorite | undefined { - for (const fav of Object.values(favorites)) { - if (fav.insertableId === insertableId) return fav; - } - return undefined; -} diff --git a/src/shared/build-status-dto.ts b/src/shared/build-status-dto.ts new file mode 100644 index 000000000..a1484da7c --- /dev/null +++ b/src/shared/build-status-dto.ts @@ -0,0 +1,34 @@ +import { BuildIssue } from "./build-issues"; +import { ConfigurationParameter } from "./configuration-models"; +import { ElementType } from "./element-type"; +import { Vendor } from "./vendors"; + +export interface ConfigurationBuildStatus { + buildIssues: BuildIssue[]; + parameters: ConfigurationParameter[]; +} + +export interface GroupBuildStatus { + buildIssues: BuildIssue[]; + sortAlphabetically: boolean; + insertableOrder: string[]; + /** When this group was last successfully loaded (epoch ms); null if never. */ + lastLoadedAt: number | null; +} + +export interface InsertableBuildStatus { + buildIssues: BuildIssue[]; + elementType: ElementType; + isVisible: boolean; + supportsFasten: boolean; + indexConfigurations: boolean; + vendors: Vendor[]; + configuration?: ConfigurationBuildStatus; + /** When this insertable was last successfully loaded (epoch ms); null if never. */ + lastLoadedAt: number | null; +} + +export interface LibraryBuildStatus { + groups: Record; + insertables: Record; +} diff --git a/src/shared/element-type.ts b/src/shared/element-type.ts new file mode 100644 index 000000000..f1771e544 --- /dev/null +++ b/src/shared/element-type.ts @@ -0,0 +1,7 @@ +/** + * The type of the Onshape tab the app is open in. + */ +export enum ElementType { + PART_STUDIO = "PARTSTUDIO", + ASSEMBLY = "ASSEMBLY" +} diff --git a/src/shared/fasten.ts b/src/shared/fasten.ts new file mode 100644 index 000000000..0a91ad16b --- /dev/null +++ b/src/shared/fasten.ts @@ -0,0 +1,12 @@ +/** Where an insertable's fasten mate connector lives inside its element. */ +export enum MateLocation { + Feature = "Feature", + Part = "Part", + Subassembly = "Subassembly" +} + +export interface FastenInfo { + mateConnectorId: string; + mateLocation: MateLocation; + path: string[]; +} diff --git a/src/shared/favorites-dto.ts b/src/shared/favorites-dto.ts new file mode 100644 index 000000000..e7c76745a --- /dev/null +++ b/src/shared/favorites-dto.ts @@ -0,0 +1,24 @@ +import { ParameterValues } from "./configuration-models"; +import { LibraryId } from "./library-id"; + +export interface Favorite { + id: string; + insertableId: string; + libraryId: LibraryId; + defaultConfiguration?: ParameterValues; +} + +export interface FavoritesData { + favorites: Record; + favoriteOrder: string[]; +} + +export function getFavoriteForInsertable( + favorites: Record, + insertableId: string +): Favorite | undefined { + for (const fav of Object.values(favorites)) { + if (fav.insertableId === insertableId) return fav; + } + return undefined; +} diff --git a/src/shared/library-dto.ts b/src/shared/library-dto.ts new file mode 100644 index 000000000..11bf63f77 --- /dev/null +++ b/src/shared/library-dto.ts @@ -0,0 +1,49 @@ +import { ElementPath, InstancePath } from "./onshape-path"; +import { ElementType } from "./element-type"; +import { Vendor } from "./vendors"; + +export interface InsertableOut { + id: string; + elementId: string; + groupId: string; + documentId: string; + versionId: string; + path: ElementPath; + name: string; + microversionId: string; + isVisible: boolean; + supportsFasten: boolean; + elementType: ElementType; + smallThumbnailUrl?: string; + largeThumbnailUrl?: string; + configurationId?: string; + vendors: Vendor[]; +} + +export interface GroupOut { + id: string; + documentId: string; + path: InstancePath; + name: string; + smallThumbnailUrl?: string; + largeThumbnailUrl?: string; + insertableOrder: string[]; +} + +export type Insertables = Record; +export type Groups = Record; + +export interface LibraryOut { + groupOrder: string[]; + groups: Groups; + insertables: Insertables; +} + +/** + * Whether a library-load job is running, and how long it has been going. + * Milliseconds since the oldest running job started paces the client's polling. + */ +export type JobStatus = + | { running: false } + | { running: true; runningForMs: number }; + diff --git a/src/shared/library-id.ts b/src/shared/library-id.ts new file mode 100644 index 000000000..192d4071f --- /dev/null +++ b/src/shared/library-id.ts @@ -0,0 +1,8 @@ +export enum LibraryId { + FRC_DESIGN_LIB = "frc-design-lib", + FTC_DESIGN_LIB = "ftc-design-lib", + MKCAD = "mkcad" +} + +/** The library a user lands in before they have picked one. */ +export const DEFAULT_LIBRARY_ID = LibraryId.FRC_DESIGN_LIB; diff --git a/src/shared/schema.ts b/src/shared/schema.ts index f78aa0264..8c911bda8 100644 --- a/src/shared/schema.ts +++ b/src/shared/schema.ts @@ -1,13 +1,9 @@ import { sqliteTable, text, integer, unique } from "drizzle-orm/sqlite-core"; -import { - DEFAULT_LIBRARY_ID, - DEFAULT_SETTINGS, - ElementType, - FastenInfo, - LibraryId, - Theme, - Vendor -} from "./types"; +import { ElementType } from "./element-type"; +import { FastenInfo } from "./fasten"; +import { DEFAULT_LIBRARY_ID, LibraryId } from "./library-id"; +import { DEFAULT_SETTINGS, Theme } from "./settings"; +import { Vendor } from "./vendors"; import { ParameterValues, ConfigurationParameter, diff --git a/src/shared/search.ts b/src/shared/search.ts index 91b7e51ad..2b0df8891 100644 --- a/src/shared/search.ts +++ b/src/shared/search.ts @@ -3,8 +3,8 @@ * deserializes it with the same options. */ import MiniSearch, { Options } from "minisearch"; -import { LibraryOut } from "./api-models"; -import { Vendor } from "./types"; +import { LibraryOut } from "./library-dto"; +import { Vendor } from "./vendors"; import { ConfigurationRecord, SearchRecord } from "./configuration-models"; const deliminator = "^"; diff --git a/src/shared/settings.ts b/src/shared/settings.ts new file mode 100644 index 000000000..8469f2993 --- /dev/null +++ b/src/shared/settings.ts @@ -0,0 +1,21 @@ +import { LibraryId } from "./library-id"; + +export enum Theme { + SYSTEM = "system", + LIGHT = "light", + DARK = "dark" +} + +/** User settings, which the entry redirect reads and seeds the app with. */ +export interface Settings { + theme: Theme; +} + +export interface SettingsUpdate { + theme?: Theme; + libraryId?: LibraryId; +} + +export const DEFAULT_SETTINGS: Settings = { + theme: Theme.SYSTEM +}; diff --git a/src/shared/thumbnail-types.ts b/src/shared/thumbnail-types.ts new file mode 100644 index 000000000..b6d180185 --- /dev/null +++ b/src/shared/thumbnail-types.ts @@ -0,0 +1,14 @@ +/** + * The two thumbnail sizes we generate and store, as the `WxH` Onshape wants. + * SMALL fills list rows; LARGE fills the hover card and the insert preview. + */ +export enum ThumbnailSize { + SMALL = "70x40", + LARGE = "300x300" +} + +/** An element's two stored thumbnail URLs, produced (and stored) as a pair. */ +export interface ThumbnailUrls { + small: string; + large: string; +} diff --git a/src/shared/thumbnails.ts b/src/shared/thumbnails.ts index 97c22b11c..c3ffd534e 100644 --- a/src/shared/thumbnails.ts +++ b/src/shared/thumbnails.ts @@ -3,7 +3,7 @@ import { DEFAULT_CANONICAL_CONFIGURATION, DEFAULT_CONFIGURATION_KEY } from "./canonical-configuration"; -import { ThumbnailSize } from "./types"; +import { ThumbnailSize } from "./thumbnail-types"; /** Short on purpose: the real render can land at any moment and must take over. */ export const THUMBNAIL_FALLBACK_CACHE_TTL = 60; diff --git a/src/shared/types.ts b/src/shared/types.ts deleted file mode 100644 index ac162f9ef..000000000 --- a/src/shared/types.ts +++ /dev/null @@ -1,155 +0,0 @@ -export enum AccessLevel { - ADMIN = "admin", - EDITOR = "editor", - USER = "user" -} - -export function hasAdminAccess(accessLevel: AccessLevel) { - return accessLevel === AccessLevel.ADMIN; -} - -export function hasEditorAccess(accessLevel: AccessLevel) { - return ( - accessLevel === AccessLevel.ADMIN || accessLevel === AccessLevel.EDITOR - ); -} - -export function hasUserAccess(accessLevel: AccessLevel) { - return accessLevel === AccessLevel.USER; -} - -const ACCESS_LEVEL_RANK: Record = { - [AccessLevel.USER]: 0, - [AccessLevel.EDITOR]: 1, - [AccessLevel.ADMIN]: 2 -}; - -/** Whether `accessLevel` grants no more than `maxAccessLevel` does. */ -export function isWithinAccessLevel( - accessLevel: AccessLevel, - maxAccessLevel: AccessLevel -): boolean { - return ACCESS_LEVEL_RANK[accessLevel] <= ACCESS_LEVEL_RANK[maxAccessLevel]; -} - -export enum Vendor { - AM = "AM", - /** Marks a part the team made, so nobody sells it and it has no part number. */ - CUSTOM = "Custom", - LAI = "LAI", - MCM = "MCM", - REDUX = "Redux", - REV = "REV", - SDS = "SDS", - SWYFT = "SWYFT", - TTB = "TTB", - VEX = "VEX", - WCP = "WCP" -} - -/** Team-made, so it is expected to have no part number. */ -export function isCustomPart(vendors: Vendor[]): boolean { - return vendors.includes(Vendor.CUSTOM); -} - -export function getVendorName(vendor: Vendor) { - switch (vendor) { - case Vendor.AM: - return "AndyMark"; - case Vendor.CUSTOM: - return "Custom"; - case Vendor.LAI: - return "Last Anvil Innovations"; - case Vendor.MCM: - return "McMaster-Carr"; - case Vendor.REDUX: - return "Redux Robotics"; - case Vendor.REV: - return "REV Robotics"; - case Vendor.SDS: - return "Swerve Drive Specialties"; - case Vendor.SWYFT: - return "SWYFT"; - case Vendor.TTB: - return "The Thrifty Bot"; - case Vendor.VEX: - return "VEXpro"; - case Vendor.WCP: - return "West Coast Products"; - } -} -/** - * The two thumbnail sizes we generate and store, as the `WxH` Onshape wants. - * SMALL fills list rows; LARGE fills the hover card and the insert preview. - */ -export enum ThumbnailSize { - SMALL = "70x40", - LARGE = "300x300" -} - -/** An element's two stored thumbnail URLs, produced (and stored) as a pair. */ -export interface ThumbnailUrls { - small: string; - large: string; -} -export enum Theme { - SYSTEM = "system", - LIGHT = "light", - DARK = "dark" -} - -/** User settings, which the entry redirect reads and seeds the app with. */ -export interface Settings { - theme: Theme; -} - -export interface SettingsUpdate { - theme?: Theme; - libraryId?: LibraryId; -} - -/** - * Server-provided access: the highest level granted plus sign-in state. The - * level the app is currently viewed as is client-side (see useAccessData). - */ -export interface AccessData { - maxAccessLevel: AccessLevel; - signedIn: boolean; -} - -export interface ContextData { - accessData: AccessData; - settings: Settings; -} -export enum LibraryId { - FRC_DESIGN_LIB = "frc-design-lib", - FTC_DESIGN_LIB = "ftc-design-lib", - MKCAD = "mkcad" -} - -/** - * The type of the Onshape tab the app is open in. - */ -export enum ElementType { - PART_STUDIO = "PARTSTUDIO", - ASSEMBLY = "ASSEMBLY" -} - -export interface FastenInfo { - mateConnectorId: string; - mateLocation: MateLocation; - path: string[]; -} - -export enum MateLocation { - Feature = "Feature", - Part = "Part", - Subassembly = "Subassembly" -} - -export const DEFAULT_SETTINGS: Settings = { - theme: Theme.SYSTEM -}; - -/** The library a user lands in before they have picked one. */ -export const DEFAULT_LIBRARY_ID = LibraryId.FRC_DESIGN_LIB; diff --git a/src/shared/vendors.ts b/src/shared/vendors.ts new file mode 100644 index 000000000..91e509945 --- /dev/null +++ b/src/shared/vendors.ts @@ -0,0 +1,47 @@ +/** The vendors an insertable can come from, and how they are displayed. */ +export enum Vendor { + AM = "AM", + /** Marks a part the team made, so nobody sells it and it has no part number. */ + CUSTOM = "Custom", + LAI = "LAI", + MCM = "MCM", + REDUX = "Redux", + REV = "REV", + SDS = "SDS", + SWYFT = "SWYFT", + TTB = "TTB", + VEX = "VEX", + WCP = "WCP" +} + +/** Team-made, so it is expected to have no part number. */ +export function isCustomPart(vendors: Vendor[]): boolean { + return vendors.includes(Vendor.CUSTOM); +} + +export function getVendorName(vendor: Vendor) { + switch (vendor) { + case Vendor.AM: + return "AndyMark"; + case Vendor.CUSTOM: + return "Custom"; + case Vendor.LAI: + return "Last Anvil Innovations"; + case Vendor.MCM: + return "McMaster-Carr"; + case Vendor.REDUX: + return "Redux Robotics"; + case Vendor.REV: + return "REV Robotics"; + case Vendor.SDS: + return "Swerve Drive Specialties"; + case Vendor.SWYFT: + return "SWYFT"; + case Vendor.TTB: + return "The Thrifty Bot"; + case Vendor.VEX: + return "VEXpro"; + case Vendor.WCP: + return "West Coast Products"; + } +} From b1a40a71844eb21bda2275a3b15a22ff958ae7b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 18:28:40 +0000 Subject: [PATCH 02/56] refactor: organize backend and frontend by feature, drop shared/ Replaces src/shared with ownership-based placement: the backend owns the contract (DTOs, domain enums, configuration models) and the frontend imports it, so there is no third top-level bucket. backend/ app.ts assembles Hono; db/ holds the client and schema; lib/ holds the Onshape client and request plumbing; features/{auth,users, library,configurations,thumbnails,build-checker,favorites,search} each own their routes, storage and models. frontend/ features//{queries.ts,components/} with cross-cutting helpers in lib/ and shared UI in components/. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- drizzle.config.ts | 2 +- src/__test_utils__/configuration-fixtures.ts | 4 +- src/__test_utils__/insertable-fixtures.ts | 6 +-- src/__test_utils__/mock-onshape-api.ts | 2 +- src/__test_utils__/seed.ts | 15 +++--- src/__test_utils__/test-app.ts | 4 +- .../{create-app.test.ts => app.test.ts} | 8 ++-- src/backend/{create-app.ts => app.ts} | 38 ++++++++------- src/backend/{db.ts => db/client.ts} | 2 +- src/{shared => backend/db}/schema.ts | 14 +++--- .../auth/access-control.ts} | 10 ++-- .../features/auth}/access-level.ts | 0 .../auth}/not-signed-in.test.ts | 10 ++-- .../auth/onshape-oauth.ts} | 8 ++-- .../auth/routes.ts} | 4 +- src/backend/{ => features/auth}/services.ts | 14 ++++-- .../auth/session.ts} | 2 +- .../auth/sign-in.ts} | 2 +- .../build-checker/checks.test.ts} | 14 +++--- .../build-checker/checks.ts} | 12 ++--- .../features/build-checker/dto.ts} | 8 ++-- .../features/build-checker/issues.test.ts} | 2 +- .../features/build-checker/issues.ts} | 2 +- .../build-checker/routes.test.ts} | 8 ++-- .../build-checker/routes.ts} | 18 +++++--- .../configurations/canonical.test.ts} | 8 ++-- .../features/configurations/canonical.ts} | 6 +-- .../configurations/combinations.test.ts} | 11 +++-- .../features/configurations/combinations.ts} | 4 +- .../features/configurations/enums.ts} | 0 .../configurations}/input-parser.test.ts | 2 +- .../features/configurations}/input-parser.ts | 2 +- .../features/configurations/models.ts} | 2 +- .../parse-configuration.test.ts | 12 ++--- .../configurations}/parse-configuration.ts | 6 +-- .../configurations/records.test.ts} | 25 +++++----- .../configurations/records.ts} | 24 +++++----- .../configurations/routes.test.ts} | 8 ++-- .../configurations/routes.ts} | 21 ++++----- .../features/configurations/utils.test.ts} | 4 +- .../features/configurations/utils.ts} | 4 +- .../features/favorites/dto.ts} | 4 +- .../favorites/routes.test.ts} | 6 +-- .../favorites/routes.ts} | 18 ++++---- .../library/db.test.ts} | 8 ++-- .../library/db.ts} | 17 +++---- .../features/library/dto.ts} | 5 +- .../library/groups/routes.test.ts} | 16 +++---- .../library/groups/routes.ts} | 26 ++++++----- .../features/library/insertables}/fasten.ts | 0 .../library/insertables/parse-fasten.test.ts} | 6 +-- .../library/insertables/parse-fasten.ts} | 16 +++---- .../library/insertables/routes.test.ts} | 24 +++++----- .../library/insertables/routes.ts} | 42 ++++++++--------- .../features/library}/library-id.ts | 0 .../library}/parse-vendors.test.ts | 6 +-- .../library}/parse-vendors.ts | 4 +- .../library/routes.test.ts} | 10 ++-- .../library.ts => features/library/routes.ts} | 12 ++--- .../features/library}/vendors.ts | 0 .../library/workflows/context.test.ts} | 2 +- .../library/workflows/context.ts} | 12 ++--- .../library/workflows/index.ts} | 26 +++++------ .../library/workflows}/job-tracker.test.ts | 2 +- .../library/workflows}/job-tracker.ts | 6 +-- .../library/workflows}/load-group.test.ts | 22 ++++----- .../library/workflows}/load-group.ts | 26 +++++------ .../workflows}/load-insertable.test.ts | 10 ++-- .../library/workflows}/load-insertable.ts | 36 +++++++-------- .../parse-document-contents.test.ts | 2 +- .../workflows}/parse-document-contents.ts | 4 +- .../library/workflows/steps.test.ts} | 6 +-- .../library/workflows/steps.ts} | 8 ++-- .../features/search/search-index.ts} | 6 +-- .../features/thumbnails/keys.ts} | 6 +-- .../thumbnails/routes.test.ts} | 12 ++--- .../thumbnails/routes.ts} | 46 +++++++++++-------- .../features/thumbnails/types.ts} | 0 .../users/routes.test.ts} | 10 ++-- .../user.ts => features/users/routes.ts} | 14 +++--- .../features/users}/settings.ts | 2 +- src/backend/index.ts | 6 +-- src/backend/{ => lib}/cache.ts | 0 src/backend/{ => lib}/context.ts | 6 +-- .../{onshape-api => lib/onshape}/api-path.ts | 2 +- .../onshape}/assertions.ts | 2 +- .../onshape/client.test.ts} | 6 +-- .../onshape-api.ts => lib/onshape/client.ts} | 2 +- .../lib/onshape}/element-type.ts | 0 .../onshape}/endpoints/assemblies.ts | 6 +-- .../onshape}/endpoints/configurations.ts | 6 +-- .../onshape}/endpoints/documents.ts | 8 ++-- .../onshape}/endpoints/feature-studios.ts | 4 +- .../onshape}/endpoints/metadata.ts | 10 ++-- .../onshape}/endpoints/part-studios.ts | 9 ++-- .../onshape}/endpoints/parts.ts | 10 ++-- .../onshape}/endpoints/permissions.ts | 4 +- .../onshape}/endpoints/settings.ts | 2 +- .../onshape}/endpoints/std-versions.ts | 2 +- .../onshape}/endpoints/thumbnails.ts | 6 +-- .../onshape}/endpoints/users.ts | 6 +-- .../onshape}/endpoints/versions.ts | 6 +-- .../onshape}/objects/assembly-features.ts | 0 .../onshape}/objects/constants.ts | 2 +- .../onshape}/objects/derive-feature.ts | 4 +- .../onshape}/objects/parse-query.ts | 0 .../lib/onshape/path.ts} | 2 +- .../onshape-types.ts => lib/onshape/types.ts} | 2 +- .../lib/query-params.ts} | 0 src/backend/{ => lib}/route-params.ts | 2 +- src/frontend/{app => components}/alerts.tsx | 0 .../{app-common => components}/app-menu.tsx | 2 +- .../{app => components}/app-navbar.tsx | 26 +++++------ .../{app-common => components}/app-select.tsx | 2 +- .../app-zero-state.tsx | 2 +- .../{common => components}/change-order.tsx | 2 +- .../open-url-button.tsx | 4 +- .../{app => components}/root-error.tsx | 10 ++-- .../{app => components}/root-spinner.tsx | 0 .../{settings => components}/select-utils.ts | 0 .../auth}/access-level.tsx | 8 ++-- .../{api-utils => features/auth}/sign-in.ts | 2 +- .../build-status/components}/build-status.tsx | 28 ++++++----- .../build-status/queries.ts} | 18 +++++--- .../favorites/components}/favorite-button.tsx | 25 +++++----- .../favorites/components}/favorite-card.tsx | 40 ++++++++-------- .../favorites/components}/favorite-menu.tsx | 32 ++++++------- .../favorites/components}/favorites-list.tsx | 34 ++++++++------ .../favorites/queries.ts} | 12 ++--- .../insert/components}/configurations.tsx | 22 ++++----- .../insert/components}/insert-menu.tsx | 34 +++++++------- .../{ => features}/insert/insert-hooks.ts | 20 ++++---- .../insert/queries.ts} | 11 +++-- .../{cards => features/library}/card-hooks.ts | 30 ++++++------ .../library/components}/add-group-menu.tsx | 18 ++++---- .../library/components}/card-components.tsx | 37 +++++++++------ .../library/components}/group-card.tsx | 30 ++++++------ .../library/components}/insertable-card.tsx | 31 +++++++------ .../components}/reload-groups-button.tsx | 16 +++---- .../library/library-path.ts} | 5 +- .../library/queries.ts} | 17 ++++--- .../search/components}/search-errors.tsx | 14 ++++-- .../search/components}/search-results.tsx | 19 ++++---- src/frontend/{ => features}/search/filter.ts | 4 +- .../search/queries.ts} | 12 ++--- .../{ => features}/search/search.test.ts | 12 +++-- src/frontend/{ => features}/search/search.ts | 9 ++-- .../settings/components}/settings-menu.tsx | 33 +++++++------ .../settings/components}/vendor-filters.tsx | 10 ++-- .../{ => features}/settings/local-settings.ts | 11 ++++- .../{ => features}/settings/settings.ts | 8 ++-- .../thumbnails/components}/thumbnail.tsx | 22 ++++----- .../{api-utils/api.ts => lib/api-client.ts} | 4 +- src/frontend/{api-utils => lib}/errors.ts | 2 +- src/frontend/{common => lib}/format-time.ts | 0 src/frontend/{api-utils => lib}/messages.ts | 2 +- .../{common => lib}/notifications.tsx | 0 .../{api-utils => lib}/onshape-params.ts | 6 +-- src/frontend/{ => lib}/query-client.ts | 2 +- src/frontend/{ => lib}/query-keys.ts | 4 +- src/frontend/{api-utils => lib}/refresh.ts | 17 ++++--- .../{common => lib}/style-constants.ts | 0 src/frontend/{api-utils => lib}/ui-state.ts | 4 +- src/frontend/{common => lib}/url.tsx | 4 +- src/frontend/{common => lib}/utils.ts | 6 +-- src/frontend/router.ts | 2 +- src/frontend/routes/__root.tsx | 10 ++-- src/frontend/routes/_pages/beta-complete.tsx | 4 +- src/frontend/routes/_pages/cookie-error.tsx | 2 +- src/frontend/routes/_pages/grant-denied.tsx | 4 +- src/frontend/routes/_pages/safari-error.tsx | 4 +- .../library/$libraryId/groups/$groupId.tsx | 35 +++++++------- .../routes/app/library/$libraryId/index.tsx | 29 ++++++------ .../routes/app/library/$libraryId/route.tsx | 20 +++++--- src/frontend/routes/app/route.tsx | 14 +++--- src/frontend/routes/index.tsx | 4 +- src/frontend/theme.ts | 2 +- tsconfig.backend.json | 4 +- tsconfig.frontend.json | 8 +--- tsconfig.test.json | 7 +-- vitest.config.ts | 7 +-- 181 files changed, 942 insertions(+), 878 deletions(-) rename src/backend/{create-app.test.ts => app.test.ts} (93%) rename src/backend/{create-app.ts => app.ts} (77%) rename src/backend/{db.ts => db/client.ts} (78%) rename src/{shared => backend/db}/schema.ts (92%) rename src/backend/{access-level-utils.ts => features/auth/access-control.ts} (82%) rename src/{shared => backend/features/auth}/access-level.ts (100%) rename src/backend/{routes => features/auth}/not-signed-in.test.ts (88%) rename src/backend/{auth-oauth.ts => features/auth/onshape-oauth.ts} (96%) rename src/backend/{auth-routes.ts => features/auth/routes.ts} (91%) rename src/backend/{ => features/auth}/services.ts (78%) rename src/backend/{auth-session.ts => features/auth/session.ts} (98%) rename src/backend/{sign-in-utils.ts => features/auth/sign-in.ts} (95%) rename src/backend/{parse/build-checks.test.ts => features/build-checker/checks.test.ts} (89%) rename src/backend/{parse/build-checks.ts => features/build-checker/checks.ts} (89%) rename src/{shared/build-status-dto.ts => backend/features/build-checker/dto.ts} (80%) rename src/{shared/build-issues.test.ts => backend/features/build-checker/issues.test.ts} (99%) rename src/{shared/build-issues.ts => backend/features/build-checker/issues.ts} (99%) rename src/backend/{routes/build-status.test.ts => features/build-checker/routes.test.ts} (93%) rename src/backend/{routes/build-status.ts => features/build-checker/routes.ts} (89%) rename src/{shared/canonical-configuration.test.ts => backend/features/configurations/canonical.test.ts} (96%) rename src/{shared/canonical-configuration.ts => backend/features/configurations/canonical.ts} (96%) rename src/{shared/configuration-combinations.test.ts => backend/features/configurations/combinations.test.ts} (97%) rename src/{shared/configuration-combinations.ts => backend/features/configurations/combinations.ts} (97%) rename src/{shared/configuration-enums.ts => backend/features/configurations/enums.ts} (100%) rename src/{shared => backend/features/configurations}/input-parser.test.ts (98%) rename src/{shared => backend/features/configurations}/input-parser.ts (99%) rename src/{shared/configuration-models.ts => backend/features/configurations/models.ts} (98%) rename src/backend/{parse => features/configurations}/parse-configuration.test.ts (98%) rename src/backend/{parse => features/configurations}/parse-configuration.ts (97%) rename src/backend/{parse/parse-configuration-records.test.ts => features/configurations/records.test.ts} (94%) rename src/backend/{parse/parse-configuration-records.ts => features/configurations/records.ts} (95%) rename src/backend/{routes/configurations.test.ts => features/configurations/routes.test.ts} (92%) rename src/backend/{routes/configurations.ts => features/configurations/routes.ts} (82%) rename src/{shared/configuration-utils.test.ts => backend/features/configurations/utils.test.ts} (92%) rename src/{shared/configuration-utils.ts => backend/features/configurations/utils.ts} (98%) rename src/{shared/favorites-dto.ts => backend/features/favorites/dto.ts} (82%) rename src/backend/{routes/favorites.test.ts => features/favorites/routes.test.ts} (98%) rename src/backend/{routes/favorites.ts => features/favorites/routes.ts} (90%) rename src/backend/{library-data.test.ts => features/library/db.test.ts} (93%) rename src/backend/{library-data.ts => features/library/db.ts} (95%) rename src/{shared/library-dto.ts => backend/features/library/dto.ts} (89%) rename src/backend/{routes/groups.test.ts => features/library/groups/routes.test.ts} (95%) rename src/backend/{routes/groups.ts => features/library/groups/routes.ts} (92%) rename src/{shared => backend/features/library/insertables}/fasten.ts (100%) rename src/backend/{parse/insert-and-fasten.test.ts => features/library/insertables/parse-fasten.test.ts} (97%) rename src/backend/{parse/insert-and-fasten.ts => features/library/insertables/parse-fasten.ts} (88%) rename src/backend/{routes/insertables.test.ts => features/library/insertables/routes.test.ts} (93%) rename src/backend/{routes/insertables.ts => features/library/insertables/routes.ts} (91%) rename src/{shared => backend/features/library}/library-id.ts (100%) rename src/backend/{parse => features/library}/parse-vendors.test.ts (94%) rename src/backend/{parse => features/library}/parse-vendors.ts (92%) rename src/backend/{routes/library.test.ts => features/library/routes.test.ts} (95%) rename src/backend/{routes/library.ts => features/library/routes.ts} (82%) rename src/{shared => backend/features/library}/vendors.ts (100%) rename src/backend/{load/load-common.test.ts => features/library/workflows/context.test.ts} (96%) rename src/backend/{load/load-common.ts => features/library/workflows/context.ts} (83%) rename src/backend/{load/workflows.ts => features/library/workflows/index.ts} (93%) rename src/backend/{load => features/library/workflows}/job-tracker.test.ts (98%) rename src/backend/{load => features/library/workflows}/job-tracker.ts (95%) rename src/backend/{load => features/library/workflows}/load-group.test.ts (93%) rename src/backend/{load => features/library/workflows}/load-group.ts (92%) rename src/backend/{load => features/library/workflows}/load-insertable.test.ts (94%) rename src/backend/{load => features/library/workflows}/load-insertable.ts (88%) rename src/backend/{parse => features/library/workflows}/parse-document-contents.test.ts (98%) rename src/backend/{parse => features/library/workflows}/parse-document-contents.ts (93%) rename src/backend/{load/load-steps.test.ts => features/library/workflows/steps.test.ts} (94%) rename src/backend/{load/load-steps.ts => features/library/workflows/steps.ts} (90%) rename src/{shared/search.ts => backend/features/search/search-index.ts} (97%) rename src/{shared/thumbnails.ts => backend/features/thumbnails/keys.ts} (92%) rename src/backend/{routes/thumbnails.test.ts => features/thumbnails/routes.test.ts} (97%) rename src/backend/{routes/thumbnails.ts => features/thumbnails/routes.ts} (91%) rename src/{shared/thumbnail-types.ts => backend/features/thumbnails/types.ts} (100%) rename src/backend/{routes/user.test.ts => features/users/routes.test.ts} (87%) rename src/backend/{routes/user.ts => features/users/routes.ts} (69%) rename src/{shared => backend/features/users}/settings.ts (87%) rename src/backend/{ => lib}/cache.ts (100%) rename src/backend/{ => lib}/context.ts (91%) rename src/backend/{onshape-api => lib/onshape}/api-path.ts (95%) rename src/backend/{onshape-api => lib/onshape}/assertions.ts (87%) rename src/backend/{onshape-api/onshape-api.test.ts => lib/onshape/client.test.ts} (94%) rename src/backend/{onshape-api/onshape-api.ts => lib/onshape/client.ts} (99%) rename src/{shared => backend/lib/onshape}/element-type.ts (100%) rename src/backend/{onshape-api => lib/onshape}/endpoints/assemblies.ts (97%) rename src/backend/{onshape-api => lib/onshape}/endpoints/configurations.ts (92%) rename src/backend/{onshape-api => lib/onshape}/endpoints/documents.ts (98%) rename src/backend/{onshape-api => lib/onshape}/endpoints/feature-studios.ts (96%) rename src/backend/{onshape-api => lib/onshape}/endpoints/metadata.ts (66%) rename src/backend/{onshape-api => lib/onshape}/endpoints/part-studios.ts (91%) rename src/backend/{onshape-api => lib/onshape}/endpoints/parts.ts (80%) rename src/backend/{onshape-api => lib/onshape}/endpoints/permissions.ts (90%) rename src/backend/{onshape-api => lib/onshape}/endpoints/settings.ts (97%) rename src/backend/{onshape-api => lib/onshape}/endpoints/std-versions.ts (96%) rename src/backend/{onshape-api => lib/onshape}/endpoints/thumbnails.ts (95%) rename src/backend/{onshape-api => lib/onshape}/endpoints/users.ts (88%) rename src/backend/{onshape-api => lib/onshape}/endpoints/versions.ts (93%) rename src/backend/{onshape-api => lib/onshape}/objects/assembly-features.ts (100%) rename src/backend/{onshape-api => lib/onshape}/objects/constants.ts (89%) rename src/backend/{onshape-api => lib/onshape}/objects/derive-feature.ts (97%) rename src/backend/{onshape-api => lib/onshape}/objects/parse-query.ts (100%) rename src/{shared/onshape-path.ts => backend/lib/onshape/path.ts} (97%) rename src/backend/{onshape-api/onshape-types.ts => lib/onshape/types.ts} (99%) rename src/{shared/url-params.ts => backend/lib/query-params.ts} (100%) rename src/backend/{ => lib}/route-params.ts (95%) rename src/frontend/{app => components}/alerts.tsx (100%) rename src/frontend/{app-common => components}/app-menu.tsx (97%) rename src/frontend/{app => components}/app-navbar.tsx (88%) rename src/frontend/{app-common => components}/app-select.tsx (93%) rename src/frontend/{app-common => components}/app-zero-state.tsx (97%) rename src/frontend/{common => components}/change-order.tsx (99%) rename src/frontend/{common => components}/open-url-button.tsx (82%) rename src/frontend/{app => components}/root-error.tsx (84%) rename src/frontend/{app => components}/root-spinner.tsx (100%) rename src/frontend/{settings => components}/select-utils.ts (100%) rename src/frontend/{api-utils => features/auth}/access-level.tsx (93%) rename src/frontend/{api-utils => features/auth}/sign-in.ts (95%) rename src/frontend/{cards => features/build-status/components}/build-status.tsx (97%) rename src/frontend/{build-status-queries.ts => features/build-status/queries.ts} (62%) rename src/frontend/{favorites => features/favorites/components}/favorite-button.tsx (88%) rename src/frontend/{favorites => features/favorites/components}/favorite-card.tsx (82%) rename src/frontend/{favorites => features/favorites/components}/favorite-menu.tsx (85%) rename src/frontend/{favorites => features/favorites/components}/favorites-list.tsx (81%) rename src/frontend/{favorites-queries.ts => features/favorites/queries.ts} (67%) rename src/frontend/{insert => features/insert/components}/configurations.tsx (95%) rename src/frontend/{insert => features/insert/components}/insert-menu.tsx (88%) rename src/frontend/{ => features}/insert/insert-hooks.ts (83%) rename src/frontend/{configuration-queries.ts => features/insert/queries.ts} (71%) rename src/frontend/{cards => features/library}/card-hooks.ts (90%) rename src/frontend/{groups => features/library/components}/add-group-menu.tsx (84%) rename src/frontend/{cards => features/library/components}/card-components.tsx (90%) rename src/frontend/{groups => features/library/components}/group-card.tsx (86%) rename src/frontend/{cards => features/library/components}/insertable-card.tsx (80%) rename src/frontend/{settings => features/library/components}/reload-groups-button.tsx (82%) rename src/frontend/{api-utils/library.ts => features/library/library-path.ts} (92%) rename src/frontend/{library-queries.ts => features/library/queries.ts} (88%) rename src/frontend/{search => features/search/components}/search-errors.tsx (91%) rename src/frontend/{search => features/search/components}/search-results.tsx (88%) rename src/frontend/{ => features}/search/filter.ts (91%) rename src/frontend/{search-queries.ts => features/search/queries.ts} (70%) rename src/frontend/{ => features}/search/search.test.ts (97%) rename src/frontend/{ => features}/search/search.ts (97%) rename src/frontend/{settings => features/settings/components}/settings-menu.tsx (81%) rename src/frontend/{settings => features/settings/components}/vendor-filters.tsx (89%) rename src/frontend/{ => features}/settings/local-settings.ts (81%) rename src/frontend/{ => features}/settings/settings.ts (75%) rename src/frontend/{insert => features/thumbnails/components}/thumbnail.tsx (90%) rename src/frontend/{api-utils/api.ts => lib/api-client.ts} (97%) rename src/frontend/{api-utils => lib}/errors.ts (92%) rename src/frontend/{common => lib}/format-time.ts (100%) rename src/frontend/{api-utils => lib}/messages.ts (97%) rename src/frontend/{common => lib}/notifications.tsx (100%) rename src/frontend/{api-utils => lib}/onshape-params.ts (84%) rename src/frontend/{ => lib}/query-client.ts (89%) rename src/frontend/{ => lib}/query-keys.ts (92%) rename src/frontend/{api-utils => lib}/refresh.ts (82%) rename src/frontend/{common => lib}/style-constants.ts (100%) rename src/frontend/{api-utils => lib}/ui-state.ts (96%) rename src/frontend/{common => lib}/url.tsx (94%) rename src/frontend/{common => lib}/utils.ts (92%) diff --git a/drizzle.config.ts b/drizzle.config.ts index 47c3926b3..c71023731 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "drizzle-kit"; export default defineConfig({ - schema: "./src/shared/schema.ts", + schema: "./src/backend/db/schema.ts", out: "./drizzle", dialect: "sqlite", driver: "d1-http", diff --git a/src/__test_utils__/configuration-fixtures.ts b/src/__test_utils__/configuration-fixtures.ts index db18da3af..8a6e75a9b 100644 --- a/src/__test_utils__/configuration-fixtures.ts +++ b/src/__test_utils__/configuration-fixtures.ts @@ -8,8 +8,8 @@ import { type EnumParameter, type QuantityParameter, type UnitInfo -} from "../shared/configuration-models"; -import { QuantityType, Unit } from "../shared/configuration-enums"; +} from "../backend/features/configurations/models"; +import { QuantityType, Unit } from "../backend/features/configurations/enums"; /** Builds an enum parameter whose options are named after their ids. */ export function enumParam( diff --git a/src/__test_utils__/insertable-fixtures.ts b/src/__test_utils__/insertable-fixtures.ts index 22319d21f..c11dc84a5 100644 --- a/src/__test_utils__/insertable-fixtures.ts +++ b/src/__test_utils__/insertable-fixtures.ts @@ -2,9 +2,9 @@ * Factories for the load pipeline's insertable shapes. Import directly: the * barrel re-exports Workers-only helpers these tests cannot resolve. */ -import type { InsertableTarget } from "../backend/load/load-common"; -import type { ParsedInsertable } from "../backend/load/load-insertable"; -import { ElementType } from "../shared/element-type"; +import type { InsertableTarget } from "../backend/features/library/workflows/context"; +import type { ParsedInsertable } from "../backend/features/library/workflows/load-insertable"; +import { ElementType } from "../backend/lib/onshape/element-type"; import { TEST_GROUP_ID, TEST_LIBRARY_ID, diff --git a/src/__test_utils__/mock-onshape-api.ts b/src/__test_utils__/mock-onshape-api.ts index 1af44af83..2a6b70f8e 100644 --- a/src/__test_utils__/mock-onshape-api.ts +++ b/src/__test_utils__/mock-onshape-api.ts @@ -1,4 +1,4 @@ -import { OAuthApi } from "../backend/onshape-api/onshape-api"; +import { OAuthApi } from "../backend/lib/onshape/client"; /** * A thin shell client extending OAuthApi. diff --git a/src/__test_utils__/seed.ts b/src/__test_utils__/seed.ts index b95906d6b..49c0befef 100644 --- a/src/__test_utils__/seed.ts +++ b/src/__test_utils__/seed.ts @@ -1,4 +1,4 @@ -import { type Db } from "../backend/db"; +import { type Db } from "../backend/db/client"; import { configurations, favorites, @@ -6,14 +6,17 @@ import { insertables, libraries, users -} from "../shared/schema"; +} from "../backend/db/schema"; import { ParameterType, type ConfigurationParameter -} from "../shared/configuration-models"; -import { type ElementPath, type InstancePath } from "../shared/onshape-path"; -import { ElementType } from "../shared/element-type"; -import { LibraryId } from "../shared/library-id"; +} from "../backend/features/configurations/models"; +import { + type ElementPath, + type InstancePath +} from "../backend/lib/onshape/path"; +import { ElementType } from "../backend/lib/onshape/element-type"; +import { LibraryId } from "../backend/features/library/library-id"; export const TEST_LIBRARY_ID = LibraryId.FRC_DESIGN_LIB; export const TEST_USER_ID = "test-user"; // matches createTestApp's default userId diff --git a/src/__test_utils__/test-app.ts b/src/__test_utils__/test-app.ts index 7e13eb718..586d72b2c 100644 --- a/src/__test_utils__/test-app.ts +++ b/src/__test_utils__/test-app.ts @@ -1,5 +1,5 @@ -import { createApp } from "../backend/create-app"; -import { AccessLevel } from "../shared/access-level"; +import { createApp } from "../backend/app"; +import { AccessLevel } from "../backend/features/auth/access-level"; import { MOCK_ONSHAPE_API, MockOnshapeApi } from "./mock-onshape-api"; export interface TestAppOptions { diff --git a/src/backend/create-app.test.ts b/src/backend/app.test.ts similarity index 93% rename from src/backend/create-app.test.ts rename to src/backend/app.test.ts index 93ff582d4..254d29087 100644 --- a/src/backend/create-app.test.ts +++ b/src/backend/app.test.ts @@ -1,9 +1,9 @@ import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it } from "vitest"; import { eq } from "drizzle-orm"; -import { users } from "../shared/schema"; -import { LibraryId } from "../shared/library-id"; -import { Theme } from "../shared/settings"; +import { users } from "./db/schema"; +import { LibraryId } from "./features/library/library-id"; +import { Theme } from "./features/users/settings"; import { TEST_USER_ID, createTestApp, @@ -11,7 +11,7 @@ import { resetDb, seedUser } from "../__test_utils__"; -import { getDb } from "./db"; +import { getDb } from "./db/client"; const db = getDb(env.DB); diff --git a/src/backend/create-app.ts b/src/backend/app.ts similarity index 77% rename from src/backend/create-app.ts rename to src/backend/app.ts index 72de109da..c763948a9 100644 --- a/src/backend/create-app.ts +++ b/src/backend/app.ts @@ -1,23 +1,27 @@ import { HTTPException } from "hono/http-exception"; import { HttpStatus } from "http-status-ts"; import { eq } from "drizzle-orm"; -import { getDb } from "./db"; -import { users } from "../shared/schema"; -import { DEFAULT_LIBRARY_ID } from "../shared/library-id"; -import { DEFAULT_SETTINGS } from "../shared/settings"; -import { authRoutes } from "./auth-routes"; -import { getSessionCompanyId } from "./auth-session"; -import { cacheMiddleware } from "./cache"; -import { getApp, type AppContext, type AppServicesFactory } from "./context"; -import { OnshapeRateLimitError } from "./onshape-api/onshape-api"; -import { userRoutes } from "./routes/user"; -import { libraryRoutes } from "./routes/library"; -import { favoriteRoutes } from "./routes/favorites"; -import { thumbnailRoutes } from "./routes/thumbnails"; -import { insertableRoutes } from "./routes/insertables"; -import { groupRoutes } from "./routes/groups"; -import { configurationRoutes } from "./routes/configurations"; -import { buildStatusRoutes } from "./routes/build-status"; +import { getDb } from "./db/client"; +import { users } from "./db/schema"; +import { DEFAULT_LIBRARY_ID } from "./features/library/library-id"; +import { DEFAULT_SETTINGS } from "./features/users/settings"; +import { authRoutes } from "./features/auth/routes"; +import { getSessionCompanyId } from "./features/auth/session"; +import { cacheMiddleware } from "./lib/cache"; +import { + getApp, + type AppContext, + type AppServicesFactory +} from "./lib/context"; +import { OnshapeRateLimitError } from "./lib/onshape/client"; +import { userRoutes } from "./features/users/routes"; +import { libraryRoutes } from "./features/library/routes"; +import { favoriteRoutes } from "./features/favorites/routes"; +import { thumbnailRoutes } from "./features/thumbnails/routes"; +import { insertableRoutes } from "./features/library/insertables/routes"; +import { groupRoutes } from "./features/library/groups/routes"; +import { configurationRoutes } from "./features/configurations/routes"; +import { buildStatusRoutes } from "./features/build-checker/routes"; /** * Returns the relative URL of the given requestUrl. diff --git a/src/backend/db.ts b/src/backend/db/client.ts similarity index 78% rename from src/backend/db.ts rename to src/backend/db/client.ts index e7ea730cf..eb93d91d6 100644 --- a/src/backend/db.ts +++ b/src/backend/db/client.ts @@ -1,5 +1,5 @@ import { drizzle } from "drizzle-orm/d1"; -import * as schema from "../shared/schema"; +import * as schema from "./schema"; export function getDb(d1: D1Database) { return drizzle(d1, { schema }); diff --git a/src/shared/schema.ts b/src/backend/db/schema.ts similarity index 92% rename from src/shared/schema.ts rename to src/backend/db/schema.ts index 8c911bda8..f05cf26d8 100644 --- a/src/shared/schema.ts +++ b/src/backend/db/schema.ts @@ -1,15 +1,15 @@ import { sqliteTable, text, integer, unique } from "drizzle-orm/sqlite-core"; -import { ElementType } from "./element-type"; -import { FastenInfo } from "./fasten"; -import { DEFAULT_LIBRARY_ID, LibraryId } from "./library-id"; -import { DEFAULT_SETTINGS, Theme } from "./settings"; -import { Vendor } from "./vendors"; +import { ElementType } from "../lib/onshape/element-type"; +import { FastenInfo } from "../features/library/insertables/fasten"; +import { DEFAULT_LIBRARY_ID, LibraryId } from "../features/library/library-id"; +import { DEFAULT_SETTINGS, Theme } from "../features/users/settings"; +import { Vendor } from "../features/library/vendors"; import { ParameterValues, ConfigurationParameter, ConfigurationRecord -} from "./configuration-models"; -import { BuildIssue } from "./build-issues"; +} from "../features/configurations/models"; +import { BuildIssue } from "../features/build-checker/issues"; export const libraries = sqliteTable("libraries", { id: text("id").primaryKey(), diff --git a/src/backend/access-level-utils.ts b/src/backend/features/auth/access-control.ts similarity index 82% rename from src/backend/access-level-utils.ts rename to src/backend/features/auth/access-control.ts index 749c001de..9d1a89fb9 100644 --- a/src/backend/access-level-utils.ts +++ b/src/backend/features/auth/access-control.ts @@ -1,11 +1,11 @@ import { HttpStatus } from "http-status-ts"; import type { MiddlewareHandler } from "hono"; import { HTTPException } from "hono/http-exception"; -import type { AppContext, AppContextEnv } from "./context"; -import { getOnshapeApi } from "./auth-oauth"; -import { getSessionId } from "./auth-session"; -import { getAccessLevel } from "./onshape-api/endpoints/users"; -import { hasEditorAccess, type AccessLevel } from "../shared/access-level"; +import type { AppContext, AppContextEnv } from "../../lib/context"; +import { getOnshapeApi } from "./onshape-oauth"; +import { getSessionId } from "./session"; +import { getAccessLevel } from "../../lib/onshape/endpoints/users"; +import { hasEditorAccess, type AccessLevel } from "./access-level"; /** How long a resolved access level is cached in KV. */ const ACCESS_LEVEL_TTL_SECONDS = 60 * 60; diff --git a/src/shared/access-level.ts b/src/backend/features/auth/access-level.ts similarity index 100% rename from src/shared/access-level.ts rename to src/backend/features/auth/access-level.ts diff --git a/src/backend/routes/not-signed-in.test.ts b/src/backend/features/auth/not-signed-in.test.ts similarity index 88% rename from src/backend/routes/not-signed-in.test.ts rename to src/backend/features/auth/not-signed-in.test.ts index a9fc10d7e..d91fec2ce 100644 --- a/src/backend/routes/not-signed-in.test.ts +++ b/src/backend/features/auth/not-signed-in.test.ts @@ -1,15 +1,15 @@ import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it } from "vitest"; -import { AccessLevel } from "../../shared/access-level"; -import { LibraryId } from "../../shared/library-id"; -import { Theme } from "../../shared/settings"; +import { AccessLevel } from "./access-level"; +import { LibraryId } from "../library/library-id"; +import { Theme } from "../users/settings"; import { createTestApp, jsonRequest, resetDb, seedLibrary -} from "../../__test_utils__"; -import { getDb } from "../db"; +} from "../../../__test_utils__"; +import { getDb } from "../../db/client"; const db = getDb(env.DB); diff --git a/src/backend/auth-oauth.ts b/src/backend/features/auth/onshape-oauth.ts similarity index 96% rename from src/backend/auth-oauth.ts rename to src/backend/features/auth/onshape-oauth.ts index af803d17d..e157400e9 100644 --- a/src/backend/auth-oauth.ts +++ b/src/backend/features/auth/onshape-oauth.ts @@ -3,9 +3,9 @@ import { HttpStatus } from "http-status-ts"; import { generateState, OAuth2Client, OAuth2Tokens } from "arctic"; import { HTTPException } from "hono/http-exception"; import { env } from "cloudflare:workers"; -import { OAuthApi } from "./onshape-api/onshape-api"; -import { getSessionInfo, getUserId } from "./onshape-api/endpoints/users"; -import { type AppContext } from "./context"; +import { OAuthApi } from "../../lib/onshape/client"; +import { getSessionInfo, getUserId } from "../../lib/onshape/endpoints/users"; +import { type AppContext } from "../../lib/context"; import { type AuthTokens, SESSION_TTL, @@ -15,7 +15,7 @@ import { saveTokens, startLoginSession, takeLoginSession -} from "./auth-session"; +} from "./session"; const AUTH_ENDPOINT = "https://oauth.onshape.com/oauth/authorize"; const TOKEN_ENDPOINT = "https://oauth.onshape.com/oauth/token"; diff --git a/src/backend/auth-routes.ts b/src/backend/features/auth/routes.ts similarity index 91% rename from src/backend/auth-routes.ts rename to src/backend/features/auth/routes.ts index 367359d0c..3c9fdc1ab 100644 --- a/src/backend/auth-routes.ts +++ b/src/backend/features/auth/routes.ts @@ -1,7 +1,7 @@ import { HttpStatus } from "http-status-ts"; import { HTTPException } from "hono/http-exception"; -import { getApp } from "./context"; -import { doCallback, doSignIn } from "./auth-oauth"; +import { getApp } from "../../lib/context"; +import { doCallback, doSignIn } from "./onshape-oauth"; export const authRoutes = getApp(); diff --git a/src/backend/services.ts b/src/backend/features/auth/services.ts similarity index 78% rename from src/backend/services.ts rename to src/backend/features/auth/services.ts index 15c39f500..72407c96c 100644 --- a/src/backend/services.ts +++ b/src/backend/features/auth/services.ts @@ -1,8 +1,12 @@ -import type { AppServicesFactory } from "./context"; -import { getCachedUserId, getOnshapeApi, isAuthenticated } from "./auth-oauth"; -import { getCachedAccessLevel } from "./access-level-utils"; -import { isForceSignedIn, isSignedIn } from "./sign-in-utils"; -import { AccessLevel } from "../shared/access-level"; +import type { AppServicesFactory } from "../../lib/context"; +import { + getCachedUserId, + getOnshapeApi, + isAuthenticated +} from "./onshape-oauth"; +import { getCachedAccessLevel } from "./access-control"; +import { isForceSignedIn, isSignedIn } from "./sign-in"; +import { AccessLevel } from "./access-level"; /** Stable fake user id used for FORCE_SIGNED_IN testing sessions. */ export const FORCE_SIGNED_IN_USER_ID = "force-signed-in-user"; diff --git a/src/backend/auth-session.ts b/src/backend/features/auth/session.ts similarity index 98% rename from src/backend/auth-session.ts rename to src/backend/features/auth/session.ts index c55f67084..4c05839ab 100644 --- a/src/backend/auth-session.ts +++ b/src/backend/features/auth/session.ts @@ -2,7 +2,7 @@ import { HttpStatus } from "http-status-ts"; import { HTTPException } from "hono/http-exception"; import { getCookie, setCookie } from "hono/cookie"; -import { type AppContext } from "./context"; +import { type AppContext } from "../../lib/context"; const SESSION_COOKIE = "frc-design-app-cookie"; const LOGIN_TTL = 600; // 10 minutes diff --git a/src/backend/sign-in-utils.ts b/src/backend/features/auth/sign-in.ts similarity index 95% rename from src/backend/sign-in-utils.ts rename to src/backend/features/auth/sign-in.ts index f57adb57b..a7e284bff 100644 --- a/src/backend/sign-in-utils.ts +++ b/src/backend/features/auth/sign-in.ts @@ -2,7 +2,7 @@ import type { MiddlewareHandler } from "hono"; import { HTTPException } from "hono/http-exception"; import { HttpStatus } from "http-status-ts"; import { env } from "process"; -import type { AppContext, AppContextEnv } from "./context"; +import type { AppContext, AppContextEnv } from "../../lib/context"; /** FORCE_SIGNED_IN is a dev-only escape hatch, ignored in production. */ export function isForceSignedIn(c: AppContext): boolean { diff --git a/src/backend/parse/build-checks.test.ts b/src/backend/features/build-checker/checks.test.ts similarity index 89% rename from src/backend/parse/build-checks.test.ts rename to src/backend/features/build-checker/checks.test.ts index 374a64157..16344d831 100644 --- a/src/backend/parse/build-checks.test.ts +++ b/src/backend/features/build-checker/checks.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; -import { ThumbnailSize, ThumbnailUrls } from "../../shared/thumbnail-types"; -import { Vendor } from "../../shared/vendors"; -import { BuildIssueType } from "../../shared/build-issues"; -import { DEFAULT_CANONICAL_CONFIGURATION } from "../../shared/canonical-configuration"; -import { thumbnailUrl } from "../../shared/thumbnails"; -import { checkGroup, checkInsertable } from "./build-checks"; -import type { ConfigurationRecord } from "../../shared/configuration-models"; +import { ThumbnailSize, ThumbnailUrls } from "../thumbnails/types"; +import { Vendor } from "../library/vendors"; +import { BuildIssueType } from "./issues"; +import { DEFAULT_CANONICAL_CONFIGURATION } from "../configurations/canonical"; +import { thumbnailUrl } from "../thumbnails/keys"; +import { checkGroup, checkInsertable } from "./checks"; +import type { ConfigurationRecord } from "../configurations/models"; /** What uploadThumbnails returns: the element's default configuration. */ const THUMBNAILS: ThumbnailUrls = { diff --git a/src/backend/parse/build-checks.ts b/src/backend/features/build-checker/checks.ts similarity index 89% rename from src/backend/parse/build-checks.ts rename to src/backend/features/build-checker/checks.ts index 00f46c52f..d04b113f5 100644 --- a/src/backend/parse/build-checks.ts +++ b/src/backend/features/build-checker/checks.ts @@ -1,11 +1,7 @@ -import { ThumbnailUrls } from "../../shared/thumbnail-types"; -import { Vendor, isCustomPart } from "../../shared/vendors"; -import { - addBuildIssue, - BuildIssue, - BuildIssueType -} from "../../shared/build-issues"; -import type { ConfigurationRecord } from "../../shared/configuration-models"; +import { ThumbnailUrls } from "../thumbnails/types"; +import { Vendor, isCustomPart } from "../library/vendors"; +import { addBuildIssue, BuildIssue, BuildIssueType } from "./issues"; +import type { ConfigurationRecord } from "../configurations/models"; interface GroupCheckInput { /** Whether the Onshape document has a designated thumbnail tab/element. */ diff --git a/src/shared/build-status-dto.ts b/src/backend/features/build-checker/dto.ts similarity index 80% rename from src/shared/build-status-dto.ts rename to src/backend/features/build-checker/dto.ts index a1484da7c..1e35f8a7f 100644 --- a/src/shared/build-status-dto.ts +++ b/src/backend/features/build-checker/dto.ts @@ -1,7 +1,7 @@ -import { BuildIssue } from "./build-issues"; -import { ConfigurationParameter } from "./configuration-models"; -import { ElementType } from "./element-type"; -import { Vendor } from "./vendors"; +import { BuildIssue } from "./issues"; +import { ConfigurationParameter } from "../configurations/models"; +import { ElementType } from "../../lib/onshape/element-type"; +import { Vendor } from "../library/vendors"; export interface ConfigurationBuildStatus { buildIssues: BuildIssue[]; diff --git a/src/shared/build-issues.test.ts b/src/backend/features/build-checker/issues.test.ts similarity index 99% rename from src/shared/build-issues.test.ts rename to src/backend/features/build-checker/issues.test.ts index 32126d28d..c7a8f404a 100644 --- a/src/shared/build-issues.test.ts +++ b/src/backend/features/build-checker/issues.test.ts @@ -6,7 +6,7 @@ import { BuildIssueType, clearBuildIssue, getMaxSeverity -} from "./build-issues"; +} from "./issues"; /** A representative issue type for each severity. */ const TYPE_BY_SEVERITY: Record = { diff --git a/src/shared/build-issues.ts b/src/backend/features/build-checker/issues.ts similarity index 99% rename from src/shared/build-issues.ts rename to src/backend/features/build-checker/issues.ts index 38732017f..08ba614a0 100644 --- a/src/shared/build-issues.ts +++ b/src/backend/features/build-checker/issues.ts @@ -5,7 +5,7 @@ import { AUTO_INDEX_THRESHOLD, MAX_PART_NUMBER_CONFIGURATIONS -} from "./configuration-combinations"; +} from "../configurations/combinations"; export enum BuildIssueSeverity { /** A potential issue that is usually fine, e.g. no vendors parsed. */ diff --git a/src/backend/routes/build-status.test.ts b/src/backend/features/build-checker/routes.test.ts similarity index 93% rename from src/backend/routes/build-status.test.ts rename to src/backend/features/build-checker/routes.test.ts index 0752117dd..6e729df24 100644 --- a/src/backend/routes/build-status.test.ts +++ b/src/backend/features/build-checker/routes.test.ts @@ -1,7 +1,7 @@ import { eq } from "drizzle-orm"; import { env } from "cloudflare:workers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { group, insertables } from "../../shared/schema"; +import { group, insertables } from "../../db/schema"; import { TEST_GROUP_ID, TEST_LIBRARY_ID, @@ -9,9 +9,9 @@ import { createTestApp, resetDb, seedPartStudio -} from "../../__test_utils__"; -import { getDb } from "../db"; -import type { LibraryBuildStatus } from "../../shared/build-status-dto"; +} from "../../../__test_utils__"; +import { getDb } from "../../db/client"; +import type { LibraryBuildStatus } from "./dto"; const db = getDb(env.DB); diff --git a/src/backend/routes/build-status.ts b/src/backend/features/build-checker/routes.ts similarity index 89% rename from src/backend/routes/build-status.ts rename to src/backend/features/build-checker/routes.ts index d05c84885..96f7c06e5 100644 --- a/src/backend/routes/build-status.ts +++ b/src/backend/features/build-checker/routes.ts @@ -1,11 +1,15 @@ import { asc, eq, inArray } from "drizzle-orm"; -import { CachePolicy, cacheMiddleware } from "../cache"; -import { getApp } from "../context"; -import { getLibraryParam, libraryRoute } from "../route-params"; -import { getDb } from "../db"; -import { requireEditorMiddleware } from "../access-level-utils"; -import { group, insertables, configurations } from "../../shared/schema"; -import type { LibraryBuildStatus, GroupBuildStatus, InsertableBuildStatus } from "../../shared/build-status-dto"; +import { CachePolicy, cacheMiddleware } from "../../lib/cache"; +import { getApp } from "../../lib/context"; +import { getLibraryParam, libraryRoute } from "../../lib/route-params"; +import { getDb } from "../../db/client"; +import { requireEditorMiddleware } from "../auth/access-control"; +import { group, insertables, configurations } from "../../db/schema"; +import type { + LibraryBuildStatus, + GroupBuildStatus, + InsertableBuildStatus +} from "./dto"; export const buildStatusRoutes = getApp(); diff --git a/src/shared/canonical-configuration.test.ts b/src/backend/features/configurations/canonical.test.ts similarity index 96% rename from src/shared/canonical-configuration.test.ts rename to src/backend/features/configurations/canonical.test.ts index 316fa88b1..9e847fe6f 100644 --- a/src/shared/canonical-configuration.test.ts +++ b/src/backend/features/configurations/canonical.test.ts @@ -5,14 +5,14 @@ import { canonicalConfigurationKey, canonicalizeConfiguration, encodeCanonicalConfiguration -} from "./canonical-configuration"; -import { ParameterValues, VisibilityType } from "./configuration-models"; -import { QuantityType, Unit } from "./configuration-enums"; +} from "./canonical"; +import { ParameterValues, VisibilityType } from "./models"; +import { QuantityType, Unit } from "./enums"; import { boolParam, enumParam, quantityParam -} from "../__test_utils__/configuration-fixtures"; +} from "../../../__test_utils__/configuration-fixtures"; /** The key of an already-canonical selection, which is what callers compare. */ function keyOf(canonicalConfiguration: ParameterValues): string { diff --git a/src/shared/canonical-configuration.ts b/src/backend/features/configurations/canonical.ts similarity index 96% rename from src/shared/canonical-configuration.ts rename to src/backend/features/configurations/canonical.ts index 696f85042..069424f20 100644 --- a/src/shared/canonical-configuration.ts +++ b/src/backend/features/configurations/canonical.ts @@ -6,9 +6,9 @@ import { type ConfigurationParameter, ParameterType, type ParameterValues -} from "./configuration-models"; -import { QuantityType, Unit, getUnitDisplayStr } from "./configuration-enums"; -import { evaluateCondition } from "./configuration-utils"; +} from "./models"; +import { QuantityType, Unit, getUnitDisplayStr } from "./enums"; +import { evaluateCondition } from "./utils"; import { evaluateBaseValue } from "./input-parser"; /** The element default, which is what an empty canonical configuration encodes. */ diff --git a/src/shared/configuration-combinations.test.ts b/src/backend/features/configurations/combinations.test.ts similarity index 97% rename from src/shared/configuration-combinations.test.ts rename to src/backend/features/configurations/combinations.test.ts index 0ebfdff52..d44b250e7 100644 --- a/src/shared/configuration-combinations.test.ts +++ b/src/backend/features/configurations/combinations.test.ts @@ -7,7 +7,7 @@ import { isIndexedParameter, isIndexingEnabled, MAX_PART_NUMBER_CONFIGURATIONS -} from "./configuration-combinations"; +} from "./combinations"; import { OptionVisibilityType, ConfigurationParameter, @@ -16,9 +16,12 @@ import { StringParameter, VisibilityCondition, VisibilityType -} from "./configuration-models"; -import { boolParam, enumParam } from "../__test_utils__/configuration-fixtures"; -import { QuantityType, Unit } from "./configuration-enums"; +} from "./models"; +import { + boolParam, + enumParam +} from "../../../__test_utils__/configuration-fixtures"; +import { QuantityType, Unit } from "./enums"; function quantityParam(id: string): QuantityParameter { return { diff --git a/src/shared/configuration-combinations.ts b/src/backend/features/configurations/combinations.ts similarity index 97% rename from src/shared/configuration-combinations.ts rename to src/backend/features/configurations/combinations.ts index 0aea56057..2993a4c77 100644 --- a/src/shared/configuration-combinations.ts +++ b/src/backend/features/configurations/combinations.ts @@ -8,8 +8,8 @@ import { ConfigurationParameter, EnumParameter, ParameterType -} from "./configuration-models"; -import { evaluateCondition, getVisibleOptions } from "./configuration-utils"; +} from "./models"; +import { evaluateCondition, getVisibleOptions } from "./utils"; /** * The most combinations we enumerate for one insertable; beyond it nothing is diff --git a/src/shared/configuration-enums.ts b/src/backend/features/configurations/enums.ts similarity index 100% rename from src/shared/configuration-enums.ts rename to src/backend/features/configurations/enums.ts diff --git a/src/shared/input-parser.test.ts b/src/backend/features/configurations/input-parser.test.ts similarity index 98% rename from src/shared/input-parser.test.ts rename to src/backend/features/configurations/input-parser.test.ts index 73e5ac616..2e6d820b2 100644 --- a/src/shared/input-parser.test.ts +++ b/src/backend/features/configurations/input-parser.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { QuantityType, Unit } from "./configuration-enums"; +import { QuantityType, Unit } from "./enums"; import { evaluateExpression, EvaluateOptions, diff --git a/src/shared/input-parser.ts b/src/backend/features/configurations/input-parser.ts similarity index 99% rename from src/shared/input-parser.ts rename to src/backend/features/configurations/input-parser.ts index a849f3c7f..7d9848d58 100644 --- a/src/shared/input-parser.ts +++ b/src/backend/features/configurations/input-parser.ts @@ -11,7 +11,7 @@ import { seq, tok } from "typescript-parsec"; -import { getUnitDisplayStr, QuantityType, Unit } from "./configuration-enums"; +import { getUnitDisplayStr, QuantityType, Unit } from "./enums"; class ParseError extends Error { constructor(message: string) { diff --git a/src/shared/configuration-models.ts b/src/backend/features/configurations/models.ts similarity index 98% rename from src/shared/configuration-models.ts rename to src/backend/features/configurations/models.ts index 866fcceac..89d4cf9c2 100644 --- a/src/shared/configuration-models.ts +++ b/src/backend/features/configurations/models.ts @@ -1,4 +1,4 @@ -import { LogicalOp, QuantityType, Unit } from "./configuration-enums"; +import { LogicalOp, QuantityType, Unit } from "./enums"; /** Discriminator of a parsed configuration parameter. */ export enum ParameterType { diff --git a/src/backend/parse/parse-configuration.test.ts b/src/backend/features/configurations/parse-configuration.test.ts similarity index 98% rename from src/backend/parse/parse-configuration.test.ts rename to src/backend/features/configurations/parse-configuration.test.ts index 6b75a0ea7..59ff22199 100644 --- a/src/backend/parse/parse-configuration.test.ts +++ b/src/backend/features/configurations/parse-configuration.test.ts @@ -5,20 +5,16 @@ import { ParameterType, VisibilityCondition, VisibilityType -} from "../../shared/configuration-models"; -import { - LogicalOp, - QuantityType, - Unit -} from "../../shared/configuration-enums"; -import { evaluateCondition } from "../../shared/configuration-utils"; +} from "./models"; +import { LogicalOp, QuantityType, Unit } from "./enums"; +import { evaluateCondition } from "./utils"; import { parseOnshapeConfiguration } from "./parse-configuration"; import { OnshapeConfigurationResponse, OnshapeOptionVisibilityConditionType, OnshapeParameterType, OnshapeVisibilityConditionType -} from "../onshape-api/onshape-types"; +} from "../../lib/onshape/types"; /** No-op visibility condition Onshape attaches to always-visible parameters. */ const NONE = { btType: OnshapeVisibilityConditionType.NONE } as const; diff --git a/src/backend/parse/parse-configuration.ts b/src/backend/features/configurations/parse-configuration.ts similarity index 97% rename from src/backend/parse/parse-configuration.ts rename to src/backend/features/configurations/parse-configuration.ts index 767e0ff73..2f30d3eaf 100644 --- a/src/backend/parse/parse-configuration.ts +++ b/src/backend/features/configurations/parse-configuration.ts @@ -6,8 +6,8 @@ import { ParameterType, type VisibilityCondition, VisibilityType -} from "../../shared/configuration-models"; -import { getUnitDisplayStr } from "../../shared/configuration-enums"; +} from "./models"; +import { getUnitDisplayStr } from "./enums"; import { type OnshapeConfigurationResponse, type OnshapeEnumOptionVisibilityConditionList, @@ -15,7 +15,7 @@ import { OnshapeParameterType, type OnshapeVisibilityCondition, OnshapeVisibilityConditionType -} from "../onshape-api/onshape-types"; +} from "../../lib/onshape/types"; function parseVisibilityCondition( onshapeCondition: OnshapeVisibilityCondition | undefined diff --git a/src/backend/parse/parse-configuration-records.test.ts b/src/backend/features/configurations/records.test.ts similarity index 94% rename from src/backend/parse/parse-configuration-records.test.ts rename to src/backend/features/configurations/records.test.ts index 1059aa392..692f25c8f 100644 --- a/src/backend/parse/parse-configuration-records.test.ts +++ b/src/backend/features/configurations/records.test.ts @@ -1,26 +1,23 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { countConfigurations } from "../../shared/configuration-combinations"; -import * as PartsEndpoints from "../onshape-api/endpoints/parts"; -import * as MetadataEndpoints from "../onshape-api/endpoints/metadata"; -import { OnshapeApi } from "../onshape-api/onshape-api"; +import { countConfigurations } from "./combinations"; +import * as PartsEndpoints from "../../lib/onshape/endpoints/parts"; +import * as MetadataEndpoints from "../../lib/onshape/endpoints/metadata"; +import { OnshapeApi } from "../../lib/onshape/client"; import type { OnshapeMetadataObject, OnshapePart -} from "../onshape-api/onshape-types"; -import { ElementPath } from "../../shared/onshape-path"; -import { - ParameterValues, - ConfigurationParameter -} from "../../shared/configuration-models"; -import { enumParam } from "../../__test_utils__/configuration-fixtures"; -import { ElementType } from "../../shared/element-type"; -import { BuildIssueType } from "../../shared/build-issues"; +} from "../../lib/onshape/types"; +import { ElementPath } from "../../lib/onshape/path"; +import { ParameterValues, ConfigurationParameter } from "./models"; +import { enumParam } from "../../../__test_utils__/configuration-fixtures"; +import { ElementType } from "../../lib/onshape/element-type"; +import { BuildIssueType } from "../build-checker/issues"; import { decideIndexing, parseAssemblyRecord, parseConfigurationRecords, parsePartStudioRecord -} from "./parse-configuration-records"; +} from "./records"; const PATH: ElementPath = { documentId: "d", diff --git a/src/backend/parse/parse-configuration-records.ts b/src/backend/features/configurations/records.ts similarity index 95% rename from src/backend/parse/parse-configuration-records.ts rename to src/backend/features/configurations/records.ts index dfe7bab08..7edcdd285 100644 --- a/src/backend/parse/parse-configuration-records.ts +++ b/src/backend/features/configurations/records.ts @@ -2,36 +2,36 @@ * Probes an insertable's configurations for the metadata we store. Every probe * is kept: search dedupes itself, and build checks read the ones it drops. */ -import { OnshapeApi } from "../onshape-api/onshape-api"; -import { ElementPath } from "../../shared/onshape-path"; -import { ElementType } from "../../shared/element-type"; +import { OnshapeApi } from "../../lib/onshape/client"; +import { ElementPath } from "../../lib/onshape/path"; +import { ElementType } from "../../lib/onshape/element-type"; import { ParameterValues, ConfigurationParameter, ConfigurationRecord -} from "../../shared/configuration-models"; +} from "./models"; import { addBuildIssue, type BuildIssue, BuildIssueType -} from "../../shared/build-issues"; +} from "../build-checker/issues"; import { countConfigurations, IndexingBand, isIndexingEnabled -} from "../../shared/configuration-combinations"; -import { canonicalizeConfiguration } from "../../shared/canonical-configuration"; -import { getParts } from "../onshape-api/endpoints/parts"; -import { getElementMetadata } from "../onshape-api/endpoints/metadata"; +} from "./combinations"; +import { canonicalizeConfiguration } from "./canonical"; +import { getParts } from "../../lib/onshape/endpoints/parts"; +import { getElementMetadata } from "../../lib/onshape/endpoints/metadata"; import type { OnshapeMetadataObject, OnshapePart -} from "../onshape-api/onshape-types"; +} from "../../lib/onshape/types"; import { type LoadContext, getOnshapeApiFromContext -} from "../load/load-common"; -import { ONSHAPE_STEP_RETRIES } from "../load/load-steps"; +} from "../library/workflows/context"; +import { ONSHAPE_STEP_RETRIES } from "../library/workflows/steps"; /** Configurations fetched per workflow step. */ const BATCH_SIZE = 20; diff --git a/src/backend/routes/configurations.test.ts b/src/backend/features/configurations/routes.test.ts similarity index 92% rename from src/backend/routes/configurations.test.ts rename to src/backend/features/configurations/routes.test.ts index e49afecc8..6b9b24a97 100644 --- a/src/backend/routes/configurations.test.ts +++ b/src/backend/features/configurations/routes.test.ts @@ -1,6 +1,6 @@ import { env } from "cloudflare:workers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { QuantityType, Unit } from "../../shared/configuration-enums"; +import { QuantityType, Unit } from "./enums"; import { TEST_PART_STUDIO_ID, createTestApp, @@ -10,9 +10,9 @@ import { seedPartStudio, TEST_INSTANCE_PATH, TEST_PARAMETERS -} from "../../__test_utils__"; -import { getDb } from "../db"; -import * as DocumentEndpoints from "../onshape-api/endpoints/documents"; +} from "../../../__test_utils__"; +import { getDb } from "../../db/client"; +import * as DocumentEndpoints from "../../lib/onshape/endpoints/documents"; const db = getDb(env.DB); diff --git a/src/backend/routes/configurations.ts b/src/backend/features/configurations/routes.ts similarity index 82% rename from src/backend/routes/configurations.ts rename to src/backend/features/configurations/routes.ts index 6307a3872..633ee0e50 100644 --- a/src/backend/routes/configurations.ts +++ b/src/backend/features/configurations/routes.ts @@ -1,16 +1,13 @@ import { eq } from "drizzle-orm"; -import { CachePolicy, cacheMiddleware } from "../cache"; -import { getApp } from "../context"; -import { getDb } from "../db"; -import { getUnitInfo } from "../onshape-api/endpoints/documents"; -import { configurations } from "../../shared/schema"; -import { - type ConfigurationResult, - type UnitInfo -} from "../../shared/configuration-models"; -import { toSearchRecords } from "../../shared/search"; -import { QuantityType, type Unit } from "../../shared/configuration-enums"; -import { isInstancePath } from "../../shared/onshape-path"; +import { CachePolicy, cacheMiddleware } from "../../lib/cache"; +import { getApp } from "../../lib/context"; +import { getDb } from "../../db/client"; +import { getUnitInfo } from "../../lib/onshape/endpoints/documents"; +import { configurations } from "../../db/schema"; +import { type ConfigurationResult, type UnitInfo } from "./models"; +import { toSearchRecords } from "../search/search-index"; +import { QuantityType, type Unit } from "./enums"; +import { isInstancePath } from "../../lib/onshape/path"; import { HTTPException } from "hono/http-exception"; import { HttpStatus } from "http-status-ts"; diff --git a/src/shared/configuration-utils.test.ts b/src/backend/features/configurations/utils.test.ts similarity index 92% rename from src/shared/configuration-utils.test.ts rename to src/backend/features/configurations/utils.test.ts index 009987ed6..6430ae18f 100644 --- a/src/shared/configuration-utils.test.ts +++ b/src/backend/features/configurations/utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { findRecordForConfiguration } from "./configuration-utils"; -import { SearchRecord } from "./configuration-models"; +import { findRecordForConfiguration } from "./utils"; +import { SearchRecord } from "./models"; function rec( configuration: Record, diff --git a/src/shared/configuration-utils.ts b/src/backend/features/configurations/utils.ts similarity index 98% rename from src/shared/configuration-utils.ts rename to src/backend/features/configurations/utils.ts index cbac1ac3e..5354afa2f 100644 --- a/src/shared/configuration-utils.ts +++ b/src/backend/features/configurations/utils.ts @@ -10,8 +10,8 @@ import { UnitInfo, VisibilityCondition, VisibilityType -} from "./configuration-models"; -import { LogicalOp, QuantityType, Unit } from "./configuration-enums"; +} from "./models"; +import { LogicalOp, QuantityType, Unit } from "./enums"; import { type EvaluateOptions, valueWithUnits } from "./input-parser"; /** diff --git a/src/shared/favorites-dto.ts b/src/backend/features/favorites/dto.ts similarity index 82% rename from src/shared/favorites-dto.ts rename to src/backend/features/favorites/dto.ts index e7c76745a..8bf3e53f9 100644 --- a/src/shared/favorites-dto.ts +++ b/src/backend/features/favorites/dto.ts @@ -1,5 +1,5 @@ -import { ParameterValues } from "./configuration-models"; -import { LibraryId } from "./library-id"; +import { ParameterValues } from "../configurations/models"; +import { LibraryId } from "../library/library-id"; export interface Favorite { id: string; diff --git a/src/backend/routes/favorites.test.ts b/src/backend/features/favorites/routes.test.ts similarity index 98% rename from src/backend/routes/favorites.test.ts rename to src/backend/features/favorites/routes.test.ts index 706265df5..cedd720f3 100644 --- a/src/backend/routes/favorites.test.ts +++ b/src/backend/features/favorites/routes.test.ts @@ -1,7 +1,7 @@ import { asc, eq } from "drizzle-orm"; import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it } from "vitest"; -import { favorites } from "../../shared/schema"; +import { favorites } from "../../db/schema"; import { TEST_ASSEMBLY_ID, TEST_LIBRARY_ID, @@ -13,8 +13,8 @@ import { seedFavorite, seedPartStudio, seedTestData -} from "../../__test_utils__"; -import { getDb } from "../db"; +} from "../../../__test_utils__"; +import { getDb } from "../../db/client"; const db = getDb(env.DB); const favoritesUrl = `/api/favorites/library/${TEST_LIBRARY_ID}`; diff --git a/src/backend/routes/favorites.ts b/src/backend/features/favorites/routes.ts similarity index 90% rename from src/backend/routes/favorites.ts rename to src/backend/features/favorites/routes.ts index b7ecef8f5..0bbda450f 100644 --- a/src/backend/routes/favorites.ts +++ b/src/backend/features/favorites/routes.ts @@ -1,14 +1,14 @@ import { and, asc, eq } from "drizzle-orm"; -import { cacheMiddleware } from "../cache"; -import { getApp } from "../context"; -import { getLibraryParam, libraryRoute } from "../route-params"; -import { type Db, getDb } from "../db"; -import { users, favorites } from "../../shared/schema"; -import type { Favorite, FavoritesData } from "../../shared/favorites-dto"; -import type { LibraryId } from "../../shared/library-id"; +import { cacheMiddleware } from "../../lib/cache"; +import { getApp } from "../../lib/context"; +import { getLibraryParam, libraryRoute } from "../../lib/route-params"; +import { type Db, getDb } from "../../db/client"; +import { users, favorites } from "../../db/schema"; +import type { Favorite, FavoritesData } from "./dto"; +import type { LibraryId } from "../library/library-id"; import { HttpStatus } from "http-status-ts"; -import { type ParameterValues } from "../../shared/configuration-models"; -import { requireSignInMiddleware } from "../sign-in-utils"; +import { type ParameterValues } from "../configurations/models"; +import { requireSignInMiddleware } from "../auth/sign-in"; export const favoriteRoutes = getApp(); diff --git a/src/backend/library-data.test.ts b/src/backend/features/library/db.test.ts similarity index 93% rename from src/backend/library-data.test.ts rename to src/backend/features/library/db.test.ts index b35383cec..d4900946d 100644 --- a/src/backend/library-data.test.ts +++ b/src/backend/features/library/db.test.ts @@ -1,16 +1,16 @@ import { env } from "cloudflare:workers"; import { asc } from "drizzle-orm"; import { beforeEach, describe, expect, it } from "vitest"; -import { getDb } from "./db"; -import { group } from "../shared/schema"; +import { getDb } from "../../db/client"; +import { group } from "../../db/schema"; import { resetDb, seedGroup, seedLibrary, TEST_GROUP_ID, TEST_LIBRARY_ID -} from "../__test_utils__"; -import { placeNewGroup } from "./library-data"; +} from "../../../__test_utils__"; +import { placeNewGroup } from "./db"; const db = getDb(env.DB); diff --git a/src/backend/library-data.ts b/src/backend/features/library/db.ts similarity index 95% rename from src/backend/library-data.ts rename to src/backend/features/library/db.ts index 4e60135fa..bfdc693de 100644 --- a/src/backend/library-data.ts +++ b/src/backend/features/library/db.ts @@ -1,15 +1,10 @@ import { and, asc, eq, sql } from "drizzle-orm"; -import { type Db } from "./db"; -import { - libraries, - group, - insertables, - configurations -} from "../shared/schema"; -import { LibraryId } from "../shared/library-id"; -import { InsertableOut, LibraryOut, Insertables, Groups } from "../shared/library-dto"; -import { ConfigurationRecord } from "../shared/configuration-models"; -import { buildSearchDb } from "../shared/search"; +import { type Db } from "../../db/client"; +import { libraries, group, insertables, configurations } from "../../db/schema"; +import { LibraryId } from "./library-id"; +import { InsertableOut, LibraryOut, Insertables, Groups } from "./dto"; +import { ConfigurationRecord } from "../configurations/models"; +import { buildSearchDb } from "../search/search-index"; /** * Assembles the full `LibraryOut` (groups + insertables, in sort order) for a diff --git a/src/shared/library-dto.ts b/src/backend/features/library/dto.ts similarity index 89% rename from src/shared/library-dto.ts rename to src/backend/features/library/dto.ts index 11bf63f77..4a0087d46 100644 --- a/src/shared/library-dto.ts +++ b/src/backend/features/library/dto.ts @@ -1,5 +1,5 @@ -import { ElementPath, InstancePath } from "./onshape-path"; -import { ElementType } from "./element-type"; +import { ElementPath, InstancePath } from "../../lib/onshape/path"; +import { ElementType } from "../../lib/onshape/element-type"; import { Vendor } from "./vendors"; export interface InsertableOut { @@ -46,4 +46,3 @@ export interface LibraryOut { export type JobStatus = | { running: false } | { running: true; runningForMs: number }; - diff --git a/src/backend/routes/groups.test.ts b/src/backend/features/library/groups/routes.test.ts similarity index 95% rename from src/backend/routes/groups.test.ts rename to src/backend/features/library/groups/routes.test.ts index 10bd8d7a0..c9bee3905 100644 --- a/src/backend/routes/groups.test.ts +++ b/src/backend/features/library/groups/routes.test.ts @@ -1,7 +1,7 @@ import { asc, eq } from "drizzle-orm"; import { env } from "cloudflare:workers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { favorites, group, insertables } from "../../shared/schema"; +import { favorites, group, insertables } from "../../../db/schema"; import { TEST_GROUP_ID, TEST_LIBRARY_ID, @@ -11,14 +11,14 @@ import { resetDb, seedGroup, seedTestData -} from "../../__test_utils__"; +} from "../../../../__test_utils__"; import MiniSearch from "minisearch"; -import { getDb } from "../db"; -import type { JobStatus } from "../../shared/library-dto"; -import { searchIndexKey } from "../library-data"; -import { SEARCH_OPTIONS, type SearchDocument } from "../../shared/search"; -import * as DocumentsEndpoint from "../onshape-api/endpoints/documents"; -import * as JobTracker from "../load/job-tracker"; +import { getDb } from "../../../db/client"; +import type { JobStatus } from "../dto"; +import { searchIndexKey } from "../db"; +import { SEARCH_OPTIONS, type SearchDocument } from "../../search/search-index"; +import * as DocumentsEndpoint from "../../../lib/onshape/endpoints/documents"; +import * as JobTracker from "../workflows/job-tracker"; const db = getDb(env.DB); diff --git a/src/backend/routes/groups.ts b/src/backend/features/library/groups/routes.ts similarity index 92% rename from src/backend/routes/groups.ts rename to src/backend/features/library/groups/routes.ts index 1f4d3dad4..9189ba782 100644 --- a/src/backend/routes/groups.ts +++ b/src/backend/features/library/groups/routes.ts @@ -1,16 +1,20 @@ import { and, eq, inArray } from "drizzle-orm"; -import { cacheMiddleware } from "../cache"; -import { getApp } from "../context"; -import { getLibraryParam, libraryRoute } from "../route-params"; -import { getDb } from "../db"; -import { getSessionId } from "../auth-session"; -import { getDocument } from "../onshape-api/endpoints/documents"; -import { requireEditorMiddleware } from "../access-level-utils"; -import { type DocumentPath } from "../../shared/onshape-path"; -import { group, insertables, libraries, favorites } from "../../shared/schema"; -import { bumpLibraryVersion, rebuildSearchDb } from "../library-data"; +import { cacheMiddleware } from "../../../lib/cache"; +import { getApp } from "../../../lib/context"; +import { getLibraryParam, libraryRoute } from "../../../lib/route-params"; +import { getDb } from "../../../db/client"; +import { getSessionId } from "../../auth/session"; +import { getDocument } from "../../../lib/onshape/endpoints/documents"; +import { requireEditorMiddleware } from "../../auth/access-control"; +import { type DocumentPath } from "../../../lib/onshape/path"; +import { group, insertables, libraries, favorites } from "../../../db/schema"; +import { bumpLibraryVersion, rebuildSearchDb } from "../db"; import { HttpStatus } from "http-status-ts"; -import { getJobStatus, isReloadRunning, trackJob } from "../load/job-tracker"; +import { + getJobStatus, + isReloadRunning, + trackJob +} from "../workflows/job-tracker"; import { z } from "zod"; import { zValidator } from "@hono/zod-validator"; diff --git a/src/shared/fasten.ts b/src/backend/features/library/insertables/fasten.ts similarity index 100% rename from src/shared/fasten.ts rename to src/backend/features/library/insertables/fasten.ts diff --git a/src/backend/parse/insert-and-fasten.test.ts b/src/backend/features/library/insertables/parse-fasten.test.ts similarity index 97% rename from src/backend/parse/insert-and-fasten.test.ts rename to src/backend/features/library/insertables/parse-fasten.test.ts index 9bbf9afe1..ead589d21 100644 --- a/src/backend/parse/insert-and-fasten.test.ts +++ b/src/backend/features/library/insertables/parse-fasten.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; -import { ElementType } from "../../shared/element-type"; -import { FastenInfo, MateLocation } from "../../shared/fasten"; +import { ElementType } from "../../../lib/onshape/element-type"; +import { FastenInfo, MateLocation } from "./fasten"; import { getFastenQuery, parseFastenInfoFromPartStudio, parseFastenInfoFromAssembly -} from "./insert-and-fasten"; +} from "./parse-fasten"; describe("parseFastenInfoFromPartStudio", () => { it("returns Feature location with empty path when mate connector is found", () => { diff --git a/src/backend/parse/insert-and-fasten.ts b/src/backend/features/library/insertables/parse-fasten.ts similarity index 88% rename from src/backend/parse/insert-and-fasten.ts rename to src/backend/features/library/insertables/parse-fasten.ts index ff34e69d4..b65997512 100644 --- a/src/backend/parse/insert-and-fasten.ts +++ b/src/backend/features/library/insertables/parse-fasten.ts @@ -1,18 +1,18 @@ -import { ElementType } from "../../shared/element-type"; -import { FastenInfo, MateLocation } from "../../shared/fasten"; -import { type ElementPath } from "../../shared/onshape-path"; -import { getAssembly } from "../onshape-api/endpoints/assemblies"; -import { getFeatures } from "../onshape-api/endpoints/part-studios"; +import { ElementType } from "../../../lib/onshape/element-type"; +import { FastenInfo, MateLocation } from "./fasten"; +import { type ElementPath } from "../../../lib/onshape/path"; +import { getAssembly } from "../../../lib/onshape/endpoints/assemblies"; +import { getFeatures } from "../../../lib/onshape/endpoints/part-studios"; import { featureOccurrenceQuery, partStudioMateConnectorQuery -} from "../onshape-api/objects/assembly-features"; -import { OnshapeApi } from "../onshape-api/onshape-api"; +} from "../../../lib/onshape/objects/assembly-features"; +import { OnshapeApi } from "../../../lib/onshape/client"; import { OnshapeAssemblyDefinition, OnshapeAssemblyFeature, OnshapeFeatureListResponse -} from "../onshape-api/onshape-types"; +} from "../../../lib/onshape/types"; export async function parseFastenInfo( onshapeApi: OnshapeApi, diff --git a/src/backend/routes/insertables.test.ts b/src/backend/features/library/insertables/routes.test.ts similarity index 93% rename from src/backend/routes/insertables.test.ts rename to src/backend/features/library/insertables/routes.test.ts index 272679510..c36cc773f 100644 --- a/src/backend/routes/insertables.test.ts +++ b/src/backend/features/library/insertables/routes.test.ts @@ -1,10 +1,10 @@ import { eq } from "drizzle-orm"; import { env } from "cloudflare:workers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { configurations, insertables } from "../../shared/schema"; -import { ElementType } from "../../shared/element-type"; -import { Vendor } from "../../shared/vendors"; -import { BuildIssueType } from "../../shared/build-issues"; +import { configurations, insertables } from "../../../db/schema"; +import { ElementType } from "../../../lib/onshape/element-type"; +import { Vendor } from "../vendors"; +import { BuildIssueType } from "../../build-checker/issues"; import { MOCK_ONSHAPE_API, TEST_ASSEMBLY_ID, @@ -17,14 +17,14 @@ import { seedGroup, seedInsertable, seedPartStudio -} from "../../__test_utils__"; -import { getDb } from "../db"; -import * as PartStudioEndpoints from "../onshape-api/endpoints/part-studios"; -import * as AssemblyEndpoints from "../onshape-api/endpoints/assemblies"; -import * as PartsEndpoints from "../onshape-api/endpoints/parts"; -import { OnshapeRateLimitError } from "../onshape-api/onshape-api"; -import { AUTO_INDEX_THRESHOLD } from "../../shared/configuration-combinations"; -import { enumParam } from "../../__test_utils__/configuration-fixtures"; +} from "../../../../__test_utils__"; +import { getDb } from "../../../db/client"; +import * as PartStudioEndpoints from "../../../lib/onshape/endpoints/part-studios"; +import * as AssemblyEndpoints from "../../../lib/onshape/endpoints/assemblies"; +import * as PartsEndpoints from "../../../lib/onshape/endpoints/parts"; +import { OnshapeRateLimitError } from "../../../lib/onshape/client"; +import { AUTO_INDEX_THRESHOLD } from "../../configurations/combinations"; +import { enumParam } from "../../../../__test_utils__/configuration-fixtures"; const db = getDb(env.DB); diff --git a/src/backend/routes/insertables.ts b/src/backend/features/library/insertables/routes.ts similarity index 91% rename from src/backend/routes/insertables.ts rename to src/backend/features/library/insertables/routes.ts index 8b25e6c83..e89994c93 100644 --- a/src/backend/routes/insertables.ts +++ b/src/backend/features/library/insertables/routes.ts @@ -3,42 +3,42 @@ import { HTTPException } from "hono/http-exception"; import { zValidator } from "@hono/zod-validator"; import { HttpStatus } from "http-status-ts"; import z from "zod"; -import { getApp } from "../context"; -import { getInsertableParam, insertableRoute } from "../route-params"; -import { getDb, type Db } from "../db"; -import { requireEditorMiddleware } from "../access-level-utils"; -import { requireSignInMiddleware } from "../sign-in-utils"; -import { insertables, configurations } from "../../shared/schema"; -import { bumpLibraryVersion, rebuildSearchDb } from "../library-data"; -import { type ElementPath, INSTANCE_TYPES } from "../../shared/onshape-path"; +import { getApp } from "../../../lib/context"; +import { getInsertableParam, insertableRoute } from "../../../lib/route-params"; +import { getDb, type Db } from "../../../db/client"; +import { requireEditorMiddleware } from "../../auth/access-control"; +import { requireSignInMiddleware } from "../../auth/sign-in"; +import { insertables, configurations } from "../../../db/schema"; +import { bumpLibraryVersion, rebuildSearchDb } from "../db"; +import { type ElementPath, INSTANCE_TYPES } from "../../../lib/onshape/path"; import { type ConfigurationParameter, type ParameterValues -} from "../../shared/configuration-models"; +} from "../../configurations/models"; import { INDEXING_ISSUE_TYPES, NO_RECORDS, decideIndexing, parseConfigurationRecords, type ConfigurationRecordsResult -} from "../parse/parse-configuration-records"; -import { type OnshapeApi } from "../onshape-api/onshape-api"; -import { ElementType } from "../../shared/element-type"; -import { DerivedFeature } from "../onshape-api/objects/derive-feature"; -import { addPartStudioFeature } from "../onshape-api/endpoints/part-studios"; +} from "../../configurations/records"; +import { type OnshapeApi } from "../../../lib/onshape/client"; +import { ElementType } from "../../../lib/onshape/element-type"; +import { DerivedFeature } from "../../../lib/onshape/objects/derive-feature"; +import { addPartStudioFeature } from "../../../lib/onshape/endpoints/part-studios"; import { addElementToAssembly, addAssemblyFeature -} from "../onshape-api/endpoints/assemblies"; +} from "../../../lib/onshape/endpoints/assemblies"; import { PartType, type OnshapeElementType -} from "../onshape-api/endpoints/documents"; -import { encodeConfiguration } from "../onshape-api/endpoints/configurations"; -import { FastenMateBuilder } from "../onshape-api/objects/assembly-features"; -import { getFastenQuery, parseFastenInfo } from "../parse/insert-and-fasten"; -import { addBuildIssue, clearBuildIssue } from "../../shared/build-issues"; -import { checkIndexedPartNumber } from "../parse/build-checks"; +} from "../../../lib/onshape/endpoints/documents"; +import { encodeConfiguration } from "../../../lib/onshape/endpoints/configurations"; +import { FastenMateBuilder } from "../../../lib/onshape/objects/assembly-features"; +import { getFastenQuery, parseFastenInfo } from "./parse-fasten"; +import { addBuildIssue, clearBuildIssue } from "../../build-checker/issues"; +import { checkIndexedPartNumber } from "../../build-checker/checks"; export const insertableRoutes = getApp(); diff --git a/src/shared/library-id.ts b/src/backend/features/library/library-id.ts similarity index 100% rename from src/shared/library-id.ts rename to src/backend/features/library/library-id.ts diff --git a/src/backend/parse/parse-vendors.test.ts b/src/backend/features/library/parse-vendors.test.ts similarity index 94% rename from src/backend/parse/parse-vendors.test.ts rename to src/backend/features/library/parse-vendors.test.ts index 08266fe43..2b43db2a7 100644 --- a/src/backend/parse/parse-vendors.test.ts +++ b/src/backend/features/library/parse-vendors.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { Vendor } from "../../shared/vendors"; -import { ParameterType } from "../../shared/configuration-models"; -import { QuantityType, Unit } from "../../shared/configuration-enums"; +import { Vendor } from "./vendors"; +import { ParameterType } from "../configurations/models"; +import { QuantityType, Unit } from "../configurations/enums"; import { parseNameVendor, parseVendors } from "./parse-vendors"; describe("parseNameVendor", () => { diff --git a/src/backend/parse/parse-vendors.ts b/src/backend/features/library/parse-vendors.ts similarity index 92% rename from src/backend/parse/parse-vendors.ts rename to src/backend/features/library/parse-vendors.ts index 7fafd3886..f8aa9715c 100644 --- a/src/backend/parse/parse-vendors.ts +++ b/src/backend/features/library/parse-vendors.ts @@ -1,8 +1,8 @@ -import { Vendor, getVendorName } from "../../shared/vendors"; +import { Vendor, getVendorName } from "./vendors"; import { ParameterType, type ConfigurationParameter -} from "../../shared/configuration-models"; +} from "../configurations/models"; export function parseNameVendor(name: string): Vendor | undefined { const words = name.toUpperCase().match(/\b(\w+)\b/g) ?? []; diff --git a/src/backend/routes/library.test.ts b/src/backend/features/library/routes.test.ts similarity index 95% rename from src/backend/routes/library.test.ts rename to src/backend/features/library/routes.test.ts index 3aea6f649..1ef29282e 100644 --- a/src/backend/routes/library.test.ts +++ b/src/backend/features/library/routes.test.ts @@ -10,11 +10,11 @@ import { seedTestData, seedConfiguration, seedLibrary -} from "../../__test_utils__"; -import { getDb } from "../db"; -import { rebuildSearchDb, searchIndexKey } from "../library-data"; -import { LibraryOut } from "../../shared/library-dto"; -import { LibraryId } from "../../shared/library-id"; +} from "../../../__test_utils__"; +import { getDb } from "../../db/client"; +import { rebuildSearchDb, searchIndexKey } from "./db"; +import { LibraryOut } from "./dto"; +import { LibraryId } from "./library-id"; const db = getDb(env.DB); diff --git a/src/backend/routes/library.ts b/src/backend/features/library/routes.ts similarity index 82% rename from src/backend/routes/library.ts rename to src/backend/features/library/routes.ts index 9c4d267bc..134da21a3 100644 --- a/src/backend/routes/library.ts +++ b/src/backend/features/library/routes.ts @@ -1,10 +1,10 @@ import { eq } from "drizzle-orm"; -import { CachePolicy, cacheMiddleware } from "../cache"; -import { getApp } from "../context"; -import { getLibraryParam, libraryRoute } from "../route-params"; -import { getDb } from "../db"; -import { libraries } from "../../shared/schema"; -import { getLibraryOut, searchIndexKey } from "../library-data"; +import { CachePolicy, cacheMiddleware } from "../../lib/cache"; +import { getApp } from "../../lib/context"; +import { getLibraryParam, libraryRoute } from "../../lib/route-params"; +import { getDb } from "../../db/client"; +import { libraries } from "../../db/schema"; +import { getLibraryOut, searchIndexKey } from "./db"; export const libraryRoutes = getApp(); diff --git a/src/shared/vendors.ts b/src/backend/features/library/vendors.ts similarity index 100% rename from src/shared/vendors.ts rename to src/backend/features/library/vendors.ts diff --git a/src/backend/load/load-common.test.ts b/src/backend/features/library/workflows/context.test.ts similarity index 96% rename from src/backend/load/load-common.test.ts rename to src/backend/features/library/workflows/context.test.ts index bcd0e10a4..de97b5c46 100644 --- a/src/backend/load/load-common.test.ts +++ b/src/backend/features/library/workflows/context.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createLimiter } from "./load-common"; +import { createLimiter } from "./context"; describe("createLimiter", () => { it("never runs more than `max` tasks at once", async () => { diff --git a/src/backend/load/load-common.ts b/src/backend/features/library/workflows/context.ts similarity index 83% rename from src/backend/load/load-common.ts rename to src/backend/features/library/workflows/context.ts index edf5e58af..37c8c1ac7 100644 --- a/src/backend/load/load-common.ts +++ b/src/backend/features/library/workflows/context.ts @@ -1,10 +1,10 @@ import type { WorkflowStep } from "cloudflare:workers"; -import type { AppBindings } from "../context"; -import { getOnshapeApiFromSessionId } from "../auth-oauth"; -import type { OnshapeApi } from "../onshape-api/onshape-api"; -import type { ElementType } from "../../shared/element-type"; -import type { LibraryId } from "../../shared/library-id"; -import type { ElementPath, InstancePath } from "../../shared/onshape-path"; +import type { AppBindings } from "../../../lib/context"; +import { getOnshapeApiFromSessionId } from "../../auth/onshape-oauth"; +import type { OnshapeApi } from "../../../lib/onshape/client"; +import type { ElementType } from "../../../lib/onshape/element-type"; +import type { LibraryId } from "../library-id"; +import type { ElementPath, InstancePath } from "../../../lib/onshape/path"; /** How many insertables a load reads from Onshape at once. */ export const LOAD_CONCURRENCY = 15; diff --git a/src/backend/load/workflows.ts b/src/backend/features/library/workflows/index.ts similarity index 93% rename from src/backend/load/workflows.ts rename to src/backend/features/library/workflows/index.ts index aba99ea4b..b6e4e04d0 100644 --- a/src/backend/load/workflows.ts +++ b/src/backend/features/library/workflows/index.ts @@ -4,29 +4,25 @@ import { type WorkflowStep } from "cloudflare:workers"; import { eq } from "drizzle-orm"; -import type { AppBindings } from "../context"; -import { getDb } from "../db"; -import type { LibraryId } from "../../shared/library-id"; -import { - bumpLibraryVersion, - placeNewGroup, - rebuildSearchDb -} from "../library-data"; -import { getDocument } from "../onshape-api/endpoints/documents"; -import { getLatestVersionId } from "../onshape-api/endpoints/versions"; -import type { InstancePath } from "../../shared/onshape-path"; -import { group, insertables, libraries } from "../../shared/schema"; -import { uploadConfigurationThumbnails } from "../routes/thumbnails"; +import type { AppBindings } from "../../../lib/context"; +import { getDb } from "../../../db/client"; +import type { LibraryId } from "../library-id"; +import { bumpLibraryVersion, placeNewGroup, rebuildSearchDb } from "../db"; +import { getDocument } from "../../../lib/onshape/endpoints/documents"; +import { getLatestVersionId } from "../../../lib/onshape/endpoints/versions"; +import type { InstancePath } from "../../../lib/onshape/path"; +import { group, insertables, libraries } from "../../../db/schema"; +import { uploadConfigurationThumbnails } from "../../thumbnails/routes"; import { type GroupTarget, type LoadContext, LOAD_CONCURRENCY, createLimiter, getOnshapeApiFromContext -} from "./load-common"; +} from "./context"; import { untrackJob } from "./job-tracker"; import { loadGroup } from "./load-group"; -import { THUMBNAIL_STEP_RETRIES } from "./load-steps"; +import { THUMBNAIL_STEP_RETRIES } from "./steps"; export interface LoadLibraryParams { libraryId: LibraryId; diff --git a/src/backend/load/job-tracker.test.ts b/src/backend/features/library/workflows/job-tracker.test.ts similarity index 98% rename from src/backend/load/job-tracker.test.ts rename to src/backend/features/library/workflows/job-tracker.test.ts index 676543f29..4f2bd648b 100644 --- a/src/backend/load/job-tracker.test.ts +++ b/src/backend/features/library/workflows/job-tracker.test.ts @@ -6,7 +6,7 @@ import { trackJob, untrackJob } from "./job-tracker"; -import { TEST_LIBRARY_ID } from "../../__test_utils__"; +import { TEST_LIBRARY_ID } from "../../../../__test_utils__"; interface Job { id: string; diff --git a/src/backend/load/job-tracker.ts b/src/backend/features/library/workflows/job-tracker.ts similarity index 95% rename from src/backend/load/job-tracker.ts rename to src/backend/features/library/workflows/job-tracker.ts index 377c0fba8..137c85b4d 100644 --- a/src/backend/load/job-tracker.ts +++ b/src/backend/features/library/workflows/job-tracker.ts @@ -1,6 +1,6 @@ -import type { AppBindings } from "../context"; -import type { LibraryId } from "../../shared/library-id"; -import type { JobStatus } from "../../shared/library-dto"; +import type { AppBindings } from "../../../lib/context"; +import type { LibraryId } from "../library-id"; +import type { JobStatus } from "../dto"; /** * Backstop for a job that crashes before untracking itself; must outlast the diff --git a/src/backend/load/load-group.test.ts b/src/backend/features/library/workflows/load-group.test.ts similarity index 93% rename from src/backend/load/load-group.test.ts rename to src/backend/features/library/workflows/load-group.test.ts index 08f68e5f6..3b57c6db5 100644 --- a/src/backend/load/load-group.test.ts +++ b/src/backend/features/library/workflows/load-group.test.ts @@ -6,22 +6,22 @@ import { type OnshapeElement, OnshapeElementType, OnshapeFolderEntryType -} from "../onshape-api/onshape-types"; -import * as DocumentEndpoints from "../onshape-api/endpoints/documents"; -import * as ConfigurationEndpoints from "../onshape-api/endpoints/configurations"; -import * as PartsEndpoints from "../onshape-api/endpoints/parts"; -import { getDb } from "../db"; -import { group, insertables } from "../../shared/schema"; -import { BuildIssueType } from "../../shared/build-issues"; +} from "../../../lib/onshape/types"; +import * as DocumentEndpoints from "../../../lib/onshape/endpoints/documents"; +import * as ConfigurationEndpoints from "../../../lib/onshape/endpoints/configurations"; +import * as PartsEndpoints from "../../../lib/onshape/endpoints/parts"; +import { getDb } from "../../../db/client"; +import { group, insertables } from "../../../db/schema"; +import { BuildIssueType } from "../../build-checker/issues"; import { type StoredInsertable, findRemovedInsertables, loadGroup, selectInsertablesToLoad } from "./load-group"; -import type { GroupTarget, LoadContext } from "./load-common"; -import { LOAD_CONCURRENCY, createLimiter } from "./load-common"; -import * as LoadCommonModule from "./load-common"; +import type { GroupTarget, LoadContext } from "./context"; +import { LOAD_CONCURRENCY, createLimiter } from "./context"; +import * as LoadCommonModule from "./context"; import { FAKE_STEP, MOCK_ONSHAPE_API, @@ -30,7 +30,7 @@ import { resetDb, seedGroup, seedInsertable -} from "../../__test_utils__"; +} from "../../../../__test_utils__"; const GROUP: GroupTarget = { libraryId: TEST_LIBRARY_ID, diff --git a/src/backend/load/load-group.ts b/src/backend/features/library/workflows/load-group.ts similarity index 92% rename from src/backend/load/load-group.ts rename to src/backend/features/library/workflows/load-group.ts index 0047caea4..4882c771c 100644 --- a/src/backend/load/load-group.ts +++ b/src/backend/features/library/workflows/load-group.ts @@ -1,28 +1,28 @@ import { eq, inArray } from "drizzle-orm"; import type { BatchItem } from "drizzle-orm/batch"; -import { type Db, getDb } from "../db"; -import { ElementType } from "../../shared/element-type"; -import type { ThumbnailUrls } from "../../shared/thumbnail-types"; +import { type Db, getDb } from "../../../db/client"; +import { ElementType } from "../../../lib/onshape/element-type"; +import type { ThumbnailUrls } from "../../thumbnails/types"; import { addBuildIssue, type BuildIssue, BuildIssueType -} from "../../shared/build-issues"; -import { group, insertables } from "../../shared/schema"; -import { uploadDocumentThumbnails } from "../routes/thumbnails"; -import { getContents } from "../onshape-api/endpoints/documents"; -import type { OnshapeElement } from "../onshape-api/onshape-types"; -import { checkGroup } from "../parse/build-checks"; -import { parseInsertableTabs } from "../parse/parse-document-contents"; +} from "../../build-checker/issues"; +import { group, insertables } from "../../../db/schema"; +import { uploadDocumentThumbnails } from "../../thumbnails/routes"; +import { getContents } from "../../../lib/onshape/endpoints/documents"; +import type { OnshapeElement } from "../../../lib/onshape/types"; +import { checkGroup } from "../../build-checker/checks"; +import { parseInsertableTabs } from "./parse-document-contents"; import { loadInsertable } from "./load-insertable"; import { type GroupTarget, type InsertableTarget, type LoadContext, getOnshapeApiFromContext -} from "./load-common"; -import { uploadThumbnailsStep } from "./load-steps"; -import type { InstancePath } from "../../shared/onshape-path"; +} from "./context"; +import { uploadThumbnailsStep } from "./steps"; +import type { InstancePath } from "../../../lib/onshape/path"; export interface GroupLoadResult { loadedElements: number; diff --git a/src/backend/load/load-insertable.test.ts b/src/backend/features/library/workflows/load-insertable.test.ts similarity index 94% rename from src/backend/load/load-insertable.test.ts rename to src/backend/features/library/workflows/load-insertable.test.ts index 4c916b56d..4f63686e9 100644 --- a/src/backend/load/load-insertable.test.ts +++ b/src/backend/features/library/workflows/load-insertable.test.ts @@ -1,19 +1,19 @@ import { env } from "cloudflare:workers"; import { eq } from "drizzle-orm"; import { beforeEach, describe, expect, it } from "vitest"; -import { getDb } from "../db"; -import { configurations, insertables } from "../../shared/schema"; -import type { ConfigurationRecord } from "../../shared/configuration-models"; +import { getDb } from "../../../db/client"; +import { configurations, insertables } from "../../../db/schema"; +import type { ConfigurationRecord } from "../../configurations/models"; import { TEST_PARAMETERS, TEST_PART_STUDIO_ID, resetDb, seedGroup -} from "../../__test_utils__"; +} from "../../../../__test_utils__"; import { insertableTarget, parsedInsertable -} from "../../__test_utils__/insertable-fixtures"; +} from "../../../../__test_utils__/insertable-fixtures"; import { saveInsertable } from "./load-insertable"; const db = getDb(env.DB); diff --git a/src/backend/load/load-insertable.ts b/src/backend/features/library/workflows/load-insertable.ts similarity index 88% rename from src/backend/load/load-insertable.ts rename to src/backend/features/library/workflows/load-insertable.ts index 24268bea7..409172a0c 100644 --- a/src/backend/load/load-insertable.ts +++ b/src/backend/features/library/workflows/load-insertable.ts @@ -1,38 +1,38 @@ import { eq } from "drizzle-orm"; -import { type Db, getDb } from "../db"; +import { type Db, getDb } from "../../../db/client"; import type { Configuration, ConfigurationParameter -} from "../../shared/configuration-models"; +} from "../../configurations/models"; import { addBuildIssue, type BuildIssue, BuildIssueType -} from "../../shared/build-issues"; -import { ElementType } from "../../shared/element-type"; -import type { FastenInfo } from "../../shared/fasten"; -import type { ThumbnailUrls } from "../../shared/thumbnail-types"; -import type { Vendor } from "../../shared/vendors"; -import { configurations, insertables } from "../../shared/schema"; -import { uploadThumbnails } from "../routes/thumbnails"; -import { getConfiguration } from "../onshape-api/endpoints/configurations"; -import { getParts } from "../onshape-api/endpoints/parts"; -import { checkInsertable } from "../parse/build-checks"; -import { parseOnshapeConfiguration } from "../parse/parse-configuration"; -import { parseVendors } from "../parse/parse-vendors"; -import { parseFastenInfo } from "../parse/insert-and-fasten"; +} from "../../build-checker/issues"; +import { ElementType } from "../../../lib/onshape/element-type"; +import type { FastenInfo } from "../insertables/fasten"; +import type { ThumbnailUrls } from "../../thumbnails/types"; +import type { Vendor } from "../vendors"; +import { configurations, insertables } from "../../../db/schema"; +import { uploadThumbnails } from "../../thumbnails/routes"; +import { getConfiguration } from "../../../lib/onshape/endpoints/configurations"; +import { getParts } from "../../../lib/onshape/endpoints/parts"; +import { checkInsertable } from "../../build-checker/checks"; +import { parseOnshapeConfiguration } from "../../configurations/parse-configuration"; +import { parseVendors } from "../parse-vendors"; +import { parseFastenInfo } from "../insertables/parse-fasten"; import { NO_RECORDS, computeOpenComposite, decideIndexing, loadConfigurationRecords -} from "../parse/parse-configuration-records"; +} from "../../configurations/records"; import { type InsertableTarget, type LoadContext, getOnshapeApiFromContext -} from "./load-common"; -import { uploadThumbnailsStep } from "./load-steps"; +} from "./context"; +import { uploadThumbnailsStep } from "./steps"; /** * Exactly the columns a reload overwrites; the rest of the row is identity or diff --git a/src/backend/parse/parse-document-contents.test.ts b/src/backend/features/library/workflows/parse-document-contents.test.ts similarity index 98% rename from src/backend/parse/parse-document-contents.test.ts rename to src/backend/features/library/workflows/parse-document-contents.test.ts index 2feb16b30..88b93f3bc 100644 --- a/src/backend/parse/parse-document-contents.test.ts +++ b/src/backend/features/library/workflows/parse-document-contents.test.ts @@ -6,7 +6,7 @@ import { type OnshapeFolderEntry, OnshapeElementType, OnshapeFolderEntryType -} from "../onshape-api/onshape-types"; +} from "../../../lib/onshape/types"; import { parseInsertableTabs } from "./parse-document-contents"; function element( diff --git a/src/backend/parse/parse-document-contents.ts b/src/backend/features/library/workflows/parse-document-contents.ts similarity index 93% rename from src/backend/parse/parse-document-contents.ts rename to src/backend/features/library/workflows/parse-document-contents.ts index c987211d0..f843333d2 100644 --- a/src/backend/parse/parse-document-contents.ts +++ b/src/backend/features/library/workflows/parse-document-contents.ts @@ -1,13 +1,13 @@ /** * Extracts the insertable tabs from a document's contents listing. */ -import { ElementType } from "../../shared/element-type"; +import { ElementType } from "../../../lib/onshape/element-type"; import { type OnshapeDocumentContents, type OnshapeElement, type OnshapeFolderEntry, OnshapeFolderEntryType -} from "../onshape-api/onshape-types"; +} from "../../../lib/onshape/types"; const VALID_ELEMENT_TYPES = new Set([ ElementType.ASSEMBLY, diff --git a/src/backend/load/load-steps.test.ts b/src/backend/features/library/workflows/steps.test.ts similarity index 94% rename from src/backend/load/load-steps.test.ts rename to src/backend/features/library/workflows/steps.test.ts index a58ebb442..f173917c6 100644 --- a/src/backend/load/load-steps.test.ts +++ b/src/backend/features/library/workflows/steps.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { OnshapeRateLimitError } from "../onshape-api/onshape-api"; -import { NoSuchConfigurationError } from "../onshape-api/endpoints/thumbnails"; -import { ONSHAPE_STEP_RETRIES, THUMBNAIL_STEP_RETRIES } from "./load-steps"; +import { OnshapeRateLimitError } from "../../../lib/onshape/client"; +import { NoSuchConfigurationError } from "../../../lib/onshape/endpoints/thumbnails"; +import { ONSHAPE_STEP_RETRIES, THUMBNAIL_STEP_RETRIES } from "./steps"; /** The delay before the retry that follows attempt `attempt`. */ function thumbnailDelay(attempt: number, error = new Error("not rendered")) { diff --git a/src/backend/load/load-steps.ts b/src/backend/features/library/workflows/steps.ts similarity index 90% rename from src/backend/load/load-steps.ts rename to src/backend/features/library/workflows/steps.ts index cc4a4526d..1284adbe7 100644 --- a/src/backend/load/load-steps.ts +++ b/src/backend/features/library/workflows/steps.ts @@ -1,7 +1,7 @@ -import { OnshapeRateLimitError } from "../onshape-api/onshape-api"; -import type { ThumbnailUrls } from "../../shared/thumbnail-types"; -import { NoSuchConfigurationError } from "../onshape-api/endpoints/thumbnails"; -import type { LoadContext } from "./load-common"; +import { OnshapeRateLimitError } from "../../../lib/onshape/client"; +import type { ThumbnailUrls } from "../../thumbnails/types"; +import { NoSuchConfigurationError } from "../../../lib/onshape/endpoints/thumbnails"; +import type { LoadContext } from "./context"; /** The retry input a Workflow `delay` callback receives. */ interface RetryDelayInput { diff --git a/src/shared/search.ts b/src/backend/features/search/search-index.ts similarity index 97% rename from src/shared/search.ts rename to src/backend/features/search/search-index.ts index 2b0df8891..03b8833cd 100644 --- a/src/shared/search.ts +++ b/src/backend/features/search/search-index.ts @@ -3,9 +3,9 @@ * deserializes it with the same options. */ import MiniSearch, { Options } from "minisearch"; -import { LibraryOut } from "./library-dto"; -import { Vendor } from "./vendors"; -import { ConfigurationRecord, SearchRecord } from "./configuration-models"; +import { LibraryOut } from "../library/dto"; +import { Vendor } from "../library/vendors"; +import { ConfigurationRecord, SearchRecord } from "../configurations/models"; const deliminator = "^"; diff --git a/src/shared/thumbnails.ts b/src/backend/features/thumbnails/keys.ts similarity index 92% rename from src/shared/thumbnails.ts rename to src/backend/features/thumbnails/keys.ts index c3ffd534e..6222351f1 100644 --- a/src/shared/thumbnails.ts +++ b/src/backend/features/thumbnails/keys.ts @@ -2,8 +2,8 @@ import { DEFAULT_CANONICAL_CONFIGURATION, DEFAULT_CONFIGURATION_KEY -} from "./canonical-configuration"; -import { ThumbnailSize } from "./thumbnail-types"; +} from "../configurations/canonical"; +import { ThumbnailSize } from "./types"; /** Short on purpose: the real render can land at any moment and must take over. */ export const THUMBNAIL_FALLBACK_CACHE_TTL = 60; @@ -53,5 +53,5 @@ export function thumbnailUrl({ query.set("i", insertableId); } } - return `/api/thumbnail/${size}/${elementId}?${query}`; + return `/api/thumbnail/${size}/${elementId}?${query.toString()}`; } diff --git a/src/backend/routes/thumbnails.test.ts b/src/backend/features/thumbnails/routes.test.ts similarity index 97% rename from src/backend/routes/thumbnails.test.ts rename to src/backend/features/thumbnails/routes.test.ts index 007c47160..c2286fa88 100644 --- a/src/backend/routes/thumbnails.test.ts +++ b/src/backend/features/thumbnails/routes.test.ts @@ -1,19 +1,19 @@ import { env } from "cloudflare:workers"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createTestApp, jsonRequest } from "../../__test_utils__"; -import { ThumbnailSize } from "../../shared/thumbnail-types"; +import { createTestApp, jsonRequest } from "../../../__test_utils__"; +import { ThumbnailSize } from "./types"; import { THUMBNAIL_FALLBACK_CACHE_TTL, THUMBNAIL_FALLBACK_HEADER, thumbnailKey, thumbnailUrl -} from "../../shared/thumbnails"; +} from "./keys"; import { DEFAULT_CANONICAL_CONFIGURATION, canonicalConfigurationKey -} from "../../shared/canonical-configuration"; -import { uploadConfigurationThumbnails } from "./thumbnails"; -import type { OnshapeApi } from "../onshape-api/onshape-api"; +} from "../configurations/canonical"; +import { uploadConfigurationThumbnails } from "./routes"; +import type { OnshapeApi } from "../../lib/onshape/client"; const SIZE = ThumbnailSize.LARGE; const MICROVERSION = "mv-1"; diff --git a/src/backend/routes/thumbnails.ts b/src/backend/features/thumbnails/routes.ts similarity index 91% rename from src/backend/routes/thumbnails.ts rename to src/backend/features/thumbnails/routes.ts index 79dd190de..b8a7ab1bc 100644 --- a/src/backend/routes/thumbnails.ts +++ b/src/backend/features/thumbnails/routes.ts @@ -1,40 +1,48 @@ import { eq } from "drizzle-orm"; import { z } from "zod"; import { zValidator } from "@hono/zod-validator"; -import { CachePolicy, cacheMiddleware, immutableCacheControl, setCacheTtl } from "../cache"; -import { getApp } from "../context"; -import { getInsertableParam, insertableRoute } from "../route-params"; -import { getInsertableElementPath } from "./insertables"; -import { getDb } from "../db"; -import { requireEditorMiddleware } from "../access-level-utils"; -import { bumpLibraryVersion } from "../library-data"; +import { + CachePolicy, + cacheMiddleware, + immutableCacheControl, + setCacheTtl +} from "../../lib/cache"; +import { getApp } from "../../lib/context"; +import { getInsertableParam, insertableRoute } from "../../lib/route-params"; +import { getInsertableElementPath } from "../library/insertables/routes"; +import { getDb } from "../../db/client"; +import { requireEditorMiddleware } from "../auth/access-control"; +import { bumpLibraryVersion } from "../library/db"; import { getElementThumbnail, getThumbnailFromId, getThumbnailId -} from "../onshape-api/endpoints/thumbnails"; -import { getDocument, getContents } from "../onshape-api/endpoints/documents"; -import { type ElementPath, type InstancePath } from "../../shared/onshape-path"; -import { group, insertables } from "../../shared/schema"; +} from "../../lib/onshape/endpoints/thumbnails"; +import { + getDocument, + getContents +} from "../../lib/onshape/endpoints/documents"; +import { type ElementPath, type InstancePath } from "../../lib/onshape/path"; +import { group, insertables } from "../../db/schema"; import { HTTPException } from "hono/http-exception"; import { HttpStatus } from "http-status-ts"; -import { ThumbnailSize, ThumbnailUrls } from "../../shared/thumbnail-types"; +import { ThumbnailSize, ThumbnailUrls } from "./types"; import { THUMBNAIL_FALLBACK_CACHE_TTL, THUMBNAIL_FALLBACK_HEADER, thumbnailKey, thumbnailUrl -} from "../../shared/thumbnails"; +} from "./keys"; import { DEFAULT_CANONICAL_CONFIGURATION, DEFAULT_CONFIGURATION_KEY, canonicalConfigurationKey -} from "../../shared/canonical-configuration"; -import { OnshapeApi } from "../onshape-api/onshape-api"; -import type { AppContext } from "../context"; -import type { ThumbnailWorkflowParams } from "../load/workflows"; -import { getSessionId } from "../auth-session"; -import { BuildIssueType, clearBuildIssue } from "../../shared/build-issues"; +} from "../configurations/canonical"; +import { OnshapeApi } from "../../lib/onshape/client"; +import type { AppContext } from "../../lib/context"; +import type { ThumbnailWorkflowParams } from "../library/workflows/index"; +import { getSessionId } from "../auth/session"; +import { BuildIssueType, clearBuildIssue } from "../build-checker/issues"; /** Stores one rendered thumbnail, tagging it with what produced it. */ async function putThumbnail( diff --git a/src/shared/thumbnail-types.ts b/src/backend/features/thumbnails/types.ts similarity index 100% rename from src/shared/thumbnail-types.ts rename to src/backend/features/thumbnails/types.ts diff --git a/src/backend/routes/user.test.ts b/src/backend/features/users/routes.test.ts similarity index 87% rename from src/backend/routes/user.test.ts rename to src/backend/features/users/routes.test.ts index 180e650ad..09aa3399d 100644 --- a/src/backend/routes/user.test.ts +++ b/src/backend/features/users/routes.test.ts @@ -1,9 +1,9 @@ import { eq } from "drizzle-orm"; import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it } from "vitest"; -import { users } from "../../shared/schema"; -import { AccessLevel } from "../../shared/access-level"; -import { Theme } from "../../shared/settings"; +import { users } from "../../db/schema"; +import { AccessLevel } from "../auth/access-level"; +import { Theme } from "./settings"; import { TEST_USER_ID, createTestApp, @@ -11,8 +11,8 @@ import { resetDb, seedLibrary, seedUser -} from "../../__test_utils__"; -import { getDb } from "../db"; +} from "../../../__test_utils__"; +import { getDb } from "../../db/client"; const db = getDb(env.DB); diff --git a/src/backend/routes/user.ts b/src/backend/features/users/routes.ts similarity index 69% rename from src/backend/routes/user.ts rename to src/backend/features/users/routes.ts index c7ef100c1..ee27fe8d7 100644 --- a/src/backend/routes/user.ts +++ b/src/backend/features/users/routes.ts @@ -1,11 +1,11 @@ import { eq } from "drizzle-orm"; -import { cacheMiddleware } from "../cache"; -import { getApp } from "../context"; -import { getDb } from "../db"; -import { users } from "../../shared/schema"; -import type { AccessData } from "../../shared/access-level"; -import type { SettingsUpdate } from "../../shared/settings"; -import { isSignedIn, requireSignInMiddleware } from "../sign-in-utils"; +import { cacheMiddleware } from "../../lib/cache"; +import { getApp } from "../../lib/context"; +import { getDb } from "../../db/client"; +import { users } from "../../db/schema"; +import type { AccessData } from "../auth/access-level"; +import type { SettingsUpdate } from "./settings"; +import { isSignedIn, requireSignInMiddleware } from "../auth/sign-in"; export const userRoutes = getApp(); diff --git a/src/shared/settings.ts b/src/backend/features/users/settings.ts similarity index 87% rename from src/shared/settings.ts rename to src/backend/features/users/settings.ts index 8469f2993..7c07e0b90 100644 --- a/src/shared/settings.ts +++ b/src/backend/features/users/settings.ts @@ -1,4 +1,4 @@ -import { LibraryId } from "./library-id"; +import { LibraryId } from "../library/library-id"; export enum Theme { SYSTEM = "system", diff --git a/src/backend/index.ts b/src/backend/index.ts index 3f69df78f..911db041b 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -2,8 +2,8 @@ export { AddGroupWorkflow, LoadLibraryWorkflow, ThumbnailWorkflow -} from "./load/workflows"; -import { createApp } from "./create-app"; -import { productionServices } from "./services"; +} from "./features/library/workflows"; +import { createApp } from "./app"; +import { productionServices } from "./features/auth/services"; export default createApp(productionServices); diff --git a/src/backend/cache.ts b/src/backend/lib/cache.ts similarity index 100% rename from src/backend/cache.ts rename to src/backend/lib/cache.ts diff --git a/src/backend/context.ts b/src/backend/lib/context.ts similarity index 91% rename from src/backend/context.ts rename to src/backend/lib/context.ts index c9a360b6d..9fcbe64c8 100644 --- a/src/backend/context.ts +++ b/src/backend/lib/context.ts @@ -3,9 +3,9 @@ import type { AddGroupParams, LoadLibraryParams, ThumbnailWorkflowParams -} from "./load/workflows"; -import { type AccessLevel } from "../shared/access-level"; -import { type OAuthApi } from "./onshape-api/onshape-api"; +} from "../features/library/workflows/index"; +import { type AccessLevel } from "../features/auth/access-level"; +import { type OAuthApi } from "./onshape/client"; export interface AppBindings { DB: D1Database; diff --git a/src/backend/onshape-api/api-path.ts b/src/backend/lib/onshape/api-path.ts similarity index 95% rename from src/backend/onshape-api/api-path.ts rename to src/backend/lib/onshape/api-path.ts index 0ae144f97..dbb89947d 100644 --- a/src/backend/onshape-api/api-path.ts +++ b/src/backend/lib/onshape/api-path.ts @@ -1,4 +1,4 @@ -import { DocumentPath } from "../../shared/onshape-path"; +import { DocumentPath } from "./path"; export interface ApiPathOptions { endRoute?: string; diff --git a/src/backend/onshape-api/assertions.ts b/src/backend/lib/onshape/assertions.ts similarity index 87% rename from src/backend/onshape-api/assertions.ts rename to src/backend/lib/onshape/assertions.ts index 2ffeecaf5..2dc55933d 100644 --- a/src/backend/onshape-api/assertions.ts +++ b/src/backend/lib/onshape/assertions.ts @@ -1,4 +1,4 @@ -import { InstancePath, InstanceType } from "../../shared/onshape-path"; +import { InstancePath, InstanceType } from "./path"; export function assertInstanceType( path: InstancePath, diff --git a/src/backend/onshape-api/onshape-api.test.ts b/src/backend/lib/onshape/client.test.ts similarity index 94% rename from src/backend/onshape-api/onshape-api.test.ts rename to src/backend/lib/onshape/client.test.ts index e6b8648c8..d90e01f05 100644 --- a/src/backend/onshape-api/onshape-api.test.ts +++ b/src/backend/lib/onshape/client.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - OnshapeApi, - OnshapeApiError, - OnshapeRateLimitError -} from "./onshape-api"; +import { OnshapeApi, OnshapeApiError, OnshapeRateLimitError } from "./client"; /** Minimal concrete client whose `_request` returns a canned response. */ class TestApi extends OnshapeApi { diff --git a/src/backend/onshape-api/onshape-api.ts b/src/backend/lib/onshape/client.ts similarity index 99% rename from src/backend/onshape-api/onshape-api.ts rename to src/backend/lib/onshape/client.ts index 5780d2594..61bee19e5 100644 --- a/src/backend/onshape-api/onshape-api.ts +++ b/src/backend/lib/onshape/client.ts @@ -3,7 +3,7 @@ import { createSearchParams, type QueryOptions, type PostOptions -} from "../../shared/url-params"; +} from "../query-params"; // Constant across all environments (dev/cert/production), so hardcoded here // rather than duplicated as a per-environment var in wrangler.jsonc. diff --git a/src/shared/element-type.ts b/src/backend/lib/onshape/element-type.ts similarity index 100% rename from src/shared/element-type.ts rename to src/backend/lib/onshape/element-type.ts diff --git a/src/backend/onshape-api/endpoints/assemblies.ts b/src/backend/lib/onshape/endpoints/assemblies.ts similarity index 97% rename from src/backend/onshape-api/endpoints/assemblies.ts rename to src/backend/lib/onshape/endpoints/assemblies.ts index 9ba87f574..c65f739ec 100644 --- a/src/backend/onshape-api/endpoints/assemblies.ts +++ b/src/backend/lib/onshape/endpoints/assemblies.ts @@ -1,4 +1,4 @@ -import { OnshapeApi } from "../onshape-api"; +import { OnshapeApi } from "../client"; import { assertWorkspace } from "../assertions"; import { ElementPath, @@ -6,7 +6,7 @@ import { toElementApiObject, toElementApiPath, toInstanceApiPath -} from "../../../shared/onshape-path"; +} from "../path"; import { apiPath } from "../api-path"; import { encodeConfiguration } from "./configurations"; import { OnshapeElementType, PartType } from "./documents"; @@ -15,7 +15,7 @@ import { OnshapeAssemblyDefinition, OnshapeCreatedFeature, OnshapeInsertInstancesResponse -} from "../onshape-types"; +} from "../types"; /** Retrieves information about an assembly. */ export function getAssembly( diff --git a/src/backend/onshape-api/endpoints/configurations.ts b/src/backend/lib/onshape/endpoints/configurations.ts similarity index 92% rename from src/backend/onshape-api/endpoints/configurations.ts rename to src/backend/lib/onshape/endpoints/configurations.ts index c888a0499..1035da911 100644 --- a/src/backend/onshape-api/endpoints/configurations.ts +++ b/src/backend/lib/onshape/endpoints/configurations.ts @@ -1,11 +1,11 @@ -import { OnshapeApi } from "../onshape-api"; -import { ElementPath, toElementApiPath } from "../../../shared/onshape-path"; +import { OnshapeApi } from "../client"; +import { ElementPath, toElementApiPath } from "../path"; import { apiPath } from "../api-path"; import { OnshapeConfigurationInfo, OnshapeConfigurationParameter, OnshapeConfigurationResponse -} from "../onshape-types"; +} from "../types"; export function getConfiguration( client: OnshapeApi, diff --git a/src/backend/onshape-api/endpoints/documents.ts b/src/backend/lib/onshape/endpoints/documents.ts similarity index 98% rename from src/backend/onshape-api/endpoints/documents.ts rename to src/backend/lib/onshape/endpoints/documents.ts index d33d5ab22..b091339e5 100644 --- a/src/backend/onshape-api/endpoints/documents.ts +++ b/src/backend/lib/onshape/endpoints/documents.ts @@ -1,4 +1,4 @@ -import { OnshapeApi } from "../onshape-api"; +import { OnshapeApi } from "../client"; import { assertInstanceType, assertWorkspace } from "../assertions"; import { DocumentPath, @@ -9,15 +9,15 @@ import { toElementApiPath, toInstanceApiPath, toInstanceTypeKey -} from "../../../shared/onshape-path"; +} from "../path"; import { apiPath } from "../api-path"; -import { OAuthApi } from "../onshape-api"; +import { OAuthApi } from "../client"; import { getLatestVersion } from "./versions"; import { OnshapeDocumentContents, OnshapeDocumentInfo, OnshapeElementType -} from "../onshape-types"; +} from "../types"; // `OnshapeElementType` is owned by the hand-authored types module; re-export it here so // existing `./documents` importers keep working. diff --git a/src/backend/onshape-api/endpoints/feature-studios.ts b/src/backend/lib/onshape/endpoints/feature-studios.ts similarity index 96% rename from src/backend/onshape-api/endpoints/feature-studios.ts rename to src/backend/lib/onshape/endpoints/feature-studios.ts index dcb28914d..1e4c4880b 100644 --- a/src/backend/onshape-api/endpoints/feature-studios.ts +++ b/src/backend/lib/onshape/endpoints/feature-studios.ts @@ -1,11 +1,11 @@ -import { OnshapeApi } from "../onshape-api"; +import { OnshapeApi } from "../client"; import { assertInstanceType, assertWorkspace } from "../assertions"; import { ElementPath, InstancePath, toElementApiPath, toInstanceApiPath -} from "../../../shared/onshape-path"; +} from "../path"; import { apiPath } from "../api-path"; /** diff --git a/src/backend/onshape-api/endpoints/metadata.ts b/src/backend/lib/onshape/endpoints/metadata.ts similarity index 66% rename from src/backend/onshape-api/endpoints/metadata.ts rename to src/backend/lib/onshape/endpoints/metadata.ts index 8d366be93..ac7987084 100644 --- a/src/backend/onshape-api/endpoints/metadata.ts +++ b/src/backend/lib/onshape/endpoints/metadata.ts @@ -1,9 +1,9 @@ -import { OnshapeApi } from "../onshape-api"; -import { ElementPath, toElementApiPath } from "../../../shared/onshape-path"; +import { OnshapeApi } from "../client"; +import { ElementPath, toElementApiPath } from "../path"; import { apiPath } from "../api-path"; -import { encodeConfigurationForQuery } from "../../../shared/configuration-utils"; -import { ParameterValues } from "../../../shared/configuration-models"; -import type { OnshapeMetadataObject } from "../onshape-types"; +import { encodeConfigurationForQuery } from "../../../features/configurations/utils"; +import { ParameterValues } from "../../../features/configurations/models"; +import type { OnshapeMetadataObject } from "../types"; /** Returns an element's metadata properties for a given configuration. */ export function getElementMetadata( diff --git a/src/backend/onshape-api/endpoints/part-studios.ts b/src/backend/lib/onshape/endpoints/part-studios.ts similarity index 91% rename from src/backend/onshape-api/endpoints/part-studios.ts rename to src/backend/lib/onshape/endpoints/part-studios.ts index 227e370f0..54ce47ca5 100644 --- a/src/backend/onshape-api/endpoints/part-studios.ts +++ b/src/backend/lib/onshape/endpoints/part-studios.ts @@ -1,16 +1,13 @@ -import { OnshapeApi } from "../onshape-api"; +import { OnshapeApi } from "../client"; import { assertInstanceType, assertWorkspace } from "../assertions"; import { ElementPath, InstancePath, toElementApiPath, toInstanceApiPath -} from "../../../shared/onshape-path"; +} from "../path"; import { apiPath } from "../api-path"; -import { - OnshapeCreatedFeature, - OnshapeFeatureListResponse -} from "../onshape-types"; +import { OnshapeCreatedFeature, OnshapeFeatureListResponse } from "../types"; export function createPartStudio( client: OnshapeApi, diff --git a/src/backend/onshape-api/endpoints/parts.ts b/src/backend/lib/onshape/endpoints/parts.ts similarity index 80% rename from src/backend/onshape-api/endpoints/parts.ts rename to src/backend/lib/onshape/endpoints/parts.ts index d3e596020..ac6dda7c6 100644 --- a/src/backend/onshape-api/endpoints/parts.ts +++ b/src/backend/lib/onshape/endpoints/parts.ts @@ -1,9 +1,9 @@ -import { OnshapeApi } from "../onshape-api"; -import { ElementPath, toElementApiPath } from "../../../shared/onshape-path"; +import { OnshapeApi } from "../client"; +import { ElementPath, toElementApiPath } from "../path"; import { apiPath } from "../api-path"; -import { encodeConfigurationForQuery } from "../../../shared/configuration-utils"; -import { ParameterValues } from "../../../shared/configuration-models"; -import type { OnshapeAssemblyDefinition, OnshapePart } from "../onshape-types"; +import { encodeConfigurationForQuery } from "../../../features/configurations/utils"; +import { ParameterValues } from "../../../features/configurations/models"; +import type { OnshapeAssemblyDefinition, OnshapePart } from "../types"; /** * Builds the `configuration` query for an element request. The value is the diff --git a/src/backend/onshape-api/endpoints/permissions.ts b/src/backend/lib/onshape/endpoints/permissions.ts similarity index 90% rename from src/backend/onshape-api/endpoints/permissions.ts rename to src/backend/lib/onshape/endpoints/permissions.ts index 95adf70f7..dec75d989 100644 --- a/src/backend/onshape-api/endpoints/permissions.ts +++ b/src/backend/lib/onshape/endpoints/permissions.ts @@ -1,5 +1,5 @@ -import { OnshapeApi, OnshapeApiError } from "../onshape-api"; -import { DocumentPath, toDocumentApiPath } from "../../../shared/onshape-path"; +import { OnshapeApi, OnshapeApiError } from "../client"; +import { DocumentPath, toDocumentApiPath } from "../path"; import { HttpStatus } from "http-status-ts"; import { apiPath } from "../api-path"; diff --git a/src/backend/onshape-api/endpoints/settings.ts b/src/backend/lib/onshape/endpoints/settings.ts similarity index 97% rename from src/backend/onshape-api/endpoints/settings.ts rename to src/backend/lib/onshape/endpoints/settings.ts index 1d02333cd..f87481e33 100644 --- a/src/backend/onshape-api/endpoints/settings.ts +++ b/src/backend/lib/onshape/endpoints/settings.ts @@ -1,4 +1,4 @@ -import { OAuthApi } from "../onshape-api"; +import { OAuthApi } from "../client"; /** Gets a setting with a specific key. Returns `null` if the key is not set. */ export async function getSetting( diff --git a/src/backend/onshape-api/endpoints/std-versions.ts b/src/backend/lib/onshape/endpoints/std-versions.ts similarity index 96% rename from src/backend/onshape-api/endpoints/std-versions.ts rename to src/backend/lib/onshape/endpoints/std-versions.ts index 86febb744..45c6dc006 100644 --- a/src/backend/onshape-api/endpoints/std-versions.ts +++ b/src/backend/lib/onshape/endpoints/std-versions.ts @@ -1,4 +1,4 @@ -import { OnshapeApi } from "../onshape-api"; +import { OnshapeApi } from "../client"; import { getLatestVersion, getVersions } from "./versions"; import { STD_PATH } from "../objects/constants"; diff --git a/src/backend/onshape-api/endpoints/thumbnails.ts b/src/backend/lib/onshape/endpoints/thumbnails.ts similarity index 95% rename from src/backend/onshape-api/endpoints/thumbnails.ts rename to src/backend/lib/onshape/endpoints/thumbnails.ts index 0c5e8ff9b..93a018fae 100644 --- a/src/backend/onshape-api/endpoints/thumbnails.ts +++ b/src/backend/lib/onshape/endpoints/thumbnails.ts @@ -1,13 +1,13 @@ -import { OnshapeApi } from "../onshape-api"; +import { OnshapeApi } from "../client"; import { assertInstanceType, assertWorkspace } from "../assertions"; import { ElementPath, InstancePath, toElementApiPath, toInstanceApiPath -} from "../../../shared/onshape-path"; +} from "../path"; import { apiPath } from "../api-path"; -import { ThumbnailSize } from "../../../shared/thumbnail-types"; +import { ThumbnailSize } from "../../../features/thumbnails/types"; /** Returns the thumbnail of a given document instance. */ export function getInstanceThumbnail( diff --git a/src/backend/onshape-api/endpoints/users.ts b/src/backend/lib/onshape/endpoints/users.ts similarity index 88% rename from src/backend/onshape-api/endpoints/users.ts rename to src/backend/lib/onshape/endpoints/users.ts index 61f8b34bf..92728c03e 100644 --- a/src/backend/onshape-api/endpoints/users.ts +++ b/src/backend/lib/onshape/endpoints/users.ts @@ -1,7 +1,7 @@ -import { OnshapeApi } from "../onshape-api"; -import { OAuthApi } from "../onshape-api"; +import { OnshapeApi } from "../client"; +import { OAuthApi } from "../client"; import { apiPath } from "../api-path"; -import { AccessLevel } from "../../../shared/access-level"; +import { AccessLevel } from "../../../features/auth/access-level"; export interface SessionInfo { id: string; diff --git a/src/backend/onshape-api/endpoints/versions.ts b/src/backend/lib/onshape/endpoints/versions.ts similarity index 93% rename from src/backend/onshape-api/endpoints/versions.ts rename to src/backend/lib/onshape/endpoints/versions.ts index 7933e8cb7..d35ef1b1c 100644 --- a/src/backend/onshape-api/endpoints/versions.ts +++ b/src/backend/lib/onshape/endpoints/versions.ts @@ -1,13 +1,13 @@ -import { OnshapeApi } from "../onshape-api"; +import { OnshapeApi } from "../client"; import { assertVersion } from "../assertions"; import { DocumentPath, InstancePath, toDocumentApiPath, toInstanceApiObject -} from "../../../shared/onshape-path"; +} from "../path"; import { apiPath } from "../api-path"; -import { OnshapeVersionInfo } from "../onshape-types"; +import { OnshapeVersionInfo } from "../types"; /** * Fetches a list of versions of a document. diff --git a/src/backend/onshape-api/objects/assembly-features.ts b/src/backend/lib/onshape/objects/assembly-features.ts similarity index 100% rename from src/backend/onshape-api/objects/assembly-features.ts rename to src/backend/lib/onshape/objects/assembly-features.ts diff --git a/src/backend/onshape-api/objects/constants.ts b/src/backend/lib/onshape/objects/constants.ts similarity index 89% rename from src/backend/onshape-api/objects/constants.ts rename to src/backend/lib/onshape/objects/constants.ts index 3f76696de..dafda2324 100644 --- a/src/backend/onshape-api/objects/constants.ts +++ b/src/backend/lib/onshape/objects/constants.ts @@ -1,4 +1,4 @@ -import { InstancePath } from "../../../shared/onshape-path"; +import { InstancePath } from "../path"; /** The path to the Onshape standard library. */ export const STD_PATH: InstancePath = { diff --git a/src/backend/onshape-api/objects/derive-feature.ts b/src/backend/lib/onshape/objects/derive-feature.ts similarity index 97% rename from src/backend/onshape-api/objects/derive-feature.ts rename to src/backend/lib/onshape/objects/derive-feature.ts index c98a1899f..1646ac37b 100644 --- a/src/backend/onshape-api/objects/derive-feature.ts +++ b/src/backend/lib/onshape/objects/derive-feature.ts @@ -2,8 +2,8 @@ import { ParameterType, type ParameterValues, type ConfigurationParameter -} from "../../../shared/configuration-models"; -import { type ElementPath } from "../../../shared/onshape-path"; +} from "../../../features/configurations/models"; +import { type ElementPath } from "../path"; const PART_STUDIO_QUERY = "query=qUnion(qAllModifiableSolidBodies(), qAllModifiableSolidBodies()->qOwnedByBody(EntityType.BODY)->qBodyType(BodyType.MATE_CONNECTOR), qAllModifiableSolidBodies()->qCompositePartsContaining());"; diff --git a/src/backend/onshape-api/objects/parse-query.ts b/src/backend/lib/onshape/objects/parse-query.ts similarity index 100% rename from src/backend/onshape-api/objects/parse-query.ts rename to src/backend/lib/onshape/objects/parse-query.ts diff --git a/src/shared/onshape-path.ts b/src/backend/lib/onshape/path.ts similarity index 97% rename from src/shared/onshape-path.ts rename to src/backend/lib/onshape/path.ts index fad4b1816..ff2ee8518 100644 --- a/src/shared/onshape-path.ts +++ b/src/backend/lib/onshape/path.ts @@ -1,4 +1,4 @@ -import { ParameterValues } from "./configuration-models"; +import { ParameterValues } from "../../features/configurations/models"; /** The instance kinds an Onshape path can address, as one definition: the type * and the runtime list validators check against both derive from it. */ diff --git a/src/backend/onshape-api/onshape-types.ts b/src/backend/lib/onshape/types.ts similarity index 99% rename from src/backend/onshape-api/onshape-types.ts rename to src/backend/lib/onshape/types.ts index 4ea581106..ba628cd9d 100644 --- a/src/backend/onshape-api/onshape-types.ts +++ b/src/backend/lib/onshape/types.ts @@ -6,7 +6,7 @@ import { LogicalOp, QuantityType, Unit -} from "../../shared/configuration-enums"; +} from "../../features/configurations/enums"; // === configuration (GET .../configuration, GET .../configurationencodings/{cid}) === diff --git a/src/shared/url-params.ts b/src/backend/lib/query-params.ts similarity index 100% rename from src/shared/url-params.ts rename to src/backend/lib/query-params.ts diff --git a/src/backend/route-params.ts b/src/backend/lib/route-params.ts similarity index 95% rename from src/backend/route-params.ts rename to src/backend/lib/route-params.ts index c05d2533a..cdf872bf3 100644 --- a/src/backend/route-params.ts +++ b/src/backend/lib/route-params.ts @@ -2,7 +2,7 @@ import { HTTPException } from "hono/http-exception"; import { HttpStatus } from "http-status-ts"; import z from "zod"; -import { LibraryId } from "../shared/library-id"; +import { LibraryId } from "../features/library/library-id"; import { type AppContext } from "./context"; export function libraryRoute(): string { diff --git a/src/frontend/app/alerts.tsx b/src/frontend/components/alerts.tsx similarity index 100% rename from src/frontend/app/alerts.tsx rename to src/frontend/components/alerts.tsx diff --git a/src/frontend/app-common/app-menu.tsx b/src/frontend/components/app-menu.tsx similarity index 97% rename from src/frontend/app-common/app-menu.tsx rename to src/frontend/components/app-menu.tsx index e3795281d..60d5a56d5 100644 --- a/src/frontend/app-common/app-menu.tsx +++ b/src/frontend/components/app-menu.tsx @@ -1,7 +1,7 @@ import { PropsWithChildren, ReactNode } from "react"; import { FloatingPosition, Menu, ActionIcon } from "@mantine/core"; import { IconDots } from "@tabler/icons-react"; -import { IconSize } from "../common/style-constants"; +import { IconSize } from "../lib/style-constants"; interface AppContextMenuProps { menuItems: ReactNode; diff --git a/src/frontend/app/app-navbar.tsx b/src/frontend/components/app-navbar.tsx similarity index 88% rename from src/frontend/app/app-navbar.tsx rename to src/frontend/components/app-navbar.tsx index 1170e769c..7360c7425 100644 --- a/src/frontend/app/app-navbar.tsx +++ b/src/frontend/components/app-navbar.tsx @@ -9,23 +9,23 @@ import { Tooltip } from "@mantine/core"; import { IconChevronDown, IconSearch, IconSettings } from "@tabler/icons-react"; -import { HEADER_CONTROL_COLOR, IconSize } from "../common/style-constants"; +import { HEADER_CONTROL_COLOR, IconSize } from "../lib/style-constants"; import { ReactNode, RefObject, useRef } from "react"; import { useNavigate } from "@tanstack/react-router"; import frcDesignBook from "/frc-design-book.svg"; -import { openSettingsMenu } from "../settings/settings-menu"; -import { VendorMenu } from "../settings/vendor-filters"; -import { useUiState } from "../api-utils/ui-state"; -import { getLibraryName, useLibraryId } from "../api-utils/library"; -import { RequireAccessLevel } from "../api-utils/access-level"; -import { useSaveSettings } from "../settings/settings"; -import { useIsSignedIn } from "../api-utils/access-level"; -import { startSignIn } from "../api-utils/sign-in"; -import { useJobStatus } from "../api-utils/refresh"; -import { LibraryId } from "../../shared/library-id"; -import { queryClient } from "../query-client"; -import { getLibraryVersionQuery } from "../library-queries"; +import { openSettingsMenu } from "../features/settings/components/settings-menu"; +import { VendorMenu } from "../features/settings/components/vendor-filters"; +import { useUiState } from "../lib/ui-state"; +import { getLibraryName, useLibraryId } from "../features/library/library-path"; +import { RequireAccessLevel } from "../features/auth/access-level"; +import { useSaveSettings } from "../features/settings/settings"; +import { useIsSignedIn } from "../features/auth/access-level"; +import { startSignIn } from "../features/auth/sign-in"; +import { useJobStatus } from "../lib/refresh"; +import { LibraryId } from "../../backend/features/library/library-id"; +import { queryClient } from "../lib/query-client"; +import { getLibraryVersionQuery } from "../features/library/queries"; /** * Provides top-level navigation for the app. A single colored control row holds diff --git a/src/frontend/app-common/app-select.tsx b/src/frontend/components/app-select.tsx similarity index 93% rename from src/frontend/app-common/app-select.tsx rename to src/frontend/components/app-select.tsx index e338a7b8b..14e9d3981 100644 --- a/src/frontend/app-common/app-select.tsx +++ b/src/frontend/components/app-select.tsx @@ -1,6 +1,6 @@ import { Select } from "@mantine/core"; import { Dispatch, ReactNode } from "react"; -import { SelectOption } from "../settings/select-utils"; +import { SelectOption } from "./select-utils"; interface AppSelectProps { option: SelectOption; diff --git a/src/frontend/app-common/app-zero-state.tsx b/src/frontend/components/app-zero-state.tsx similarity index 97% rename from src/frontend/app-common/app-zero-state.tsx rename to src/frontend/components/app-zero-state.tsx index 439393aff..d23d9e92d 100644 --- a/src/frontend/app-common/app-zero-state.tsx +++ b/src/frontend/components/app-zero-state.tsx @@ -1,6 +1,6 @@ import { Center, Loader, EmptyState } from "@mantine/core"; import { IconX } from "@tabler/icons-react"; -import { HeartIconColor, IconSize } from "../common/style-constants"; +import { HeartIconColor, IconSize } from "../lib/style-constants"; import { type JSX, ReactNode } from "react"; const DEFAULT_ERROR_ICON = ( diff --git a/src/frontend/common/change-order.tsx b/src/frontend/components/change-order.tsx similarity index 99% rename from src/frontend/common/change-order.tsx rename to src/frontend/components/change-order.tsx index c1bbc4080..a26fab5b3 100644 --- a/src/frontend/common/change-order.tsx +++ b/src/frontend/components/change-order.tsx @@ -5,7 +5,7 @@ import { IconChevronsUp, IconChevronUp } from "@tabler/icons-react"; -import { IconSize } from "./style-constants"; +import { IconSize } from "../lib/style-constants"; import { type ReactNode } from "react"; interface ChangeOrderMenuProps { diff --git a/src/frontend/common/open-url-button.tsx b/src/frontend/components/open-url-button.tsx similarity index 82% rename from src/frontend/common/open-url-button.tsx rename to src/frontend/components/open-url-button.tsx index 9f4a0b954..01bdb3afd 100644 --- a/src/frontend/common/open-url-button.tsx +++ b/src/frontend/components/open-url-button.tsx @@ -1,7 +1,7 @@ import { Button } from "@mantine/core"; import { IconExternalLink } from "@tabler/icons-react"; -import { IconSize } from "./style-constants"; -import { openUrlInNewTab } from "./url"; +import { IconSize } from "../lib/style-constants"; +import { openUrlInNewTab } from "../lib/url"; interface UrlButtonProps { url: string; diff --git a/src/frontend/app/root-error.tsx b/src/frontend/components/root-error.tsx similarity index 84% rename from src/frontend/app/root-error.tsx rename to src/frontend/components/root-error.tsx index 2a255aab9..758cd4056 100644 --- a/src/frontend/app/root-error.tsx +++ b/src/frontend/components/root-error.tsx @@ -1,12 +1,12 @@ -import { RequireAccessLevel } from "../api-utils/access-level"; -import { PageError } from "../app-common/app-zero-state"; +import { RequireAccessLevel } from "../features/auth/access-level"; +import { PageError } from "./app-zero-state"; import { ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; import { Button } from "@mantine/core"; import { IconHome } from "@tabler/icons-react"; -import { IconSize } from "../common/style-constants"; -import { ReloadGroupsButton } from "../settings/reload-groups-button"; -import { DEFAULT_LIBRARY_ID } from "../../shared/library-id"; +import { IconSize } from "../lib/style-constants"; +import { ReloadGroupsButton } from "../features/library/components/reload-groups-button"; +import { DEFAULT_LIBRARY_ID } from "../../backend/features/library/library-id"; /** * Catch-all error state for when a route below the root fails to load. diff --git a/src/frontend/app/root-spinner.tsx b/src/frontend/components/root-spinner.tsx similarity index 100% rename from src/frontend/app/root-spinner.tsx rename to src/frontend/components/root-spinner.tsx diff --git a/src/frontend/settings/select-utils.ts b/src/frontend/components/select-utils.ts similarity index 100% rename from src/frontend/settings/select-utils.ts rename to src/frontend/components/select-utils.ts diff --git a/src/frontend/api-utils/access-level.tsx b/src/frontend/features/auth/access-level.tsx similarity index 93% rename from src/frontend/api-utils/access-level.tsx rename to src/frontend/features/auth/access-level.tsx index 0a9f821f7..bdb212b43 100644 --- a/src/frontend/api-utils/access-level.tsx +++ b/src/frontend/features/auth/access-level.tsx @@ -6,10 +6,10 @@ import { hasAdminAccess, hasEditorAccess, isWithinAccessLevel -} from "../../shared/access-level"; -import { accessDataQueryKey } from "../query-keys"; -import { apiGet } from "./api"; -import { useUiState } from "./ui-state"; +} from "../../../backend/features/auth/access-level"; +import { accessDataQueryKey } from "../../lib/query-keys"; +import { apiGet } from "../../lib/api-client"; +import { useUiState } from "../../lib/ui-state"; const DEFAULT_ACCESS_DATA: AccessData = { maxAccessLevel: AccessLevel.USER, diff --git a/src/frontend/api-utils/sign-in.ts b/src/frontend/features/auth/sign-in.ts similarity index 95% rename from src/frontend/api-utils/sign-in.ts rename to src/frontend/features/auth/sign-in.ts index 1363fe884..e54414a23 100644 --- a/src/frontend/api-utils/sign-in.ts +++ b/src/frontend/features/auth/sign-in.ts @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import { showSuccessToast } from "../common/notifications"; +import { showSuccessToast } from "../../lib/notifications"; const SIGNED_IN_PARAM = "justSignedIn"; diff --git a/src/frontend/cards/build-status.tsx b/src/frontend/features/build-status/components/build-status.tsx similarity index 97% rename from src/frontend/cards/build-status.tsx rename to src/frontend/features/build-status/components/build-status.tsx index 337336b87..ef8c4ffa8 100644 --- a/src/frontend/cards/build-status.tsx +++ b/src/frontend/features/build-status/components/build-status.tsx @@ -27,7 +27,7 @@ import { useMemo, useState } from "react"; -import { formatRelativeTime } from "../common/format-time"; +import { formatRelativeTime } from "../../../lib/format-time"; import { addBuildIssue, BuildIssue, @@ -36,13 +36,19 @@ import { getIssueDescription, getIssueSeverity, getMaxSeverity -} from "../../shared/build-issues"; -import { GroupBuildStatus, InsertableBuildStatus } from "../../shared/build-status-dto"; -import { getVendorName, Vendor } from "../../shared/vendors"; +} from "../../../../backend/features/build-checker/issues"; +import { + GroupBuildStatus, + InsertableBuildStatus +} from "../../../../backend/features/build-checker/dto"; +import { + getVendorName, + Vendor +} from "../../../../backend/features/library/vendors"; import { ConfigurationParameter, ParameterType -} from "../../shared/configuration-models"; +} from "../../../../backend/features/configurations/models"; import { AUTO_INDEX_THRESHOLD, type ConfigurationCount, @@ -50,17 +56,17 @@ import { IndexingBand, isIndexedParameter, MAX_PART_NUMBER_CONFIGURATIONS -} from "../../shared/configuration-combinations"; -import { FontWeight, IconColor, IconSize } from "../common/style-constants"; -import { RequireAccessLevel } from "../api-utils/access-level"; -import { useBuildStatusQuery } from "../build-status-queries"; -import { useJobStatusQuery } from "../library-queries"; +} from "../../../../backend/features/configurations/combinations"; +import { FontWeight, IconColor, IconSize } from "../../../lib/style-constants"; +import { RequireAccessLevel } from "../../auth/access-level"; +import { useBuildStatusQuery } from "../queries"; +import { useJobStatusQuery } from "../../library/queries"; import { useSetVisibilityMutation, useToggleInsertAndFastenMutation, useIndexConfigurationsMutation, useToggleSortOrderMutation -} from "./card-hooks"; +} from "../../library/card-hooks"; /** Discriminated so `StateValue` renders each kind its own way. */ export type StateRowValue = diff --git a/src/frontend/build-status-queries.ts b/src/frontend/features/build-status/queries.ts similarity index 62% rename from src/frontend/build-status-queries.ts rename to src/frontend/features/build-status/queries.ts index 3516e7fbc..4faa626c5 100644 --- a/src/frontend/build-status-queries.ts +++ b/src/frontend/features/build-status/queries.ts @@ -1,10 +1,14 @@ -import { keepPreviousData, queryOptions, useQuery } from "@tanstack/react-query"; -import { apiGet } from "./api-utils/api"; -import { type LibraryBuildStatus } from "../shared/build-status-dto"; -import { LibraryId } from "../shared/library-id"; -import { useLibraryId } from "./api-utils/library"; -import { useCacheVersion } from "./library-queries"; -import { buildStatusQueryKey } from "./query-keys"; +import { + keepPreviousData, + queryOptions, + useQuery +} from "@tanstack/react-query"; +import { apiGet } from "../../lib/api-client"; +import { type LibraryBuildStatus } from "../../../backend/features/build-checker/dto"; +import { LibraryId } from "../../../backend/features/library/library-id"; +import { useLibraryId } from "../library/library-path"; +import { useCacheVersion } from "../library/queries"; +import { buildStatusQueryKey } from "../../lib/query-keys"; export function getBuildStatusQuery( libraryId: LibraryId, diff --git a/src/frontend/favorites/favorite-button.tsx b/src/frontend/features/favorites/components/favorite-button.tsx similarity index 88% rename from src/frontend/favorites/favorite-button.tsx rename to src/frontend/features/favorites/components/favorite-button.tsx index ce2d763eb..c86ae26d8 100644 --- a/src/frontend/favorites/favorite-button.tsx +++ b/src/frontend/features/favorites/components/favorite-button.tsx @@ -4,20 +4,23 @@ import { IconHeartBroken, IconHeartFilled } from "@tabler/icons-react"; -import { HeartIconColor, IconSize } from "../common/style-constants"; +import { HeartIconColor, IconSize } from "../../../lib/style-constants"; import { useMutation } from "@tanstack/react-query"; import { ReactNode, useState } from "react"; -import { apiDelete, apiPost } from "../api-utils/api"; -import type { Favorite, FavoritesData } from "../../shared/favorites-dto"; -import type { InsertableOut } from "../../shared/library-dto"; -import { LibraryId } from "../../shared/library-id"; -import { queryClient } from "../query-client"; +import { apiDelete, apiPost } from "../../../lib/api-client"; +import type { + Favorite, + FavoritesData +} from "../../../../backend/features/favorites/dto"; +import type { InsertableOut } from "../../../../backend/features/library/dto"; +import { LibraryId } from "../../../../backend/features/library/library-id"; +import { queryClient } from "../../../lib/query-client"; import { useRouter } from "@tanstack/react-router"; -import { handleAppError, HandledError } from "../api-utils/errors"; -import { getQueryUpdater } from "../common/utils"; -import { toLibraryPath, useLibraryId } from "../api-utils/library"; -import { favoritesQueryKey } from "../query-keys"; -import { useRefreshFavorites } from "../api-utils/refresh"; +import { handleAppError, HandledError } from "../../../lib/errors"; +import { getQueryUpdater } from "../../../lib/utils"; +import { toLibraryPath, useLibraryId } from "../../library/library-path"; +import { favoritesQueryKey } from "../../../lib/query-keys"; +import { useRefreshFavorites } from "../../../lib/refresh"; enum Operation { ADD, diff --git a/src/frontend/favorites/favorite-card.tsx b/src/frontend/features/favorites/components/favorite-card.tsx similarity index 82% rename from src/frontend/favorites/favorite-card.tsx rename to src/frontend/features/favorites/components/favorite-card.tsx index 3b3896c02..3e96e2592 100644 --- a/src/frontend/favorites/favorite-card.tsx +++ b/src/frontend/features/favorites/components/favorite-card.tsx @@ -1,15 +1,15 @@ -import { encodeCanonicalConfiguration } from "../../shared/canonical-configuration"; +import { encodeCanonicalConfiguration } from "../../../../backend/features/configurations/canonical"; import { ReactNode } from "react"; -import { Favorite } from "../../shared/favorites-dto"; -import { InsertableOut } from "../../shared/library-dto"; +import { Favorite } from "../../../../backend/features/favorites/dto"; +import { InsertableOut } from "../../../../backend/features/library/dto"; import { useMutation } from "@tanstack/react-query"; -import { apiPost } from "../api-utils/api"; -import { queryClient } from "../query-client"; +import { apiPost } from "../../../lib/api-client"; +import { queryClient } from "../../../lib/query-client"; import { Menu } from "@mantine/core"; import { IconPencil } from "@tabler/icons-react"; -import { IconSize } from "../common/style-constants"; +import { IconSize } from "../../../lib/style-constants"; import { useRouter } from "@tanstack/react-router"; -import { openInsertMenu } from "../insert/insert-menu"; +import { openInsertMenu } from "../../insert/components/insert-menu"; import { openFavoriteMenu } from "./favorite-menu"; import { FavoriteButton, FavoriteInsertableItem } from "./favorite-button"; import { @@ -17,24 +17,24 @@ import { ItemRow, OpenDocumentItems, QuickInsertItems -} from "../cards/card-components"; -import { useIsInsertableHidden } from "../cards/card-hooks"; -import { useIsAssemblyInPartStudio } from "../insert/insert-hooks"; -import { ChangeOrderItems } from "../common/change-order"; -import { useUiState } from "../api-utils/ui-state"; -import { useIsConnectedToOnshape } from "../api-utils/onshape-params"; +} from "../../library/components/card-components"; +import { useIsInsertableHidden } from "../../library/card-hooks"; +import { useIsAssemblyInPartStudio } from "../../insert/insert-hooks"; +import { ChangeOrderItems } from "../../../components/change-order"; +import { useUiState } from "../../../lib/ui-state"; +import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; import { openCannotDeriveAssemblyAlert, openCannotEditDefaultConfigurationAlert, openCannotReorderAlert -} from "../app/alerts"; -import { getAppErrorHandler } from "../api-utils/errors"; -import { useFavoritesQuery } from "../favorites-queries"; -import { favoritesQueryKey } from "../query-keys"; -import { useRefreshFavorites } from "../api-utils/refresh"; +} from "../../../components/alerts"; +import { getAppErrorHandler } from "../../../lib/errors"; +import { useFavoritesQuery } from "../queries"; +import { favoritesQueryKey } from "../../../lib/query-keys"; +import { useRefreshFavorites } from "../../../lib/refresh"; import { produce } from "immer"; -import { SearchHit } from "../search/search"; -import { toLibraryPath, useLibraryId } from "../api-utils/library"; +import { SearchHit } from "../../search/search"; +import { toLibraryPath, useLibraryId } from "../../library/library-path"; interface FavoriteCardProps { insertable: InsertableOut; diff --git a/src/frontend/favorites/favorite-menu.tsx b/src/frontend/features/favorites/components/favorite-menu.tsx similarity index 85% rename from src/frontend/favorites/favorite-menu.tsx rename to src/frontend/features/favorites/components/favorite-menu.tsx index a2b1147c0..b8aebd8cf 100644 --- a/src/frontend/favorites/favorite-menu.tsx +++ b/src/frontend/features/favorites/components/favorite-menu.tsx @@ -1,29 +1,29 @@ import { Button, Group, Stack, Text } from "@mantine/core"; import { modals } from "@mantine/modals"; import { IconDeviceFloppy } from "@tabler/icons-react"; -import { FontWeight, IconSize } from "../common/style-constants"; +import { FontWeight, IconSize } from "../../../lib/style-constants"; import { ReactNode, useEffect, useState } from "react"; import { useRouter } from "@tanstack/react-router"; import { useMutation } from "@tanstack/react-query"; -import { apiPost } from "../api-utils/api"; -import { showErrorToast, showSuccessToast } from "../common/notifications"; -import { PreviewImageCard } from "../insert/thumbnail"; -import { ConfigurationWrapper } from "../insert/configurations"; -import type { FavoritesData } from "../../shared/favorites-dto"; +import { apiPost } from "../../../lib/api-client"; +import { showErrorToast, showSuccessToast } from "../../../lib/notifications"; +import { PreviewImageCard } from "../../thumbnails/components/thumbnail"; +import { ConfigurationWrapper } from "../../insert/components/configurations"; +import type { FavoritesData } from "../../../../backend/features/favorites/dto"; import { HeartIcon } from "./favorite-button"; -import { queryClient } from "../query-client"; +import { queryClient } from "../../../lib/query-client"; import { ParameterValues, SearchRecord -} from "../../shared/configuration-models"; -import { encodeCanonicalConfiguration } from "../../shared/canonical-configuration"; -import { useFavoritesQuery } from "../favorites-queries"; -import { useLibraryQuery } from "../library-queries"; -import { favoritesQueryKey } from "../query-keys"; -import { getQueryUpdater } from "../common/utils"; -import { useLibraryId } from "../api-utils/library"; -import { useRefreshFavorites } from "../api-utils/refresh"; -import { PageError } from "../app-common/app-zero-state"; +} from "../../../../backend/features/configurations/models"; +import { encodeCanonicalConfiguration } from "../../../../backend/features/configurations/canonical"; +import { useFavoritesQuery } from "../queries"; +import { useLibraryQuery } from "../../library/queries"; +import { favoritesQueryKey } from "../../../lib/query-keys"; +import { getQueryUpdater } from "../../../lib/utils"; +import { useLibraryId } from "../../library/library-path"; +import { useRefreshFavorites } from "../../../lib/refresh"; +import { PageError } from "../../../components/app-zero-state"; interface OpenFavoriteMenuProps { favoriteId: string; diff --git a/src/frontend/favorites/favorites-list.tsx b/src/frontend/features/favorites/components/favorites-list.tsx similarity index 81% rename from src/frontend/favorites/favorites-list.tsx rename to src/frontend/features/favorites/components/favorites-list.tsx index dc1e2745f..7885b641b 100644 --- a/src/frontend/favorites/favorites-list.tsx +++ b/src/frontend/features/favorites/components/favorites-list.tsx @@ -1,20 +1,26 @@ -import { useAccessData } from "../api-utils/access-level"; +import { useAccessData } from "../../auth/access-level"; import { IconHeartBroken } from "@tabler/icons-react"; -import { HeartIconColor, IconSize } from "../common/style-constants"; +import { HeartIconColor, IconSize } from "../../../lib/style-constants"; import { ReactNode } from "react"; -import { filterInsertables } from "../search/filter"; -import { getFavoriteForInsertable } from "../../shared/favorites-dto"; -import { InsertableOut } from "../../shared/library-dto"; -import { useUiState } from "../api-utils/ui-state"; -import { SectionError, SectionLoading } from "../app-common/app-zero-state"; -import { NoSearchResultError, SearchCallout } from "../search/search-errors"; +import { filterInsertables } from "../../search/filter"; +import { getFavoriteForInsertable } from "../../../../backend/features/favorites/dto"; +import { InsertableOut } from "../../../../backend/features/library/dto"; +import { useUiState } from "../../../lib/ui-state"; +import { + SectionError, + SectionLoading +} from "../../../components/app-zero-state"; +import { + NoSearchResultError, + SearchCallout +} from "../../search/components/search-errors"; import { FavoriteCard } from "./favorite-card"; -import { ItemTable } from "../cards/card-components"; -import { useFavoritesQuery } from "../favorites-queries"; -import { useLibraryQuery } from "../library-queries"; -import { useSearchDbQuery } from "../search-queries"; -import { doSearch, FilterResult, SearchHit } from "../search/search"; -import { hasEditorAccess } from "../../shared/access-level"; +import { ItemTable } from "../../library/components/card-components"; +import { useFavoritesQuery } from "../queries"; +import { useLibraryQuery } from "../../library/queries"; +import { useSearchDbQuery } from "../../search/queries"; +import { doSearch, FilterResult, SearchHit } from "../../search/search"; +import { hasEditorAccess } from "../../../../backend/features/auth/access-level"; /** * A list of current favorite cards. diff --git a/src/frontend/favorites-queries.ts b/src/frontend/features/favorites/queries.ts similarity index 67% rename from src/frontend/favorites-queries.ts rename to src/frontend/features/favorites/queries.ts index b5948edbf..83875d102 100644 --- a/src/frontend/favorites-queries.ts +++ b/src/frontend/features/favorites/queries.ts @@ -1,10 +1,10 @@ import { queryOptions, useQuery } from "@tanstack/react-query"; -import { apiGet } from "./api-utils/api"; -import { type FavoritesData } from "../shared/favorites-dto"; -import { LibraryId } from "../shared/library-id"; -import { useAccessData } from "./api-utils/access-level"; -import { useLibraryId } from "./api-utils/library"; -import { favoritesQueryKey } from "./query-keys"; +import { apiGet } from "../../lib/api-client"; +import { type FavoritesData } from "../../../backend/features/favorites/dto"; +import { LibraryId } from "../../../backend/features/library/library-id"; +import { useAccessData } from "../auth/access-level"; +import { useLibraryId } from "../library/library-path"; +import { favoritesQueryKey } from "../../lib/query-keys"; const EMPTY_FAVORITES: FavoritesData = { favorites: {}, favoriteOrder: [] }; diff --git a/src/frontend/insert/configurations.tsx b/src/frontend/features/insert/components/configurations.tsx similarity index 95% rename from src/frontend/insert/configurations.tsx rename to src/frontend/features/insert/components/configurations.tsx index 8d1d77a2b..529b7034c 100644 --- a/src/frontend/insert/configurations.tsx +++ b/src/frontend/features/insert/components/configurations.tsx @@ -17,7 +17,7 @@ import { useState, useCallback } from "react"; -import { apiGet } from "../api-utils/api"; +import { apiGet } from "../../../lib/api-client"; import { ParameterValues, ConfigurationResult, @@ -31,26 +31,26 @@ import { EnumOption, EMPTY_UNIT_INFO, SearchRecord -} from "../../shared/configuration-models"; +} from "../../../../backend/features/configurations/models"; import { evaluateCondition, findRecordForConfiguration, getEvaluateOptions, getOption, getVisibleOptions -} from "../../shared/configuration-utils"; -import { canonicalizeConfiguration } from "../../shared/canonical-configuration"; -import { handleBooleanChange } from "../common/utils"; +} from "../../../../backend/features/configurations/utils"; +import { canonicalizeConfiguration } from "../../../../backend/features/configurations/canonical"; +import { handleBooleanChange } from "../../../lib/utils"; import { formatValueWithUnits, valueWithUnits, evaluateExpression -} from "../../shared/input-parser"; -import { useUnitInfoQuery } from "../configuration-queries"; -import { configurationQueryKey } from "../query-keys"; -import { showErrorToast } from "../common/notifications"; -import { SectionError } from "../app-common/app-zero-state"; -import { useIsConnectedToOnshape } from "../api-utils/onshape-params"; +} from "../../../../backend/features/configurations/input-parser"; +import { useUnitInfoQuery } from "../queries"; +import { configurationQueryKey } from "../../../lib/query-keys"; +import { showErrorToast } from "../../../lib/notifications"; +import { SectionError } from "../../../components/app-zero-state"; +import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; interface ConfigurationWrapperProps { configurationId: string; diff --git a/src/frontend/insert/insert-menu.tsx b/src/frontend/features/insert/components/insert-menu.tsx similarity index 88% rename from src/frontend/insert/insert-menu.tsx rename to src/frontend/features/insert/components/insert-menu.tsx index 0da54226e..e24de459e 100644 --- a/src/frontend/insert/insert-menu.tsx +++ b/src/frontend/features/insert/components/insert-menu.tsx @@ -1,34 +1,34 @@ import { useSearch } from "@tanstack/react-router"; import { ReactNode, useCallback, useEffect, useState } from "react"; -import { getFavoriteForInsertable } from "../../shared/favorites-dto"; -import { InsertableOut } from "../../shared/library-dto"; -import { ElementType } from "../../shared/element-type"; +import { getFavoriteForInsertable } from "../../../../backend/features/favorites/dto"; +import { InsertableOut } from "../../../../backend/features/library/dto"; +import { ElementType } from "../../../../backend/lib/onshape/element-type"; import { Button, Checkbox, Group, Stack, Text } from "@mantine/core"; import { IconInfoCircle, IconPlus } from "@tabler/icons-react"; -import { FontWeight, IconSize } from "../common/style-constants"; +import { FontWeight, IconSize } from "../../../lib/style-constants"; import { modals } from "@mantine/modals"; import { useIsFetching } from "@tanstack/react-query"; -import { PreviewImageCard } from "./thumbnail"; -import { FavoriteButton } from "../favorites/favorite-button"; +import { PreviewImageCard } from "../../thumbnails/components/thumbnail"; +import { FavoriteButton } from "../../favorites/components/favorite-button"; import { NotificationAction, renderNotification -} from "../common/notifications"; -import { MenuButton } from "../app-common/app-menu"; -import { InsertableMenuItems } from "../cards/insertable-card"; +} from "../../../lib/notifications"; +import { MenuButton } from "../../../components/app-menu"; +import { InsertableMenuItems } from "../../library/components/insertable-card"; import { ConfigurationWrapper } from "./configurations"; -import { useInsertMutation } from "./insert-hooks"; +import { useInsertMutation } from "../insert-hooks"; import { ParameterValues, SearchRecord -} from "../../shared/configuration-models"; -import { encodeCanonicalConfiguration } from "../../shared/canonical-configuration"; -import { useFavoritesQuery } from "../favorites-queries"; -import { useUiState } from "../api-utils/ui-state"; +} from "../../../../backend/features/configurations/models"; +import { encodeCanonicalConfiguration } from "../../../../backend/features/configurations/canonical"; +import { useFavoritesQuery } from "../../favorites/queries"; +import { useUiState } from "../../../lib/ui-state"; import { notifications } from "@mantine/notifications"; -import { RequireSignIn, useIsSignedIn } from "../api-utils/access-level"; -import { useIsConnectedToOnshape } from "../api-utils/onshape-params"; -import { startSignIn } from "../api-utils/sign-in"; +import { RequireSignIn, useIsSignedIn } from "../../auth/access-level"; +import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; +import { startSignIn } from "../../auth/sign-in"; interface OpenInsertMenuProps { insertable: InsertableOut; diff --git a/src/frontend/insert/insert-hooks.ts b/src/frontend/features/insert/insert-hooks.ts similarity index 83% rename from src/frontend/insert/insert-hooks.ts rename to src/frontend/features/insert/insert-hooks.ts index 3e01a59d4..6098d8cd4 100644 --- a/src/frontend/insert/insert-hooks.ts +++ b/src/frontend/features/insert/insert-hooks.ts @@ -1,16 +1,16 @@ import { useMutation } from "@tanstack/react-query"; import { useSearch } from "@tanstack/react-router"; -import { apiPost } from "../api-utils/api"; -import { InsertableOut } from "../../shared/library-dto"; -import { ElementType } from "../../shared/element-type"; -import { type ElementPath } from "../../shared/onshape-path"; -import { showLoadingToast, showSuccessToast } from "../common/notifications"; -import { queryClient } from "../query-client"; -import { getAppErrorHandler } from "../api-utils/errors"; +import { apiPost } from "../../lib/api-client"; +import { InsertableOut } from "../../../backend/features/library/dto"; +import { ElementType } from "../../../backend/lib/onshape/element-type"; +import { type ElementPath } from "../../../backend/lib/onshape/path"; +import { showLoadingToast, showSuccessToast } from "../../lib/notifications"; +import { queryClient } from "../../lib/query-client"; +import { getAppErrorHandler } from "../../lib/errors"; import { useMemo } from "react"; -import { ParameterValues } from "../../shared/configuration-models"; -import { toInsertablePath } from "../api-utils/library"; -import { sendOpenFeatureMessage } from "../api-utils/messages"; +import { ParameterValues } from "../../../backend/features/configurations/models"; +import { toInsertablePath } from "../library/library-path"; +import { sendOpenFeatureMessage } from "../../lib/messages"; export interface InsertArgs { isFavorite: boolean; diff --git a/src/frontend/configuration-queries.ts b/src/frontend/features/insert/queries.ts similarity index 71% rename from src/frontend/configuration-queries.ts rename to src/frontend/features/insert/queries.ts index 138923676..850ea3659 100644 --- a/src/frontend/configuration-queries.ts +++ b/src/frontend/features/insert/queries.ts @@ -1,8 +1,11 @@ import { useQuery } from "@tanstack/react-query"; -import { apiGet } from "./api-utils/api"; -import { EMPTY_UNIT_INFO, type UnitInfo } from "../shared/configuration-models"; -import { InstancePath } from "../shared/onshape-path"; -import { unitInfoQueryKey } from "./query-keys"; +import { apiGet } from "../../lib/api-client"; +import { + EMPTY_UNIT_INFO, + type UnitInfo +} from "../../../backend/features/configurations/models"; +import { InstancePath } from "../../../backend/lib/onshape/path"; +import { unitInfoQueryKey } from "../../lib/query-keys"; /** * The current document's units. Disabled when not connected to a document, and diff --git a/src/frontend/cards/card-hooks.ts b/src/frontend/features/library/card-hooks.ts similarity index 90% rename from src/frontend/cards/card-hooks.ts rename to src/frontend/features/library/card-hooks.ts index c5cec3664..da536dfc0 100644 --- a/src/frontend/cards/card-hooks.ts +++ b/src/frontend/features/library/card-hooks.ts @@ -1,27 +1,23 @@ -import { useAccessData } from "../api-utils/access-level"; +import { useAccessData } from "../auth/access-level"; import { useMutation } from "@tanstack/react-query"; import { modals } from "@mantine/modals"; -import { apiPost } from "../api-utils/api"; -import { LibraryBuildStatus } from "../../shared/build-status-dto"; -import { InsertableOut } from "../../shared/library-dto"; -import { hasUserAccess } from "../../shared/access-level"; +import { apiPost } from "../../lib/api-client"; +import { LibraryBuildStatus } from "../../../backend/features/build-checker/dto"; +import { InsertableOut } from "../../../backend/features/library/dto"; +import { hasUserAccess } from "../../../backend/features/auth/access-level"; import { useCallback, useMemo } from "react"; import { showErrorToast, showLoadingToast, showSuccessToast -} from "../common/notifications"; -import { - toInsertablePath, - toLibraryPath, - useLibraryId -} from "../api-utils/library"; -import { getAppErrorHandler } from "../api-utils/errors"; -import { useCacheVersion } from "../library-queries"; -import { buildStatusQueryKey } from "../query-keys"; -import { useRefreshLibrary } from "../api-utils/refresh"; -import { patchQuery } from "../common/utils"; -import { useCloseBuildCard } from "./build-status"; +} from "../../lib/notifications"; +import { toInsertablePath, toLibraryPath, useLibraryId } from "./library-path"; +import { getAppErrorHandler } from "../../lib/errors"; +import { useCacheVersion } from "./queries"; +import { buildStatusQueryKey } from "../../lib/query-keys"; +import { useRefreshLibrary } from "../../lib/refresh"; +import { patchQuery } from "../../lib/utils"; +import { useCloseBuildCard } from "../build-status/components/build-status"; /** The build-status query key for the currently-viewed library. */ function useBuildStatusKey() { diff --git a/src/frontend/groups/add-group-menu.tsx b/src/frontend/features/library/components/add-group-menu.tsx similarity index 84% rename from src/frontend/groups/add-group-menu.tsx rename to src/frontend/features/library/components/add-group-menu.tsx index 9fc0c30b4..ecfef9a84 100644 --- a/src/frontend/groups/add-group-menu.tsx +++ b/src/frontend/features/library/components/add-group-menu.tsx @@ -1,17 +1,17 @@ import { Button, Group, Menu, TextInput } from "@mantine/core"; import { modals } from "@mantine/modals"; import { IconPlus } from "@tabler/icons-react"; -import { IconSize } from "../common/style-constants"; +import { IconSize } from "../../../lib/style-constants"; import { ReactNode, useState } from "react"; import { useMutation } from "@tanstack/react-query"; -import { apiPost } from "../api-utils/api"; -import { parseUrl } from "../common/url"; -import { getAppErrorHandler, HandledError } from "../api-utils/errors"; -import { showInfoToast, showLoadingToast } from "../common/notifications"; -import { queryClient } from "../query-client"; -import { toLibraryPath, useLibraryId } from "../api-utils/library"; -import { jobStatusQueryKey } from "../query-keys"; -import type { JobStatus } from "../../shared/library-dto"; +import { apiPost } from "../../../lib/api-client"; +import { parseUrl } from "../../../lib/url"; +import { getAppErrorHandler, HandledError } from "../../../lib/errors"; +import { showInfoToast, showLoadingToast } from "../../../lib/notifications"; +import { queryClient } from "../../../lib/query-client"; +import { toLibraryPath, useLibraryId } from "../library-path"; +import { jobStatusQueryKey } from "../../../lib/query-keys"; +import type { JobStatus } from "../../../../backend/features/library/dto"; function openAddGroupMenu(selectedGroupId?: string) { modals.open({ diff --git a/src/frontend/cards/card-components.tsx b/src/frontend/features/library/components/card-components.tsx similarity index 90% rename from src/frontend/cards/card-components.tsx rename to src/frontend/features/library/components/card-components.tsx index 53a8ebf85..28289cd6b 100644 --- a/src/frontend/cards/card-components.tsx +++ b/src/frontend/features/library/components/card-components.tsx @@ -7,26 +7,35 @@ import { IconRefresh, IconSettings } from "@tabler/icons-react"; -import { IconColor, IconSize } from "../common/style-constants"; -import { copyUrlToClipboard, makeUrl, openUrlInNewTab } from "../common/url"; +import { IconColor, IconSize } from "../../../lib/style-constants"; +import { copyUrlToClipboard, makeUrl, openUrlInNewTab } from "../../../lib/url"; import { Fragment, PropsWithChildren, ReactNode, useCallback } from "react"; -import { AppContextMenu, MenuButton } from "../app-common/app-menu"; -import { type Position, SearchHit } from "../search/search"; -import { HighlightedText, SearchHitTitle } from "../search/search-results"; -import { CardThumbnail, type ThumbnailTarget } from "../insert/thumbnail"; -import { ConfigurablePath, InstancePath } from "../../shared/onshape-path"; -import { openCannotDeriveAssemblyAlert } from "../app/alerts"; +import { AppContextMenu, MenuButton } from "../../../components/app-menu"; +import { type Position, SearchHit } from "../../search/search"; +import { + HighlightedText, + SearchHitTitle +} from "../../search/components/search-results"; +import { + CardThumbnail, + type ThumbnailTarget +} from "../../thumbnails/components/thumbnail"; +import { + ConfigurablePath, + InstancePath +} from "../../../../backend/lib/onshape/path"; +import { openCannotDeriveAssemblyAlert } from "../../../components/alerts"; import { useInsertMutation, useIsAssemblyInPartStudio -} from "../insert/insert-hooks"; -import { InsertableOut } from "../../shared/library-dto"; -import { ElementType } from "../../shared/element-type"; +} from "../../insert/insert-hooks"; +import { InsertableOut } from "../../../../backend/features/library/dto"; +import { ElementType } from "../../../../backend/lib/onshape/element-type"; -import { ParameterValues } from "../../shared/configuration-models"; +import { ParameterValues } from "../../../../backend/features/configurations/models"; import { useSearch } from "@tanstack/react-router"; -import { RequireAccessLevel } from "../api-utils/access-level"; -import { useReloadThumbnailMutation } from "./card-hooks"; +import { RequireAccessLevel } from "../../auth/access-level"; +import { useReloadThumbnailMutation } from "../card-hooks"; interface OpenDocumentItemsProps { path: InstancePath | ConfigurablePath; diff --git a/src/frontend/groups/group-card.tsx b/src/frontend/features/library/components/group-card.tsx similarity index 86% rename from src/frontend/groups/group-card.tsx rename to src/frontend/features/library/components/group-card.tsx index 41c276b77..d3112da42 100644 --- a/src/frontend/groups/group-card.tsx +++ b/src/frontend/features/library/components/group-card.tsx @@ -5,31 +5,31 @@ import { IconEyeOff, IconTrash } from "@tabler/icons-react"; -import { IconSize } from "../common/style-constants"; +import { IconSize } from "../../../lib/style-constants"; import { useNavigate } from "@tanstack/react-router"; import { PropsWithChildren, ReactNode } from "react"; -import { GroupOut, LibraryOut } from "../../shared/library-dto"; +import { GroupOut, LibraryOut } from "../../../../backend/features/library/dto"; import { useMutation } from "@tanstack/react-query"; -import { apiPost, apiDelete } from "../api-utils/api"; -import { showErrorToast } from "../common/notifications"; -import { queryClient } from "../query-client"; -import { ChangeOrderItems } from "../common/change-order"; -import { useSetVisibilityMutation } from "../cards/card-hooks"; +import { apiPost, apiDelete } from "../../../lib/api-client"; +import { showErrorToast } from "../../../lib/notifications"; +import { queryClient } from "../../../lib/query-client"; +import { ChangeOrderItems } from "../../../components/change-order"; +import { useSetVisibilityMutation } from "../card-hooks"; import { AdminOptionsSubmenu, CardTitle, ItemRow, OpenDocumentItems, ReloadThumbnailMenuItem -} from "../cards/card-components"; +} from "./card-components"; import { AddGroupItem } from "./add-group-menu"; -import { GroupStatusBadge } from "../cards/build-status"; -import { useRefreshLibrary } from "../api-utils/refresh"; -import { useBuildStatusQuery } from "../build-status-queries"; -import { useCacheVersion, useLibraryQuery } from "../library-queries"; -import { libraryQueryKey } from "../query-keys"; -import { toLibraryPath, useLibraryId } from "../api-utils/library"; -import { getQueryUpdater, useIsHome } from "../common/utils"; +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 { toLibraryPath, useLibraryId } from "../library-path"; +import { getQueryUpdater, useIsHome } from "../../../lib/utils"; interface GroupCardProps extends PropsWithChildren { group: GroupOut; diff --git a/src/frontend/cards/insertable-card.tsx b/src/frontend/features/library/components/insertable-card.tsx similarity index 80% rename from src/frontend/cards/insertable-card.tsx rename to src/frontend/features/library/components/insertable-card.tsx index 9cc8fe36c..6b6807fdc 100644 --- a/src/frontend/cards/insertable-card.tsx +++ b/src/frontend/features/library/components/insertable-card.tsx @@ -1,16 +1,19 @@ -import { encodeCanonicalConfiguration } from "../../shared/canonical-configuration"; +import { encodeCanonicalConfiguration } from "../../../../backend/features/configurations/canonical"; import { Menu } from "@mantine/core"; import { PropsWithChildren, ReactNode } from "react"; -import { Favorite, getFavoriteForInsertable } from "../../shared/favorites-dto"; -import { InsertableOut } from "../../shared/library-dto"; -import { ParameterValues } from "../../shared/configuration-models"; -import { SearchHit } from "../search/search"; +import { + Favorite, + getFavoriteForInsertable +} from "../../../../backend/features/favorites/dto"; +import { InsertableOut } from "../../../../backend/features/library/dto"; +import { ParameterValues } from "../../../../backend/features/configurations/models"; +import { SearchHit } from "../../search/search"; import { FavoriteButton, FavoriteInsertableItem -} from "../favorites/favorite-button"; -import { useIsInsertableHidden } from "./card-hooks"; -import { InsertableStatusBadge } from "./build-status"; +} from "../../favorites/components/favorite-button"; +import { useIsInsertableHidden } from "../card-hooks"; +import { InsertableStatusBadge } from "../../build-status/components/build-status"; import { AdminOptionsSubmenu, CardTitle, @@ -19,12 +22,12 @@ import { QuickInsertItems, ReloadThumbnailMenuItem } from "./card-components"; -import { openCannotDeriveAssemblyAlert } from "../app/alerts"; -import { useIsAssemblyInPartStudio } from "../insert/insert-hooks"; -import { openInsertMenu } from "../insert/insert-menu"; -import { useFavoritesQuery } from "../favorites-queries"; -import { RequireSignIn } from "../api-utils/access-level"; -import { useIsConnectedToOnshape } from "../api-utils/onshape-params"; +import { openCannotDeriveAssemblyAlert } from "../../../components/alerts"; +import { useIsAssemblyInPartStudio } from "../../insert/insert-hooks"; +import { openInsertMenu } from "../../insert/components/insert-menu"; +import { useFavoritesQuery } from "../../favorites/queries"; +import { RequireSignIn } from "../../auth/access-level"; +import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; interface InsertableCardProps extends PropsWithChildren { insertable: InsertableOut; diff --git a/src/frontend/settings/reload-groups-button.tsx b/src/frontend/features/library/components/reload-groups-button.tsx similarity index 82% rename from src/frontend/settings/reload-groups-button.tsx rename to src/frontend/features/library/components/reload-groups-button.tsx index c648638f7..f664cb0c2 100644 --- a/src/frontend/settings/reload-groups-button.tsx +++ b/src/frontend/features/library/components/reload-groups-button.tsx @@ -1,16 +1,16 @@ import { Button } from "@mantine/core"; import { modals } from "@mantine/modals"; import { IconRefresh } from "@tabler/icons-react"; -import { IconSize } from "../common/style-constants"; +import { IconSize } from "../../../lib/style-constants"; import { ReactNode } from "react"; -import { showInfoToast } from "../common/notifications"; +import { showInfoToast } from "../../../lib/notifications"; import { useMutation } from "@tanstack/react-query"; -import { apiPost } from "../api-utils/api"; -import { queryClient } from "../query-client"; -import { getAppErrorHandler } from "../api-utils/errors"; -import { toLibraryPath, useLibraryId } from "../api-utils/library"; -import { jobStatusQueryKey } from "../query-keys"; -import type { JobStatus } from "../../shared/library-dto"; +import { apiPost } from "../../../lib/api-client"; +import { queryClient } from "../../../lib/query-client"; +import { getAppErrorHandler } from "../../../lib/errors"; +import { toLibraryPath, useLibraryId } from "../library-path"; +import { jobStatusQueryKey } from "../../../lib/query-keys"; +import type { JobStatus } from "../../../../backend/features/library/dto"; interface ReloadGroupsButtonProps { reloadAll?: boolean; diff --git a/src/frontend/api-utils/library.ts b/src/frontend/features/library/library-path.ts similarity index 92% rename from src/frontend/api-utils/library.ts rename to src/frontend/features/library/library-path.ts index a87edafe8..8e8b77b62 100644 --- a/src/frontend/api-utils/library.ts +++ b/src/frontend/features/library/library-path.ts @@ -1,5 +1,8 @@ import { useParams } from "@tanstack/react-router"; -import { DEFAULT_LIBRARY_ID, LibraryId } from "../../shared/library-id"; +import { + DEFAULT_LIBRARY_ID, + LibraryId +} from "../../../backend/features/library/library-id"; /** Returns the library being displayed, which the url is the source of truth for. */ export function useLibraryId(): LibraryId { diff --git a/src/frontend/library-queries.ts b/src/frontend/features/library/queries.ts similarity index 88% rename from src/frontend/library-queries.ts rename to src/frontend/features/library/queries.ts index a845eb5ef..d6d805c3b 100644 --- a/src/frontend/library-queries.ts +++ b/src/frontend/features/library/queries.ts @@ -1,16 +1,19 @@ /** Queries for the library snapshot, its cache version, and its load jobs. */ import { queryOptions, useQuery } from "@tanstack/react-query"; -import { apiGet } from "./api-utils/api"; -import { type JobStatus, type LibraryOut } from "../shared/library-dto"; -import { hasEditorAccess } from "../shared/access-level"; -import { LibraryId } from "../shared/library-id"; -import { useAccessData } from "./api-utils/access-level"; -import { toLibraryPath, useLibraryId } from "./api-utils/library"; +import { apiGet } from "../../lib/api-client"; +import { + type JobStatus, + type LibraryOut +} from "../../../backend/features/library/dto"; +import { hasEditorAccess } from "../../../backend/features/auth/access-level"; +import { LibraryId } from "../../../backend/features/library/library-id"; +import { useAccessData } from "../auth/access-level"; +import { toLibraryPath, useLibraryId } from "./library-path"; import { jobStatusQueryKey, libraryQueryKey, libraryVersionQueryKey -} from "./query-keys"; +} from "../../lib/query-keys"; export function getLibraryQuery(libraryId: LibraryId, cacheVersion: number) { return queryOptions({ diff --git a/src/frontend/search/search-errors.tsx b/src/frontend/features/search/components/search-errors.tsx similarity index 91% rename from src/frontend/search/search-errors.tsx rename to src/frontend/features/search/components/search-errors.tsx index 63155e207..1e98ece4c 100644 --- a/src/frontend/search/search-errors.tsx +++ b/src/frontend/features/search/components/search-errors.tsx @@ -1,12 +1,16 @@ import { Alert, Button, Group } from "@mantine/core"; import { IconHeartBroken, IconSearch } from "@tabler/icons-react"; -import { HeartIconColor, IconColor, IconSize } from "../common/style-constants"; +import { + HeartIconColor, + IconColor, + IconSize +} from "../../../lib/style-constants"; import { ReactNode } from "react"; -import { ClearFiltersButton } from "../settings/vendor-filters"; -import { FilterResult, ObjectLabel, plural } from "./search"; +import { ClearFiltersButton } from "../../settings/components/vendor-filters"; +import { FilterResult, ObjectLabel, plural } from "../search"; import { useNavigate } from "@tanstack/react-router"; -import { SectionError } from "../app-common/app-zero-state"; -import { useLibraryId } from "../api-utils/library"; +import { SectionError } from "../../../components/app-zero-state"; +import { useLibraryId } from "../../library/library-path"; function getGroupString(filtered: FilterResult, objectLabel: ObjectLabel) { if (filtered.byGroup > 1) { diff --git a/src/frontend/search/search-results.tsx b/src/frontend/features/search/components/search-results.tsx similarity index 88% rename from src/frontend/search/search-results.tsx rename to src/frontend/features/search/components/search-results.tsx index 4c94076e7..b30cd0a5d 100644 --- a/src/frontend/search/search-results.tsx +++ b/src/frontend/features/search/components/search-results.tsx @@ -1,13 +1,16 @@ -import { useAccessData } from "../api-utils/access-level"; +import { useAccessData } from "../../auth/access-level"; import { ReactNode } from "react"; -import { Position, SearchFilters, SearchHit, doSearch } from "./search"; -import { InsertableCard } from "../cards/insertable-card"; -import { ItemTable } from "../cards/card-components"; -import { SectionError, SectionLoading } from "../app-common/app-zero-state"; +import { Position, SearchFilters, SearchHit, doSearch } from "../search"; +import { InsertableCard } from "../../library/components/insertable-card"; +import { ItemTable } from "../../library/components/card-components"; +import { + SectionError, + SectionLoading +} from "../../../components/app-zero-state"; import { NoSearchResultError, SearchCallout } from "./search-errors"; -import { useLibraryQuery } from "../library-queries"; -import { useSearchDbQuery } from "../search-queries"; -import { hasEditorAccess } from "../../shared/access-level"; +import { useLibraryQuery } from "../../library/queries"; +import { useSearchDbQuery } from "../queries"; +import { hasEditorAccess } from "../../../../backend/features/auth/access-level"; interface SearchResultsProps { query: string; diff --git a/src/frontend/search/filter.ts b/src/frontend/features/search/filter.ts similarity index 91% rename from src/frontend/search/filter.ts rename to src/frontend/features/search/filter.ts index 62d347d87..c7c4eedbc 100644 --- a/src/frontend/search/filter.ts +++ b/src/frontend/features/search/filter.ts @@ -1,5 +1,5 @@ -import { InsertableOut } from "../../shared/library-dto"; -import { Vendor } from "../../shared/vendors"; +import { InsertableOut } from "../../../backend/features/library/dto"; +import { Vendor } from "../../../backend/features/library/vendors"; import { FilterResult } from "./search"; export interface FilterArgs { diff --git a/src/frontend/search-queries.ts b/src/frontend/features/search/queries.ts similarity index 70% rename from src/frontend/search-queries.ts rename to src/frontend/features/search/queries.ts index 1fec0d017..8c9d82ab8 100644 --- a/src/frontend/search-queries.ts +++ b/src/frontend/features/search/queries.ts @@ -1,11 +1,11 @@ import { queryOptions, useQuery } from "@tanstack/react-query"; import MiniSearch from "minisearch"; -import { apiGetText } from "./api-utils/api"; -import { LibraryId } from "../shared/library-id"; -import { SEARCH_OPTIONS } from "../shared/search"; -import { toLibraryPath, useLibraryId } from "./api-utils/library"; -import { useCacheVersion } from "./library-queries"; -import { searchDbQueryKey } from "./query-keys"; +import { apiGetText } from "../../lib/api-client"; +import { LibraryId } from "../../../backend/features/library/library-id"; +import { SEARCH_OPTIONS } from "../../../backend/features/search/search-index"; +import { toLibraryPath, useLibraryId } from "../library/library-path"; +import { useCacheVersion } from "../library/queries"; +import { searchDbQueryKey } from "../../lib/query-keys"; export function getSearchDbQuery(libraryId: LibraryId, cacheVersion: number) { return queryOptions({ diff --git a/src/frontend/search/search.test.ts b/src/frontend/features/search/search.test.ts similarity index 97% rename from src/frontend/search/search.test.ts rename to src/frontend/features/search/search.test.ts index 5ffebaaa0..ade4b6cc0 100644 --- a/src/frontend/search/search.test.ts +++ b/src/frontend/features/search/search.test.ts @@ -1,12 +1,16 @@ import { describe, expect, it } from "vitest"; -import { buildSearchDb, processTerm, tokenize } from "../../shared/search"; +import { + buildSearchDb, + processTerm, + tokenize +} from "../../../backend/features/search/search-index"; import { doSearch, type Position } from "./search"; -import { LibraryOut } from "../../shared/library-dto"; -import { ElementType } from "../../shared/element-type"; +import { LibraryOut } from "../../../backend/features/library/dto"; +import { ElementType } from "../../../backend/lib/onshape/element-type"; import { ConfigurationRecord, ParameterValues -} from "../../shared/configuration-models"; +} from "../../../backend/features/configurations/models"; /** Builds a configuration record carrying a part number, name, + configuration. */ function record( diff --git a/src/frontend/search/search.ts b/src/frontend/features/search/search.ts similarity index 97% rename from src/frontend/search/search.ts rename to src/frontend/features/search/search.ts index a2eff1df3..929dbb902 100644 --- a/src/frontend/search/search.ts +++ b/src/frontend/features/search/search.ts @@ -1,10 +1,13 @@ import MiniSearch, { SearchResult as MiniSearchResult } from "minisearch"; -import { Vendor } from "../../shared/vendors"; -import { SearchDocument, normalizeForMatch } from "../../shared/search"; +import { Vendor } from "../../../backend/features/library/vendors"; +import { + SearchDocument, + normalizeForMatch +} from "../../../backend/features/search/search-index"; import { ParameterValues, SearchRecord -} from "../../shared/configuration-models"; +} from "../../../backend/features/configurations/models"; /** * A user facing name to use for elements currently being filtered/searched on. diff --git a/src/frontend/settings/settings-menu.tsx b/src/frontend/features/settings/components/settings-menu.tsx similarity index 81% rename from src/frontend/settings/settings-menu.tsx rename to src/frontend/features/settings/components/settings-menu.tsx index 6ee47a512..14bdd1d6c 100644 --- a/src/frontend/settings/settings-menu.tsx +++ b/src/frontend/features/settings/components/settings-menu.tsx @@ -1,22 +1,25 @@ import { useNavigate, useRouterState } from "@tanstack/react-router"; -import { DEFAULT_SETTINGS } from "../../shared/settings"; +import { DEFAULT_SETTINGS } from "../../../../backend/features/users/settings"; import { Divider, Group, Text, Title } from "@mantine/core"; import { modals } from "@mantine/modals"; -import { FontWeight } from "../common/style-constants"; +import { FontWeight } from "../../../lib/style-constants"; import { Dispatch, ReactNode, useMemo } from "react"; -import { Theme } from "../../shared/settings"; -import { hasEditorAccess } from "../../shared/access-level"; -import { isWithinAccessLevel } from "../../shared/access-level"; -import { AccessLevel } from "../../shared/access-level"; -import { useSaveSettings } from "./settings"; -import { capitalize } from "../common/utils"; -import { OpenUrlButton } from "../common/open-url-button"; -import { RequireAccessLevel, useAccessData } from "../api-utils/access-level"; -import { useUiState } from "../api-utils/ui-state"; -import { FEEDBACK_FORM_URL } from "../common/url"; -import { AppSelect } from "../app-common/app-select"; -import { makeSelectOption, useSelectOptions } from "./select-utils"; -import { ReloadGroupsButton } from "./reload-groups-button"; +import { Theme } from "../../../../backend/features/users/settings"; +import { hasEditorAccess } from "../../../../backend/features/auth/access-level"; +import { isWithinAccessLevel } from "../../../../backend/features/auth/access-level"; +import { AccessLevel } from "../../../../backend/features/auth/access-level"; +import { useSaveSettings } from "../settings"; +import { capitalize } from "../../../lib/utils"; +import { OpenUrlButton } from "../../../components/open-url-button"; +import { RequireAccessLevel, useAccessData } from "../../auth/access-level"; +import { useUiState } from "../../../lib/ui-state"; +import { FEEDBACK_FORM_URL } from "../../../lib/url"; +import { AppSelect } from "../../../components/app-select"; +import { + makeSelectOption, + useSelectOptions +} from "../../../components/select-utils"; +import { ReloadGroupsButton } from "../../library/components/reload-groups-button"; export function openSettingsMenu() { modals.open({ diff --git a/src/frontend/settings/vendor-filters.tsx b/src/frontend/features/settings/components/vendor-filters.tsx similarity index 89% rename from src/frontend/settings/vendor-filters.tsx rename to src/frontend/features/settings/components/vendor-filters.tsx index ffedfac2e..b2053899e 100644 --- a/src/frontend/settings/vendor-filters.tsx +++ b/src/frontend/features/settings/components/vendor-filters.tsx @@ -1,11 +1,11 @@ import { ActionIcon, Button, Menu } from "@mantine/core"; import { IconFilter, IconFilterOff } from "@tabler/icons-react"; -import { HEADER_CONTROL_COLOR, IconSize } from "../common/style-constants"; +import { HEADER_CONTROL_COLOR, IconSize } from "../../../lib/style-constants"; import { ReactNode } from "react"; -import { getVendorName } from "../../shared/vendors"; -import { Vendor } from "../../shared/vendors"; -import { useUiState } from "../api-utils/ui-state"; -import { AppContextMenu } from "../app-common/app-menu"; +import { getVendorName } from "../../../../backend/features/library/vendors"; +import { Vendor } from "../../../../backend/features/library/vendors"; +import { useUiState } from "../../../lib/ui-state"; +import { AppContextMenu } from "../../../components/app-menu"; interface ClearFiltersButtonProps { /** diff --git a/src/frontend/settings/local-settings.ts b/src/frontend/features/settings/local-settings.ts similarity index 81% rename from src/frontend/settings/local-settings.ts rename to src/frontend/features/settings/local-settings.ts index b7cc8f5aa..6d75a0c6e 100644 --- a/src/frontend/settings/local-settings.ts +++ b/src/frontend/features/settings/local-settings.ts @@ -1,5 +1,12 @@ -import { DEFAULT_LIBRARY_ID, type LibraryId } from "../../shared/library-id"; -import { DEFAULT_SETTINGS, type SettingsUpdate, type Theme } from "../../shared/settings"; +import { + DEFAULT_LIBRARY_ID, + type LibraryId +} from "../../../backend/features/library/library-id"; +import { + DEFAULT_SETTINGS, + type SettingsUpdate, + type Theme +} from "../../../backend/features/users/settings"; const SETTINGS_STORAGE_KEY = "frc-design-app-settings"; diff --git a/src/frontend/settings/settings.ts b/src/frontend/features/settings/settings.ts similarity index 75% rename from src/frontend/settings/settings.ts rename to src/frontend/features/settings/settings.ts index fb2d2ad22..b7fd961e1 100644 --- a/src/frontend/settings/settings.ts +++ b/src/frontend/features/settings/settings.ts @@ -1,8 +1,8 @@ import { useMutation } from "@tanstack/react-query"; -import type { SettingsUpdate } from "../../shared/settings"; -import { showErrorToast } from "../common/notifications"; -import { apiPost } from "../api-utils/api"; -import { useIsSignedIn } from "../api-utils/access-level"; +import type { SettingsUpdate } from "../../../backend/features/users/settings"; +import { showErrorToast } from "../../lib/notifications"; +import { apiPost } from "../../lib/api-client"; +import { useIsSignedIn } from "../auth/access-level"; import { writeLocalSettings } from "./local-settings"; export function useSaveSettings() { diff --git a/src/frontend/insert/thumbnail.tsx b/src/frontend/features/thumbnails/components/thumbnail.tsx similarity index 90% rename from src/frontend/insert/thumbnail.tsx rename to src/frontend/features/thumbnails/components/thumbnail.tsx index cdbb60eab..14f5e486a 100644 --- a/src/frontend/insert/thumbnail.tsx +++ b/src/frontend/features/thumbnails/components/thumbnail.tsx @@ -1,19 +1,19 @@ import { useIsFetching, useQuery } from "@tanstack/react-query"; -import { loadImage, loadImageResult } from "../api-utils/api"; -import { ElementType } from "../../shared/element-type"; -import { ThumbnailSize } from "../../shared/thumbnail-types"; -import { ElementPath } from "../../shared/onshape-path"; +import { loadImage, loadImageResult } 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 { IconHelp } from "@tabler/icons-react"; import { ComponentPropsWithRef, ReactNode } from "react"; -import { DEFAULT_CANONICAL_CONFIGURATION } from "../../shared/canonical-configuration"; -import { thumbnailUrl } from "../../shared/thumbnails"; -import { configurationQueryMatchKey } from "../query-keys"; -import { SectionError } from "../app-common/app-zero-state"; -import { useTargetElementType } from "./insert-hooks"; -import { useIsSignedIn } from "../api-utils/access-level"; -import { useIsConnectedToOnshape } from "../api-utils/onshape-params"; +import { DEFAULT_CANONICAL_CONFIGURATION } from "../../../../backend/features/configurations/canonical"; +import { thumbnailUrl } from "../../../../backend/features/thumbnails/keys"; +import { configurationQueryMatchKey } from "../../../lib/query-keys"; +import { SectionError } from "../../../components/app-zero-state"; +import { useTargetElementType } from "../../insert/insert-hooks"; +import { useIsSignedIn } from "../../auth/access-level"; +import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; /** Letterbox rather than stretch, in case the render is not the size we asked for. */ const FIT_INSIDE_BOX = { diff --git a/src/frontend/api-utils/api.ts b/src/frontend/lib/api-client.ts similarity index 97% rename from src/frontend/api-utils/api.ts rename to src/frontend/lib/api-client.ts index 34b97717e..7cfc6c7ae 100644 --- a/src/frontend/api-utils/api.ts +++ b/src/frontend/lib/api-client.ts @@ -3,9 +3,9 @@ import { type URLSearchParamsInit, type QueryOptions, type PostOptions -} from "../common/utils"; +} from "./utils"; import { HandledError } from "./errors"; -import { THUMBNAIL_FALLBACK_HEADER } from "../../shared/thumbnails"; +import { THUMBNAIL_FALLBACK_HEADER } from "../../backend/features/thumbnails/keys"; import { HttpStatus } from "http-status-ts"; function getUrl( diff --git a/src/frontend/api-utils/errors.ts b/src/frontend/lib/errors.ts similarity index 92% rename from src/frontend/api-utils/errors.ts rename to src/frontend/lib/errors.ts index eda404896..e4c3fc60e 100644 --- a/src/frontend/api-utils/errors.ts +++ b/src/frontend/lib/errors.ts @@ -1,4 +1,4 @@ -import { showErrorToast, showInfoToast } from "../common/notifications"; +import { showErrorToast, showInfoToast } from "./notifications"; /** * Errors which are generated and thrown on the client. diff --git a/src/frontend/common/format-time.ts b/src/frontend/lib/format-time.ts similarity index 100% rename from src/frontend/common/format-time.ts rename to src/frontend/lib/format-time.ts diff --git a/src/frontend/api-utils/messages.ts b/src/frontend/lib/messages.ts similarity index 97% rename from src/frontend/api-utils/messages.ts rename to src/frontend/lib/messages.ts index 6e97fe1d7..385d63dc7 100644 --- a/src/frontend/api-utils/messages.ts +++ b/src/frontend/lib/messages.ts @@ -4,7 +4,7 @@ */ import { useSearch } from "@tanstack/react-router"; -import { type ElementPath } from "../../shared/onshape-path"; +import { type ElementPath } from "../../backend/lib/onshape/path"; import { useCallback, useEffect } from "react"; import { useIsConnectedToOnshape } from "./onshape-params"; diff --git a/src/frontend/common/notifications.tsx b/src/frontend/lib/notifications.tsx similarity index 100% rename from src/frontend/common/notifications.tsx rename to src/frontend/lib/notifications.tsx diff --git a/src/frontend/api-utils/onshape-params.ts b/src/frontend/lib/onshape-params.ts similarity index 84% rename from src/frontend/api-utils/onshape-params.ts rename to src/frontend/lib/onshape-params.ts index 71337e75c..404470755 100644 --- a/src/frontend/api-utils/onshape-params.ts +++ b/src/frontend/lib/onshape-params.ts @@ -1,7 +1,7 @@ import { useSearch } from "@tanstack/react-router"; -import { ElementType } from "../../shared/element-type"; -import { Theme } from "../../shared/settings"; -import { ElementPath, isElementPath } from "../../shared/onshape-path"; +import { ElementType } from "../../backend/lib/onshape/element-type"; +import { Theme } from "../../backend/features/users/settings"; +import { ElementPath, isElementPath } from "../../backend/lib/onshape/path"; /** * Documents search parameter values received from Onshape. diff --git a/src/frontend/query-client.ts b/src/frontend/lib/query-client.ts similarity index 89% rename from src/frontend/query-client.ts rename to src/frontend/lib/query-client.ts index 4af9ff21f..80f1faff0 100644 --- a/src/frontend/query-client.ts +++ b/src/frontend/lib/query-client.ts @@ -1,5 +1,5 @@ import { QueryClient } from "@tanstack/react-query"; -import { HandledError } from "./api-utils/errors"; +import { HandledError } from "./errors"; export const queryClient = new QueryClient({ defaultOptions: { diff --git a/src/frontend/query-keys.ts b/src/frontend/lib/query-keys.ts similarity index 92% rename from src/frontend/query-keys.ts rename to src/frontend/lib/query-keys.ts index 0b1071729..d4bb8f3b8 100644 --- a/src/frontend/query-keys.ts +++ b/src/frontend/lib/query-keys.ts @@ -2,8 +2,8 @@ * Every query key in one place: features read their own keys here, and the * cross-feature refresh flows invalidate by the match keys. */ -import { LibraryId } from "../shared/library-id"; -import { InstancePath } from "../shared/onshape-path"; +import { LibraryId } from "../../backend/features/library/library-id"; +import { InstancePath } from "../../backend/lib/onshape/path"; export function accessDataQueryKey() { return ["access-data"]; diff --git a/src/frontend/api-utils/refresh.ts b/src/frontend/lib/refresh.ts similarity index 82% rename from src/frontend/api-utils/refresh.ts rename to src/frontend/lib/refresh.ts index b37fc0553..3c0918edb 100644 --- a/src/frontend/api-utils/refresh.ts +++ b/src/frontend/lib/refresh.ts @@ -1,11 +1,16 @@ import { useCallback, useEffect, useRef } from "react"; import { useRouter } from "@tanstack/react-router"; -import { queryClient } from "../query-client"; -import { useJobStatusQuery } from "../library-queries"; -import { buildStatusQueryMatchKey, favoritesQueryKey, libraryQueryMatchKey, libraryVersionQueryMatchKey } from "../query-keys"; -import { accessDataQueryKey } from "../query-keys"; -import { useLibraryId } from "./library"; -import type { LibraryId } from "../../shared/library-id"; +import { queryClient } from "./query-client"; +import { useJobStatusQuery } from "../features/library/queries"; +import { + buildStatusQueryMatchKey, + favoritesQueryKey, + libraryQueryMatchKey, + libraryVersionQueryMatchKey +} 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 { diff --git a/src/frontend/common/style-constants.ts b/src/frontend/lib/style-constants.ts similarity index 100% rename from src/frontend/common/style-constants.ts rename to src/frontend/lib/style-constants.ts diff --git a/src/frontend/api-utils/ui-state.ts b/src/frontend/lib/ui-state.ts similarity index 96% rename from src/frontend/api-utils/ui-state.ts rename to src/frontend/lib/ui-state.ts index 58fca6142..b51fc459c 100644 --- a/src/frontend/api-utils/ui-state.ts +++ b/src/frontend/lib/ui-state.ts @@ -1,7 +1,7 @@ import { useSyncExternalStore } from "react"; import * as z from "zod"; -import { AccessLevel } from "../../shared/access-level"; -import { Vendor } from "../../shared/vendors"; +import { AccessLevel } from "../../backend/features/auth/access-level"; +import { Vendor } from "../../backend/features/library/vendors"; // Increment this when a breaking change is made to the schema const LATEST_VERSION = 3; diff --git a/src/frontend/common/url.tsx b/src/frontend/lib/url.tsx similarity index 94% rename from src/frontend/common/url.tsx rename to src/frontend/lib/url.tsx index 096bb5276..a97061735 100644 --- a/src/frontend/common/url.tsx +++ b/src/frontend/lib/url.tsx @@ -7,8 +7,8 @@ import { InstanceType, ConfigurablePath, isConfigurablePath -} from "../../shared/onshape-path"; -import { encodeConfigurationForQuery } from "../../shared/configuration-utils"; +} from "../../backend/lib/onshape/path"; +import { encodeConfigurationForQuery } from "../../backend/features/configurations/utils"; import { notifications } from "@mantine/notifications"; import { IconLink } from "@tabler/icons-react"; import { IconSize } from "./style-constants"; diff --git a/src/frontend/common/utils.ts b/src/frontend/lib/utils.ts similarity index 92% rename from src/frontend/common/utils.ts rename to src/frontend/lib/utils.ts index 85730d3c1..664e94768 100644 --- a/src/frontend/common/utils.ts +++ b/src/frontend/lib/utils.ts @@ -1,16 +1,16 @@ import { useMatch } from "@tanstack/react-router"; import { produce } from "immer"; import { Dispatch, SyntheticEvent } from "react"; -import { queryClient } from "../query-client"; +import { queryClient } from "./query-client"; import { QueryKey } from "@tanstack/react-query"; -export { createSearchParams } from "../../shared/url-params"; +export { createSearchParams } from "../../backend/lib/query-params"; export type { URLSearchParamsInit, ParamKeyValuePair, QueryOptions, PostOptions -} from "../../shared/url-params"; +} from "../../backend/lib/query-params"; /** * Capitalizes the first letter of a string and lower cases everything else. diff --git a/src/frontend/router.ts b/src/frontend/router.ts index 5c2fc60e5..74b57d6c7 100644 --- a/src/frontend/router.ts +++ b/src/frontend/router.ts @@ -1,6 +1,6 @@ import { createRouter } from "@tanstack/react-router"; import { routeTree } from "./routeTree.gen"; -import { RootAppSpinner } from "./app/root-spinner"; +import { RootAppSpinner } from "./components/root-spinner"; export const router = createRouter({ routeTree, diff --git a/src/frontend/routes/__root.tsx b/src/frontend/routes/__root.tsx index 822a5d300..a411148bc 100644 --- a/src/frontend/routes/__root.tsx +++ b/src/frontend/routes/__root.tsx @@ -9,12 +9,12 @@ import { MantineProvider } from "@mantine/core"; import { ModalsProvider } from "@mantine/modals"; import { Notifications } from "@mantine/notifications"; import { ReactNode, useMemo } from "react"; -import { queryClient } from "../query-client"; +import { queryClient } from "../lib/query-client"; import { createAppTheme } from "../theme"; -import { getColorTheme } from "../api-utils/onshape-params"; -import { DEFAULT_LIBRARY_ID } from "../../shared/library-id"; -import { DEFAULT_SETTINGS } from "../../shared/settings"; -import { NotFoundError, RootCrash } from "../app/root-error"; +import { getColorTheme } from "../lib/onshape-params"; +import { DEFAULT_LIBRARY_ID } from "../../backend/features/library/library-id"; +import { DEFAULT_SETTINGS } from "../../backend/features/users/settings"; +import { NotFoundError, RootCrash } from "../components/root-error"; export const Route = createRootRoute({ component: RootComponent, diff --git a/src/frontend/routes/_pages/beta-complete.tsx b/src/frontend/routes/_pages/beta-complete.tsx index 086e70b57..f7986b9b2 100644 --- a/src/frontend/routes/_pages/beta-complete.tsx +++ b/src/frontend/routes/_pages/beta-complete.tsx @@ -1,7 +1,7 @@ import type { JSX } from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { OpenUrlButton } from "../../common/open-url-button"; -import { PageError } from "../../app-common/app-zero-state"; +import { OpenUrlButton } from "../../components/open-url-button"; +import { PageError } from "../../components/app-zero-state"; export const Route = createFileRoute("/_pages/beta-complete")({ component: BetaComplete diff --git a/src/frontend/routes/_pages/cookie-error.tsx b/src/frontend/routes/_pages/cookie-error.tsx index 0c65cee7a..53c956f33 100644 --- a/src/frontend/routes/_pages/cookie-error.tsx +++ b/src/frontend/routes/_pages/cookie-error.tsx @@ -1,6 +1,6 @@ import type { JSX } from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { PageError } from "../../app-common/app-zero-state"; +import { PageError } from "../../components/app-zero-state"; export const Route = createFileRoute("/_pages/cookie-error")({ component: CookieError diff --git a/src/frontend/routes/_pages/grant-denied.tsx b/src/frontend/routes/_pages/grant-denied.tsx index 2939bc2fc..86db3c520 100644 --- a/src/frontend/routes/_pages/grant-denied.tsx +++ b/src/frontend/routes/_pages/grant-denied.tsx @@ -1,7 +1,7 @@ import type { JSX } from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { OpenUrlButton } from "../../common/open-url-button"; -import { PageError } from "../../app-common/app-zero-state"; +import { OpenUrlButton } from "../../components/open-url-button"; +import { PageError } from "../../components/app-zero-state"; export const Route = createFileRoute("/_pages/grant-denied")({ component: GrantDenied diff --git a/src/frontend/routes/_pages/safari-error.tsx b/src/frontend/routes/_pages/safari-error.tsx index a38eb93e4..c863e7170 100644 --- a/src/frontend/routes/_pages/safari-error.tsx +++ b/src/frontend/routes/_pages/safari-error.tsx @@ -1,7 +1,7 @@ import type { JSX } from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { OpenUrlButton } from "../../common/open-url-button"; -import { PageError } from "../../app-common/app-zero-state"; +import { OpenUrlButton } from "../../components/open-url-button"; +import { PageError } from "../../components/app-zero-state"; export const Route = createFileRoute("/_pages/safari-error")({ component: SafariError diff --git a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx index 8e36ac94a..866dce81c 100644 --- a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx +++ b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx @@ -1,4 +1,4 @@ -import { useAccessData } from "../../../../../api-utils/access-level"; +import { useAccessData } from "../../../../../features/auth/access-level"; import { createFileRoute, Outlet, @@ -16,26 +16,29 @@ import { FontWeight, IconColor, IconSize -} from "../../../../../common/style-constants"; +} from "../../../../../lib/style-constants"; import { ReactNode } from "react"; -import { SearchResults } from "../../../../../search/search-results"; -import { GroupOut, Insertables } from "../../../../../../shared/library-dto"; -import { hasEditorAccess } from "../../../../../../shared/access-level"; -import { filterInsertables } from "../../../../../search/filter"; -import { GroupMenuItems } from "../../../../../groups/group-card"; -import { InsertableCard } from "../../../../../cards/insertable-card"; -import { ItemTable } from "../../../../../cards/card-components"; -import { AppContextMenu, MenuButton } from "../../../../../app-common/app-menu"; -import { SearchCallout } from "../../../../../search/search-errors"; +import { SearchResults } from "../../../../../features/search/components/search-results"; +import { + GroupOut, + Insertables +} from "../../../../../../backend/features/library/dto"; +import { hasEditorAccess } from "../../../../../../backend/features/auth/access-level"; +import { filterInsertables } from "../../../../../features/search/filter"; +import { GroupMenuItems } from "../../../../../features/library/components/group-card"; +import { InsertableCard } from "../../../../../features/library/components/insertable-card"; +import { ItemTable } from "../../../../../features/library/components/card-components"; +import { AppContextMenu, MenuButton } from "../../../../../components/app-menu"; +import { SearchCallout } from "../../../../../features/search/components/search-errors"; import { PageError, SectionError, SectionLoading -} from "../../../../../app-common/app-zero-state"; -import { ClearFiltersButton } from "../../../../../settings/vendor-filters"; -import { useLibraryQuery } from "../../../../../library-queries"; -import { useLibraryId } from "../../../../../api-utils/library"; -import { useUiState, updateUiState } from "../../../../../api-utils/ui-state"; +} from "../../../../../components/app-zero-state"; +import { ClearFiltersButton } from "../../../../../features/settings/components/vendor-filters"; +import { useLibraryQuery } from "../../../../../features/library/queries"; +import { useLibraryId } from "../../../../../features/library/library-path"; +import { useUiState, updateUiState } from "../../../../../lib/ui-state"; export const Route = createFileRoute("/app/library/$libraryId/groups/$groupId")( { diff --git a/src/frontend/routes/app/library/$libraryId/index.tsx b/src/frontend/routes/app/library/$libraryId/index.tsx index 7d1781dba..7678d26fb 100644 --- a/src/frontend/routes/app/library/$libraryId/index.tsx +++ b/src/frontend/routes/app/library/$libraryId/index.tsx @@ -5,23 +5,26 @@ import { BORDER, IconSize, PrimaryColor -} from "../../../../common/style-constants"; +} from "../../../../lib/style-constants"; import { ReactNode, useState } from "react"; -import { GroupCard } from "../../../../groups/group-card"; -import { ItemTable } from "../../../../cards/card-components"; -import { HeartIcon } from "../../../../favorites/favorite-button"; -import { SearchResults } from "../../../../search/search-results"; +import { GroupCard } from "../../../../features/library/components/group-card"; +import { ItemTable } from "../../../../features/library/components/card-components"; +import { HeartIcon } from "../../../../features/favorites/components/favorite-button"; +import { SearchResults } from "../../../../features/search/components/search-results"; import { SectionError, SectionLoading -} from "../../../../app-common/app-zero-state"; -import { RequireAccessLevel } from "../../../../api-utils/access-level"; -import { AddGroupButton } from "../../../../groups/add-group-menu"; -import { FavoritesList } from "../../../../favorites/favorites-list"; -import { useLibraryQuery } from "../../../../library-queries"; -import { getLibraryName, useLibraryId } from "../../../../api-utils/library"; -import { updateUiState, useUiState } from "../../../../api-utils/ui-state"; -import { useIsSignedIn } from "../../../../api-utils/access-level"; +} from "../../../../components/app-zero-state"; +import { RequireAccessLevel } from "../../../../features/auth/access-level"; +import { AddGroupButton } from "../../../../features/library/components/add-group-menu"; +import { FavoritesList } from "../../../../features/favorites/components/favorites-list"; +import { useLibraryQuery } from "../../../../features/library/queries"; +import { + getLibraryName, + useLibraryId +} from "../../../../features/library/library-path"; +import { updateUiState, useUiState } from "../../../../lib/ui-state"; +import { useIsSignedIn } from "../../../../features/auth/access-level"; export const Route = createFileRoute("/app/library/$libraryId/")({ component: HomeList, diff --git a/src/frontend/routes/app/library/$libraryId/route.tsx b/src/frontend/routes/app/library/$libraryId/route.tsx index 2487c8d87..844573022 100644 --- a/src/frontend/routes/app/library/$libraryId/route.tsx +++ b/src/frontend/routes/app/library/$libraryId/route.tsx @@ -1,11 +1,17 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; -import { queryClient } from "../../../../query-client"; -import { getAccessDataQuery } from "../../../../api-utils/access-level"; -import { getFavoritesQuery } from "../../../../favorites-queries"; -import { getLibraryQuery, getLibraryVersionQuery } from "../../../../library-queries"; -import { getSearchDbQuery } from "../../../../search-queries"; -import { DEFAULT_LIBRARY_ID, LibraryId } from "../../../../../shared/library-id"; -import { getUiState } from "../../../../api-utils/ui-state"; +import { queryClient } from "../../../../lib/query-client"; +import { getAccessDataQuery } from "../../../../features/auth/access-level"; +import { getFavoritesQuery } from "../../../../features/favorites/queries"; +import { + getLibraryQuery, + getLibraryVersionQuery +} from "../../../../features/library/queries"; +import { getSearchDbQuery } from "../../../../features/search/queries"; +import { + DEFAULT_LIBRARY_ID, + LibraryId +} from "../../../../../backend/features/library/library-id"; +import { getUiState } from "../../../../lib/ui-state"; /** Restoring the last group is an entry behavior, so it happens once per load. */ let restoredGroup = false; diff --git a/src/frontend/routes/app/route.tsx b/src/frontend/routes/app/route.tsx index b341a4a29..2c589fafb 100644 --- a/src/frontend/routes/app/route.tsx +++ b/src/frontend/routes/app/route.tsx @@ -8,13 +8,13 @@ import { AppShell } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; import { Suspense } from "react"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; -import { OnshapeParams } from "../../api-utils/onshape-params"; -import { AppNavbar } from "../../app/app-navbar"; -import { SectionLoading } from "../../app-common/app-zero-state"; -import { useMessageListener } from "../../api-utils/messages"; -import { useSignInToast } from "../../api-utils/sign-in"; -import { RootAppError } from "../../app/root-error"; -import { PrimaryColor } from "../../common/style-constants"; +import { OnshapeParams } from "../../lib/onshape-params"; +import { AppNavbar } from "../../components/app-navbar"; +import { SectionLoading } from "../../components/app-zero-state"; +import { useMessageListener } from "../../lib/messages"; +import { useSignInToast } from "../../features/auth/sign-in"; +import { RootAppError } from "../../components/root-error"; +import { PrimaryColor } from "../../lib/style-constants"; export const Route = createFileRoute("/app")({ component: App, diff --git a/src/frontend/routes/index.tsx b/src/frontend/routes/index.tsx index 76462c099..bcf09fe53 100644 --- a/src/frontend/routes/index.tsx +++ b/src/frontend/routes/index.tsx @@ -1,6 +1,6 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; -import { readLocalSettings } from "../settings/local-settings"; -import { RootAppError } from "../app/root-error"; +import { readLocalSettings } from "../features/settings/local-settings"; +import { RootAppError } from "../components/root-error"; // Direct entry for a user opening the app outside Onshape. Onshape's own launch // is handled server-side, so it never reaches this route. diff --git a/src/frontend/theme.ts b/src/frontend/theme.ts index 206e8094c..37e4f1c9d 100644 --- a/src/frontend/theme.ts +++ b/src/frontend/theme.ts @@ -1,5 +1,5 @@ import { createTheme, type MantineColorsTuple } from "@mantine/core"; -import { LibraryId } from "../shared/library-id"; +import { LibraryId } from "../backend/features/library/library-id"; /** * FRCDesign brand green ramp (index 6 = #4cae4f, the brand color). diff --git a/tsconfig.backend.json b/tsconfig.backend.json index a7043e1fe..c27523431 100644 --- a/tsconfig.backend.json +++ b/tsconfig.backend.json @@ -4,6 +4,6 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.backend.tsbuildinfo", "types": ["node", "./worker-configuration.d.ts"] }, - "include": ["src/backend", "src/shared"], - "exclude": ["src/backend/**/*.test.ts", "src/shared/**/*.test.ts"] + "include": ["src/backend"], + "exclude": ["src/backend/**/*.test.ts"] } diff --git a/tsconfig.frontend.json b/tsconfig.frontend.json index 5ee7bcac7..9b0906ae9 100644 --- a/tsconfig.frontend.json +++ b/tsconfig.frontend.json @@ -19,10 +19,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src/frontend", "src/shared"], - "exclude": [ - "src/frontend/**/*.test.ts", - "src/frontend/**/*.test.tsx", - "src/shared/**/*.test.ts" - ] + "include": ["src/frontend"], + "exclude": ["src/frontend/**/*.test.ts", "src/frontend/**/*.test.tsx"] } diff --git a/tsconfig.test.json b/tsconfig.test.json index d8ceee7f5..3a4a06211 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -18,10 +18,5 @@ "noUnusedLocals": true, "noUnusedParameters": true }, - "include": [ - "src/**/*.test.ts", - "src/**/*.test.tsx", - "src/__test_utils__", - "src/shared" - ] + "include": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/__test_utils__"] } diff --git a/vitest.config.ts b/vitest.config.ts index 67e892075..4747bbe33 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,14 +8,11 @@ export default defineConfig({ test: { projects: [ { - // Pure logic (frontend + shared) tests run in a fast Node environment. + // Frontend logic needs no bindings, so it runs in a fast Node environment. test: { name: "node", environment: "node", - include: [ - "src/frontend/**/*.test.ts", - "src/shared/**/*.test.ts" - ] + include: ["src/frontend/**/*.test.ts"] } }, { From 4d59d9b17f13e410b4e29d3929b2a8da42fbb676 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 18:31:43 +0000 Subject: [PATCH 03/56] refactor: alias frontend imports of the backend contract The frontend reaches the backend's DTOs and domain models through @backend/* instead of counting ../ levels; imports within a side stay relative. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- src/__test_utils__/configuration-fixtures.ts | 4 ++-- src/__test_utils__/insertable-fixtures.ts | 6 +++--- src/__test_utils__/mock-onshape-api.ts | 2 +- src/__test_utils__/seed.ts | 15 ++++++--------- src/__test_utils__/test-app.ts | 4 ++-- src/frontend/components/app-navbar.tsx | 2 +- src/frontend/components/root-error.tsx | 2 +- src/frontend/features/auth/access-level.tsx | 2 +- .../build-status/components/build-status.tsx | 13 +++++-------- src/frontend/features/build-status/queries.ts | 4 ++-- .../favorites/components/favorite-button.tsx | 9 +++------ .../favorites/components/favorite-card.tsx | 6 +++--- .../favorites/components/favorite-menu.tsx | 6 +++--- .../favorites/components/favorites-list.tsx | 6 +++--- src/frontend/features/favorites/queries.ts | 4 ++-- .../features/insert/components/configurations.tsx | 8 ++++---- .../features/insert/components/insert-menu.tsx | 10 +++++----- src/frontend/features/insert/insert-hooks.ts | 8 ++++---- src/frontend/features/insert/queries.ts | 4 ++-- src/frontend/features/library/card-hooks.ts | 6 +++--- .../library/components/add-group-menu.tsx | 2 +- .../library/components/card-components.tsx | 11 ++++------- .../features/library/components/group-card.tsx | 2 +- .../library/components/insertable-card.tsx | 8 ++++---- .../library/components/reload-groups-button.tsx | 2 +- src/frontend/features/library/library-path.ts | 2 +- src/frontend/features/library/queries.ts | 9 +++------ .../features/search/components/search-results.tsx | 2 +- src/frontend/features/search/filter.ts | 4 ++-- src/frontend/features/search/queries.ts | 4 ++-- src/frontend/features/search/search.test.ts | 8 ++++---- src/frontend/features/search/search.ts | 6 +++--- .../settings/components/settings-menu.tsx | 10 +++++----- .../settings/components/vendor-filters.tsx | 4 ++-- src/frontend/features/settings/local-settings.ts | 4 ++-- src/frontend/features/settings/settings.ts | 2 +- .../features/thumbnails/components/thumbnail.tsx | 10 +++++----- src/frontend/lib/api-client.ts | 2 +- src/frontend/lib/messages.ts | 2 +- src/frontend/lib/onshape-params.ts | 6 +++--- src/frontend/lib/query-keys.ts | 4 ++-- src/frontend/lib/refresh.ts | 2 +- src/frontend/lib/ui-state.ts | 4 ++-- src/frontend/lib/url.tsx | 4 ++-- src/frontend/lib/utils.ts | 4 ++-- src/frontend/routes/__root.tsx | 4 ++-- .../app/library/$libraryId/groups/$groupId.tsx | 7 ++----- .../routes/app/library/$libraryId/route.tsx | 2 +- src/frontend/theme.ts | 2 +- tsconfig.backend.json | 4 ++++ tsconfig.frontend.json | 4 ++++ tsconfig.test.json | 4 ++++ vite.config.ts | 11 +++++++++++ vitest.config.ts | 3 +++ 54 files changed, 144 insertions(+), 136 deletions(-) diff --git a/src/__test_utils__/configuration-fixtures.ts b/src/__test_utils__/configuration-fixtures.ts index 8a6e75a9b..cc4c551e2 100644 --- a/src/__test_utils__/configuration-fixtures.ts +++ b/src/__test_utils__/configuration-fixtures.ts @@ -8,8 +8,8 @@ import { type EnumParameter, type QuantityParameter, type UnitInfo -} from "../backend/features/configurations/models"; -import { QuantityType, Unit } from "../backend/features/configurations/enums"; +} from "@backend/features/configurations/models"; +import { QuantityType, Unit } from "@backend/features/configurations/enums"; /** Builds an enum parameter whose options are named after their ids. */ export function enumParam( diff --git a/src/__test_utils__/insertable-fixtures.ts b/src/__test_utils__/insertable-fixtures.ts index c11dc84a5..d77e956cd 100644 --- a/src/__test_utils__/insertable-fixtures.ts +++ b/src/__test_utils__/insertable-fixtures.ts @@ -2,9 +2,9 @@ * Factories for the load pipeline's insertable shapes. Import directly: the * barrel re-exports Workers-only helpers these tests cannot resolve. */ -import type { InsertableTarget } from "../backend/features/library/workflows/context"; -import type { ParsedInsertable } from "../backend/features/library/workflows/load-insertable"; -import { ElementType } from "../backend/lib/onshape/element-type"; +import type { InsertableTarget } from "@backend/features/library/workflows/context"; +import type { ParsedInsertable } from "@backend/features/library/workflows/load-insertable"; +import { ElementType } from "@backend/lib/onshape/element-type"; import { TEST_GROUP_ID, TEST_LIBRARY_ID, diff --git a/src/__test_utils__/mock-onshape-api.ts b/src/__test_utils__/mock-onshape-api.ts index 2a6b70f8e..92008ced9 100644 --- a/src/__test_utils__/mock-onshape-api.ts +++ b/src/__test_utils__/mock-onshape-api.ts @@ -1,4 +1,4 @@ -import { OAuthApi } from "../backend/lib/onshape/client"; +import { OAuthApi } from "@backend/lib/onshape/client"; /** * A thin shell client extending OAuthApi. diff --git a/src/__test_utils__/seed.ts b/src/__test_utils__/seed.ts index 49c0befef..7d6466784 100644 --- a/src/__test_utils__/seed.ts +++ b/src/__test_utils__/seed.ts @@ -1,4 +1,4 @@ -import { type Db } from "../backend/db/client"; +import { type Db } from "@backend/db/client"; import { configurations, favorites, @@ -6,17 +6,14 @@ import { insertables, libraries, users -} from "../backend/db/schema"; +} from "@backend/db/schema"; import { ParameterType, type ConfigurationParameter -} from "../backend/features/configurations/models"; -import { - type ElementPath, - type InstancePath -} from "../backend/lib/onshape/path"; -import { ElementType } from "../backend/lib/onshape/element-type"; -import { LibraryId } from "../backend/features/library/library-id"; +} from "@backend/features/configurations/models"; +import { type ElementPath, type InstancePath } from "@backend/lib/onshape/path"; +import { ElementType } from "@backend/lib/onshape/element-type"; +import { LibraryId } from "@backend/features/library/library-id"; export const TEST_LIBRARY_ID = LibraryId.FRC_DESIGN_LIB; export const TEST_USER_ID = "test-user"; // matches createTestApp's default userId diff --git a/src/__test_utils__/test-app.ts b/src/__test_utils__/test-app.ts index 586d72b2c..076b3a605 100644 --- a/src/__test_utils__/test-app.ts +++ b/src/__test_utils__/test-app.ts @@ -1,5 +1,5 @@ -import { createApp } from "../backend/app"; -import { AccessLevel } from "../backend/features/auth/access-level"; +import { createApp } from "@backend/app"; +import { AccessLevel } from "@backend/features/auth/access-level"; import { MOCK_ONSHAPE_API, MockOnshapeApi } from "./mock-onshape-api"; export interface TestAppOptions { diff --git a/src/frontend/components/app-navbar.tsx b/src/frontend/components/app-navbar.tsx index 7360c7425..3e75186fd 100644 --- a/src/frontend/components/app-navbar.tsx +++ b/src/frontend/components/app-navbar.tsx @@ -23,7 +23,7 @@ import { useSaveSettings } from "../features/settings/settings"; import { useIsSignedIn } from "../features/auth/access-level"; import { startSignIn } from "../features/auth/sign-in"; import { useJobStatus } from "../lib/refresh"; -import { LibraryId } from "../../backend/features/library/library-id"; +import { LibraryId } from "@backend/features/library/library-id"; import { queryClient } from "../lib/query-client"; import { getLibraryVersionQuery } from "../features/library/queries"; diff --git a/src/frontend/components/root-error.tsx b/src/frontend/components/root-error.tsx index 758cd4056..558d15725 100644 --- a/src/frontend/components/root-error.tsx +++ b/src/frontend/components/root-error.tsx @@ -6,7 +6,7 @@ import { Button } from "@mantine/core"; import { IconHome } from "@tabler/icons-react"; import { IconSize } from "../lib/style-constants"; import { ReloadGroupsButton } from "../features/library/components/reload-groups-button"; -import { DEFAULT_LIBRARY_ID } from "../../backend/features/library/library-id"; +import { DEFAULT_LIBRARY_ID } from "@backend/features/library/library-id"; /** * Catch-all error state for when a route below the root fails to load. diff --git a/src/frontend/features/auth/access-level.tsx b/src/frontend/features/auth/access-level.tsx index bdb212b43..846c2581c 100644 --- a/src/frontend/features/auth/access-level.tsx +++ b/src/frontend/features/auth/access-level.tsx @@ -6,7 +6,7 @@ import { hasAdminAccess, hasEditorAccess, isWithinAccessLevel -} from "../../../backend/features/auth/access-level"; +} from "@backend/features/auth/access-level"; import { accessDataQueryKey } from "../../lib/query-keys"; import { apiGet } from "../../lib/api-client"; import { useUiState } from "../../lib/ui-state"; diff --git a/src/frontend/features/build-status/components/build-status.tsx b/src/frontend/features/build-status/components/build-status.tsx index ef8c4ffa8..b175fb501 100644 --- a/src/frontend/features/build-status/components/build-status.tsx +++ b/src/frontend/features/build-status/components/build-status.tsx @@ -36,19 +36,16 @@ import { getIssueDescription, getIssueSeverity, getMaxSeverity -} from "../../../../backend/features/build-checker/issues"; +} from "@backend/features/build-checker/issues"; import { GroupBuildStatus, InsertableBuildStatus -} from "../../../../backend/features/build-checker/dto"; -import { - getVendorName, - Vendor -} from "../../../../backend/features/library/vendors"; +} from "@backend/features/build-checker/dto"; +import { getVendorName, Vendor } from "@backend/features/library/vendors"; import { ConfigurationParameter, ParameterType -} from "../../../../backend/features/configurations/models"; +} from "@backend/features/configurations/models"; import { AUTO_INDEX_THRESHOLD, type ConfigurationCount, @@ -56,7 +53,7 @@ import { IndexingBand, isIndexedParameter, MAX_PART_NUMBER_CONFIGURATIONS -} from "../../../../backend/features/configurations/combinations"; +} from "@backend/features/configurations/combinations"; import { FontWeight, IconColor, IconSize } from "../../../lib/style-constants"; import { RequireAccessLevel } from "../../auth/access-level"; import { useBuildStatusQuery } from "../queries"; diff --git a/src/frontend/features/build-status/queries.ts b/src/frontend/features/build-status/queries.ts index 4faa626c5..2e0dca329 100644 --- a/src/frontend/features/build-status/queries.ts +++ b/src/frontend/features/build-status/queries.ts @@ -4,8 +4,8 @@ import { useQuery } from "@tanstack/react-query"; import { apiGet } from "../../lib/api-client"; -import { type LibraryBuildStatus } from "../../../backend/features/build-checker/dto"; -import { LibraryId } from "../../../backend/features/library/library-id"; +import { type LibraryBuildStatus } from "@backend/features/build-checker/dto"; +import { LibraryId } from "@backend/features/library/library-id"; import { useLibraryId } from "../library/library-path"; import { useCacheVersion } from "../library/queries"; import { buildStatusQueryKey } from "../../lib/query-keys"; diff --git a/src/frontend/features/favorites/components/favorite-button.tsx b/src/frontend/features/favorites/components/favorite-button.tsx index c86ae26d8..f0b426d4f 100644 --- a/src/frontend/features/favorites/components/favorite-button.tsx +++ b/src/frontend/features/favorites/components/favorite-button.tsx @@ -8,12 +8,9 @@ import { HeartIconColor, IconSize } from "../../../lib/style-constants"; import { useMutation } from "@tanstack/react-query"; import { ReactNode, useState } from "react"; import { apiDelete, apiPost } from "../../../lib/api-client"; -import type { - Favorite, - FavoritesData -} from "../../../../backend/features/favorites/dto"; -import type { InsertableOut } from "../../../../backend/features/library/dto"; -import { LibraryId } from "../../../../backend/features/library/library-id"; +import type { Favorite, FavoritesData } from "@backend/features/favorites/dto"; +import type { InsertableOut } from "@backend/features/library/dto"; +import { LibraryId } from "@backend/features/library/library-id"; import { queryClient } from "../../../lib/query-client"; import { useRouter } from "@tanstack/react-router"; import { handleAppError, HandledError } from "../../../lib/errors"; diff --git a/src/frontend/features/favorites/components/favorite-card.tsx b/src/frontend/features/favorites/components/favorite-card.tsx index 3e96e2592..1818ae94e 100644 --- a/src/frontend/features/favorites/components/favorite-card.tsx +++ b/src/frontend/features/favorites/components/favorite-card.tsx @@ -1,7 +1,7 @@ -import { encodeCanonicalConfiguration } from "../../../../backend/features/configurations/canonical"; +import { encodeCanonicalConfiguration } from "@backend/features/configurations/canonical"; import { ReactNode } from "react"; -import { Favorite } from "../../../../backend/features/favorites/dto"; -import { InsertableOut } from "../../../../backend/features/library/dto"; +import { Favorite } from "@backend/features/favorites/dto"; +import { InsertableOut } from "@backend/features/library/dto"; import { useMutation } from "@tanstack/react-query"; import { apiPost } from "../../../lib/api-client"; import { queryClient } from "../../../lib/query-client"; diff --git a/src/frontend/features/favorites/components/favorite-menu.tsx b/src/frontend/features/favorites/components/favorite-menu.tsx index b8aebd8cf..452a1e72b 100644 --- a/src/frontend/features/favorites/components/favorite-menu.tsx +++ b/src/frontend/features/favorites/components/favorite-menu.tsx @@ -9,14 +9,14 @@ import { apiPost } from "../../../lib/api-client"; import { showErrorToast, showSuccessToast } from "../../../lib/notifications"; import { PreviewImageCard } from "../../thumbnails/components/thumbnail"; import { ConfigurationWrapper } from "../../insert/components/configurations"; -import type { FavoritesData } from "../../../../backend/features/favorites/dto"; +import type { FavoritesData } from "@backend/features/favorites/dto"; import { HeartIcon } from "./favorite-button"; import { queryClient } from "../../../lib/query-client"; import { ParameterValues, SearchRecord -} from "../../../../backend/features/configurations/models"; -import { encodeCanonicalConfiguration } from "../../../../backend/features/configurations/canonical"; +} from "@backend/features/configurations/models"; +import { encodeCanonicalConfiguration } from "@backend/features/configurations/canonical"; import { useFavoritesQuery } from "../queries"; import { useLibraryQuery } from "../../library/queries"; import { favoritesQueryKey } from "../../../lib/query-keys"; diff --git a/src/frontend/features/favorites/components/favorites-list.tsx b/src/frontend/features/favorites/components/favorites-list.tsx index 7885b641b..77031640e 100644 --- a/src/frontend/features/favorites/components/favorites-list.tsx +++ b/src/frontend/features/favorites/components/favorites-list.tsx @@ -3,8 +3,8 @@ import { IconHeartBroken } from "@tabler/icons-react"; import { HeartIconColor, IconSize } from "../../../lib/style-constants"; import { ReactNode } from "react"; import { filterInsertables } from "../../search/filter"; -import { getFavoriteForInsertable } from "../../../../backend/features/favorites/dto"; -import { InsertableOut } from "../../../../backend/features/library/dto"; +import { getFavoriteForInsertable } from "@backend/features/favorites/dto"; +import { InsertableOut } from "@backend/features/library/dto"; import { useUiState } from "../../../lib/ui-state"; import { SectionError, @@ -20,7 +20,7 @@ import { useFavoritesQuery } from "../queries"; import { useLibraryQuery } from "../../library/queries"; import { useSearchDbQuery } from "../../search/queries"; import { doSearch, FilterResult, SearchHit } from "../../search/search"; -import { hasEditorAccess } from "../../../../backend/features/auth/access-level"; +import { hasEditorAccess } from "@backend/features/auth/access-level"; /** * A list of current favorite cards. diff --git a/src/frontend/features/favorites/queries.ts b/src/frontend/features/favorites/queries.ts index 83875d102..447d3455c 100644 --- a/src/frontend/features/favorites/queries.ts +++ b/src/frontend/features/favorites/queries.ts @@ -1,7 +1,7 @@ import { queryOptions, useQuery } from "@tanstack/react-query"; import { apiGet } from "../../lib/api-client"; -import { type FavoritesData } from "../../../backend/features/favorites/dto"; -import { LibraryId } from "../../../backend/features/library/library-id"; +import { type FavoritesData } from "@backend/features/favorites/dto"; +import { LibraryId } from "@backend/features/library/library-id"; import { useAccessData } from "../auth/access-level"; import { useLibraryId } from "../library/library-path"; import { favoritesQueryKey } from "../../lib/query-keys"; diff --git a/src/frontend/features/insert/components/configurations.tsx b/src/frontend/features/insert/components/configurations.tsx index 529b7034c..527b97712 100644 --- a/src/frontend/features/insert/components/configurations.tsx +++ b/src/frontend/features/insert/components/configurations.tsx @@ -31,21 +31,21 @@ import { EnumOption, EMPTY_UNIT_INFO, SearchRecord -} from "../../../../backend/features/configurations/models"; +} from "@backend/features/configurations/models"; import { evaluateCondition, findRecordForConfiguration, getEvaluateOptions, getOption, getVisibleOptions -} from "../../../../backend/features/configurations/utils"; -import { canonicalizeConfiguration } from "../../../../backend/features/configurations/canonical"; +} from "@backend/features/configurations/utils"; +import { canonicalizeConfiguration } from "@backend/features/configurations/canonical"; import { handleBooleanChange } from "../../../lib/utils"; import { formatValueWithUnits, valueWithUnits, evaluateExpression -} from "../../../../backend/features/configurations/input-parser"; +} from "@backend/features/configurations/input-parser"; import { useUnitInfoQuery } from "../queries"; import { configurationQueryKey } from "../../../lib/query-keys"; import { showErrorToast } from "../../../lib/notifications"; diff --git a/src/frontend/features/insert/components/insert-menu.tsx b/src/frontend/features/insert/components/insert-menu.tsx index e24de459e..b37734231 100644 --- a/src/frontend/features/insert/components/insert-menu.tsx +++ b/src/frontend/features/insert/components/insert-menu.tsx @@ -1,8 +1,8 @@ import { useSearch } from "@tanstack/react-router"; import { ReactNode, useCallback, useEffect, useState } from "react"; -import { getFavoriteForInsertable } from "../../../../backend/features/favorites/dto"; -import { InsertableOut } from "../../../../backend/features/library/dto"; -import { ElementType } from "../../../../backend/lib/onshape/element-type"; +import { getFavoriteForInsertable } from "@backend/features/favorites/dto"; +import { InsertableOut } from "@backend/features/library/dto"; +import { ElementType } from "@backend/lib/onshape/element-type"; import { Button, Checkbox, Group, Stack, Text } from "@mantine/core"; import { IconInfoCircle, IconPlus } from "@tabler/icons-react"; import { FontWeight, IconSize } from "../../../lib/style-constants"; @@ -21,8 +21,8 @@ import { useInsertMutation } from "../insert-hooks"; import { ParameterValues, SearchRecord -} from "../../../../backend/features/configurations/models"; -import { encodeCanonicalConfiguration } from "../../../../backend/features/configurations/canonical"; +} from "@backend/features/configurations/models"; +import { encodeCanonicalConfiguration } from "@backend/features/configurations/canonical"; import { useFavoritesQuery } from "../../favorites/queries"; import { useUiState } from "../../../lib/ui-state"; import { notifications } from "@mantine/notifications"; diff --git a/src/frontend/features/insert/insert-hooks.ts b/src/frontend/features/insert/insert-hooks.ts index 6098d8cd4..aeec61af2 100644 --- a/src/frontend/features/insert/insert-hooks.ts +++ b/src/frontend/features/insert/insert-hooks.ts @@ -1,14 +1,14 @@ import { useMutation } from "@tanstack/react-query"; import { useSearch } from "@tanstack/react-router"; import { apiPost } from "../../lib/api-client"; -import { InsertableOut } from "../../../backend/features/library/dto"; -import { ElementType } from "../../../backend/lib/onshape/element-type"; -import { type ElementPath } from "../../../backend/lib/onshape/path"; +import { InsertableOut } from "@backend/features/library/dto"; +import { ElementType } from "@backend/lib/onshape/element-type"; +import { type ElementPath } from "@backend/lib/onshape/path"; import { showLoadingToast, showSuccessToast } from "../../lib/notifications"; import { queryClient } from "../../lib/query-client"; import { getAppErrorHandler } from "../../lib/errors"; import { useMemo } from "react"; -import { ParameterValues } from "../../../backend/features/configurations/models"; +import { ParameterValues } from "@backend/features/configurations/models"; import { toInsertablePath } from "../library/library-path"; import { sendOpenFeatureMessage } from "../../lib/messages"; diff --git a/src/frontend/features/insert/queries.ts b/src/frontend/features/insert/queries.ts index 850ea3659..dc803833f 100644 --- a/src/frontend/features/insert/queries.ts +++ b/src/frontend/features/insert/queries.ts @@ -3,8 +3,8 @@ import { apiGet } from "../../lib/api-client"; import { EMPTY_UNIT_INFO, type UnitInfo -} from "../../../backend/features/configurations/models"; -import { InstancePath } from "../../../backend/lib/onshape/path"; +} from "@backend/features/configurations/models"; +import { InstancePath } from "@backend/lib/onshape/path"; import { unitInfoQueryKey } from "../../lib/query-keys"; /** diff --git a/src/frontend/features/library/card-hooks.ts b/src/frontend/features/library/card-hooks.ts index da536dfc0..f0fefce2f 100644 --- a/src/frontend/features/library/card-hooks.ts +++ b/src/frontend/features/library/card-hooks.ts @@ -2,9 +2,9 @@ import { useAccessData } from "../auth/access-level"; import { useMutation } from "@tanstack/react-query"; import { modals } from "@mantine/modals"; import { apiPost } from "../../lib/api-client"; -import { LibraryBuildStatus } from "../../../backend/features/build-checker/dto"; -import { InsertableOut } from "../../../backend/features/library/dto"; -import { hasUserAccess } from "../../../backend/features/auth/access-level"; +import { LibraryBuildStatus } from "@backend/features/build-checker/dto"; +import { InsertableOut } from "@backend/features/library/dto"; +import { hasUserAccess } from "@backend/features/auth/access-level"; import { useCallback, useMemo } from "react"; import { showErrorToast, diff --git a/src/frontend/features/library/components/add-group-menu.tsx b/src/frontend/features/library/components/add-group-menu.tsx index ecfef9a84..61b59ae49 100644 --- a/src/frontend/features/library/components/add-group-menu.tsx +++ b/src/frontend/features/library/components/add-group-menu.tsx @@ -11,7 +11,7 @@ import { showInfoToast, showLoadingToast } from "../../../lib/notifications"; import { queryClient } from "../../../lib/query-client"; import { toLibraryPath, useLibraryId } from "../library-path"; import { jobStatusQueryKey } from "../../../lib/query-keys"; -import type { JobStatus } from "../../../../backend/features/library/dto"; +import type { JobStatus } from "@backend/features/library/dto"; function openAddGroupMenu(selectedGroupId?: string) { modals.open({ diff --git a/src/frontend/features/library/components/card-components.tsx b/src/frontend/features/library/components/card-components.tsx index 28289cd6b..c5bd6cae2 100644 --- a/src/frontend/features/library/components/card-components.tsx +++ b/src/frontend/features/library/components/card-components.tsx @@ -20,19 +20,16 @@ import { CardThumbnail, type ThumbnailTarget } from "../../thumbnails/components/thumbnail"; -import { - ConfigurablePath, - InstancePath -} from "../../../../backend/lib/onshape/path"; +import { ConfigurablePath, InstancePath } from "@backend/lib/onshape/path"; import { openCannotDeriveAssemblyAlert } from "../../../components/alerts"; import { useInsertMutation, useIsAssemblyInPartStudio } from "../../insert/insert-hooks"; -import { InsertableOut } from "../../../../backend/features/library/dto"; -import { ElementType } from "../../../../backend/lib/onshape/element-type"; +import { InsertableOut } from "@backend/features/library/dto"; +import { ElementType } from "@backend/lib/onshape/element-type"; -import { ParameterValues } from "../../../../backend/features/configurations/models"; +import { ParameterValues } from "@backend/features/configurations/models"; import { useSearch } from "@tanstack/react-router"; import { RequireAccessLevel } from "../../auth/access-level"; import { useReloadThumbnailMutation } from "../card-hooks"; diff --git a/src/frontend/features/library/components/group-card.tsx b/src/frontend/features/library/components/group-card.tsx index d3112da42..6e5095017 100644 --- a/src/frontend/features/library/components/group-card.tsx +++ b/src/frontend/features/library/components/group-card.tsx @@ -8,7 +8,7 @@ import { import { IconSize } from "../../../lib/style-constants"; import { useNavigate } from "@tanstack/react-router"; import { PropsWithChildren, ReactNode } from "react"; -import { GroupOut, LibraryOut } from "../../../../backend/features/library/dto"; +import { GroupOut, LibraryOut } from "@backend/features/library/dto"; import { useMutation } from "@tanstack/react-query"; import { apiPost, apiDelete } from "../../../lib/api-client"; import { showErrorToast } from "../../../lib/notifications"; diff --git a/src/frontend/features/library/components/insertable-card.tsx b/src/frontend/features/library/components/insertable-card.tsx index 6b6807fdc..53616df98 100644 --- a/src/frontend/features/library/components/insertable-card.tsx +++ b/src/frontend/features/library/components/insertable-card.tsx @@ -1,12 +1,12 @@ -import { encodeCanonicalConfiguration } from "../../../../backend/features/configurations/canonical"; +import { encodeCanonicalConfiguration } from "@backend/features/configurations/canonical"; import { Menu } from "@mantine/core"; import { PropsWithChildren, ReactNode } from "react"; import { Favorite, getFavoriteForInsertable -} from "../../../../backend/features/favorites/dto"; -import { InsertableOut } from "../../../../backend/features/library/dto"; -import { ParameterValues } from "../../../../backend/features/configurations/models"; +} from "@backend/features/favorites/dto"; +import { InsertableOut } from "@backend/features/library/dto"; +import { ParameterValues } from "@backend/features/configurations/models"; import { SearchHit } from "../../search/search"; import { FavoriteButton, diff --git a/src/frontend/features/library/components/reload-groups-button.tsx b/src/frontend/features/library/components/reload-groups-button.tsx index f664cb0c2..c7ab65966 100644 --- a/src/frontend/features/library/components/reload-groups-button.tsx +++ b/src/frontend/features/library/components/reload-groups-button.tsx @@ -10,7 +10,7 @@ import { queryClient } from "../../../lib/query-client"; import { getAppErrorHandler } from "../../../lib/errors"; import { toLibraryPath, useLibraryId } from "../library-path"; import { jobStatusQueryKey } from "../../../lib/query-keys"; -import type { JobStatus } from "../../../../backend/features/library/dto"; +import type { JobStatus } from "@backend/features/library/dto"; interface ReloadGroupsButtonProps { reloadAll?: boolean; diff --git a/src/frontend/features/library/library-path.ts b/src/frontend/features/library/library-path.ts index 8e8b77b62..998b85e93 100644 --- a/src/frontend/features/library/library-path.ts +++ b/src/frontend/features/library/library-path.ts @@ -2,7 +2,7 @@ import { useParams } from "@tanstack/react-router"; import { DEFAULT_LIBRARY_ID, LibraryId -} from "../../../backend/features/library/library-id"; +} from "@backend/features/library/library-id"; /** Returns the library being displayed, which the url is the source of truth for. */ export function useLibraryId(): LibraryId { diff --git a/src/frontend/features/library/queries.ts b/src/frontend/features/library/queries.ts index d6d805c3b..6678ba754 100644 --- a/src/frontend/features/library/queries.ts +++ b/src/frontend/features/library/queries.ts @@ -1,12 +1,9 @@ /** Queries for the library snapshot, its cache version, and its load jobs. */ import { queryOptions, useQuery } from "@tanstack/react-query"; import { apiGet } from "../../lib/api-client"; -import { - type JobStatus, - type LibraryOut -} from "../../../backend/features/library/dto"; -import { hasEditorAccess } from "../../../backend/features/auth/access-level"; -import { LibraryId } from "../../../backend/features/library/library-id"; +import { type JobStatus, type LibraryOut } from "@backend/features/library/dto"; +import { hasEditorAccess } from "@backend/features/auth/access-level"; +import { LibraryId } from "@backend/features/library/library-id"; import { useAccessData } from "../auth/access-level"; import { toLibraryPath, useLibraryId } from "./library-path"; import { diff --git a/src/frontend/features/search/components/search-results.tsx b/src/frontend/features/search/components/search-results.tsx index b30cd0a5d..d13249f96 100644 --- a/src/frontend/features/search/components/search-results.tsx +++ b/src/frontend/features/search/components/search-results.tsx @@ -10,7 +10,7 @@ import { import { NoSearchResultError, SearchCallout } from "./search-errors"; import { useLibraryQuery } from "../../library/queries"; import { useSearchDbQuery } from "../queries"; -import { hasEditorAccess } from "../../../../backend/features/auth/access-level"; +import { hasEditorAccess } from "@backend/features/auth/access-level"; interface SearchResultsProps { query: string; diff --git a/src/frontend/features/search/filter.ts b/src/frontend/features/search/filter.ts index c7c4eedbc..01fef08a3 100644 --- a/src/frontend/features/search/filter.ts +++ b/src/frontend/features/search/filter.ts @@ -1,5 +1,5 @@ -import { InsertableOut } from "../../../backend/features/library/dto"; -import { Vendor } from "../../../backend/features/library/vendors"; +import { InsertableOut } from "@backend/features/library/dto"; +import { Vendor } from "@backend/features/library/vendors"; import { FilterResult } from "./search"; export interface FilterArgs { diff --git a/src/frontend/features/search/queries.ts b/src/frontend/features/search/queries.ts index 8c9d82ab8..46510e9f7 100644 --- a/src/frontend/features/search/queries.ts +++ b/src/frontend/features/search/queries.ts @@ -1,8 +1,8 @@ import { queryOptions, useQuery } from "@tanstack/react-query"; import MiniSearch from "minisearch"; import { apiGetText } from "../../lib/api-client"; -import { LibraryId } from "../../../backend/features/library/library-id"; -import { SEARCH_OPTIONS } from "../../../backend/features/search/search-index"; +import { LibraryId } from "@backend/features/library/library-id"; +import { SEARCH_OPTIONS } from "@backend/features/search/search-index"; import { toLibraryPath, useLibraryId } from "../library/library-path"; import { useCacheVersion } from "../library/queries"; import { searchDbQueryKey } from "../../lib/query-keys"; diff --git a/src/frontend/features/search/search.test.ts b/src/frontend/features/search/search.test.ts index ade4b6cc0..c22eb4339 100644 --- a/src/frontend/features/search/search.test.ts +++ b/src/frontend/features/search/search.test.ts @@ -3,14 +3,14 @@ import { buildSearchDb, processTerm, tokenize -} from "../../../backend/features/search/search-index"; +} from "@backend/features/search/search-index"; import { doSearch, type Position } from "./search"; -import { LibraryOut } from "../../../backend/features/library/dto"; -import { ElementType } from "../../../backend/lib/onshape/element-type"; +import { LibraryOut } from "@backend/features/library/dto"; +import { ElementType } from "@backend/lib/onshape/element-type"; import { ConfigurationRecord, ParameterValues -} from "../../../backend/features/configurations/models"; +} from "@backend/features/configurations/models"; /** Builds a configuration record carrying a part number, name, + configuration. */ function record( diff --git a/src/frontend/features/search/search.ts b/src/frontend/features/search/search.ts index 929dbb902..266908508 100644 --- a/src/frontend/features/search/search.ts +++ b/src/frontend/features/search/search.ts @@ -1,13 +1,13 @@ import MiniSearch, { SearchResult as MiniSearchResult } from "minisearch"; -import { Vendor } from "../../../backend/features/library/vendors"; +import { Vendor } from "@backend/features/library/vendors"; import { SearchDocument, normalizeForMatch -} from "../../../backend/features/search/search-index"; +} from "@backend/features/search/search-index"; import { ParameterValues, SearchRecord -} from "../../../backend/features/configurations/models"; +} from "@backend/features/configurations/models"; /** * A user facing name to use for elements currently being filtered/searched on. diff --git a/src/frontend/features/settings/components/settings-menu.tsx b/src/frontend/features/settings/components/settings-menu.tsx index 14bdd1d6c..b867a4c94 100644 --- a/src/frontend/features/settings/components/settings-menu.tsx +++ b/src/frontend/features/settings/components/settings-menu.tsx @@ -1,13 +1,13 @@ import { useNavigate, useRouterState } from "@tanstack/react-router"; -import { DEFAULT_SETTINGS } from "../../../../backend/features/users/settings"; +import { DEFAULT_SETTINGS } from "@backend/features/users/settings"; import { Divider, Group, Text, Title } from "@mantine/core"; import { modals } from "@mantine/modals"; import { FontWeight } from "../../../lib/style-constants"; import { Dispatch, ReactNode, useMemo } from "react"; -import { Theme } from "../../../../backend/features/users/settings"; -import { hasEditorAccess } from "../../../../backend/features/auth/access-level"; -import { isWithinAccessLevel } from "../../../../backend/features/auth/access-level"; -import { AccessLevel } from "../../../../backend/features/auth/access-level"; +import { Theme } from "@backend/features/users/settings"; +import { hasEditorAccess } from "@backend/features/auth/access-level"; +import { isWithinAccessLevel } from "@backend/features/auth/access-level"; +import { AccessLevel } from "@backend/features/auth/access-level"; import { useSaveSettings } from "../settings"; import { capitalize } from "../../../lib/utils"; import { OpenUrlButton } from "../../../components/open-url-button"; diff --git a/src/frontend/features/settings/components/vendor-filters.tsx b/src/frontend/features/settings/components/vendor-filters.tsx index b2053899e..ecbfe7a00 100644 --- a/src/frontend/features/settings/components/vendor-filters.tsx +++ b/src/frontend/features/settings/components/vendor-filters.tsx @@ -2,8 +2,8 @@ import { ActionIcon, Button, Menu } from "@mantine/core"; import { IconFilter, IconFilterOff } from "@tabler/icons-react"; import { HEADER_CONTROL_COLOR, IconSize } from "../../../lib/style-constants"; import { ReactNode } from "react"; -import { getVendorName } from "../../../../backend/features/library/vendors"; -import { Vendor } from "../../../../backend/features/library/vendors"; +import { getVendorName } from "@backend/features/library/vendors"; +import { Vendor } from "@backend/features/library/vendors"; import { useUiState } from "../../../lib/ui-state"; import { AppContextMenu } from "../../../components/app-menu"; diff --git a/src/frontend/features/settings/local-settings.ts b/src/frontend/features/settings/local-settings.ts index 6d75a0c6e..32159041c 100644 --- a/src/frontend/features/settings/local-settings.ts +++ b/src/frontend/features/settings/local-settings.ts @@ -1,12 +1,12 @@ import { DEFAULT_LIBRARY_ID, type LibraryId -} from "../../../backend/features/library/library-id"; +} from "@backend/features/library/library-id"; import { DEFAULT_SETTINGS, type SettingsUpdate, type Theme -} from "../../../backend/features/users/settings"; +} from "@backend/features/users/settings"; const SETTINGS_STORAGE_KEY = "frc-design-app-settings"; diff --git a/src/frontend/features/settings/settings.ts b/src/frontend/features/settings/settings.ts index b7fd961e1..8e446a056 100644 --- a/src/frontend/features/settings/settings.ts +++ b/src/frontend/features/settings/settings.ts @@ -1,5 +1,5 @@ import { useMutation } from "@tanstack/react-query"; -import type { SettingsUpdate } from "../../../backend/features/users/settings"; +import type { SettingsUpdate } from "@backend/features/users/settings"; import { showErrorToast } from "../../lib/notifications"; import { apiPost } from "../../lib/api-client"; import { useIsSignedIn } from "../auth/access-level"; diff --git a/src/frontend/features/thumbnails/components/thumbnail.tsx b/src/frontend/features/thumbnails/components/thumbnail.tsx index 14f5e486a..5038f0680 100644 --- a/src/frontend/features/thumbnails/components/thumbnail.tsx +++ b/src/frontend/features/thumbnails/components/thumbnail.tsx @@ -1,14 +1,14 @@ import { useIsFetching, useQuery } from "@tanstack/react-query"; import { loadImage, loadImageResult } 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 { 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 { IconHelp } from "@tabler/icons-react"; import { ComponentPropsWithRef, ReactNode } from "react"; -import { DEFAULT_CANONICAL_CONFIGURATION } from "../../../../backend/features/configurations/canonical"; -import { thumbnailUrl } from "../../../../backend/features/thumbnails/keys"; +import { DEFAULT_CANONICAL_CONFIGURATION } from "@backend/features/configurations/canonical"; +import { thumbnailUrl } from "@backend/features/thumbnails/keys"; import { configurationQueryMatchKey } from "../../../lib/query-keys"; import { SectionError } from "../../../components/app-zero-state"; import { useTargetElementType } from "../../insert/insert-hooks"; diff --git a/src/frontend/lib/api-client.ts b/src/frontend/lib/api-client.ts index 7cfc6c7ae..7e618cb05 100644 --- a/src/frontend/lib/api-client.ts +++ b/src/frontend/lib/api-client.ts @@ -5,7 +5,7 @@ import { type PostOptions } from "./utils"; import { HandledError } from "./errors"; -import { THUMBNAIL_FALLBACK_HEADER } from "../../backend/features/thumbnails/keys"; +import { THUMBNAIL_FALLBACK_HEADER } from "@backend/features/thumbnails/keys"; import { HttpStatus } from "http-status-ts"; function getUrl( diff --git a/src/frontend/lib/messages.ts b/src/frontend/lib/messages.ts index 385d63dc7..0c99d5ec2 100644 --- a/src/frontend/lib/messages.ts +++ b/src/frontend/lib/messages.ts @@ -4,7 +4,7 @@ */ import { useSearch } from "@tanstack/react-router"; -import { type ElementPath } from "../../backend/lib/onshape/path"; +import { type ElementPath } from "@backend/lib/onshape/path"; import { useCallback, useEffect } from "react"; import { useIsConnectedToOnshape } from "./onshape-params"; diff --git a/src/frontend/lib/onshape-params.ts b/src/frontend/lib/onshape-params.ts index 404470755..36c7c221a 100644 --- a/src/frontend/lib/onshape-params.ts +++ b/src/frontend/lib/onshape-params.ts @@ -1,7 +1,7 @@ import { useSearch } from "@tanstack/react-router"; -import { ElementType } from "../../backend/lib/onshape/element-type"; -import { Theme } from "../../backend/features/users/settings"; -import { ElementPath, isElementPath } from "../../backend/lib/onshape/path"; +import { ElementType } from "@backend/lib/onshape/element-type"; +import { Theme } from "@backend/features/users/settings"; +import { ElementPath, isElementPath } from "@backend/lib/onshape/path"; /** * Documents search parameter values received from Onshape. diff --git a/src/frontend/lib/query-keys.ts b/src/frontend/lib/query-keys.ts index d4bb8f3b8..6f1b2cdd0 100644 --- a/src/frontend/lib/query-keys.ts +++ b/src/frontend/lib/query-keys.ts @@ -2,8 +2,8 @@ * Every query key in one place: features read their own keys here, and the * cross-feature refresh flows invalidate by the match keys. */ -import { LibraryId } from "../../backend/features/library/library-id"; -import { InstancePath } from "../../backend/lib/onshape/path"; +import { LibraryId } from "@backend/features/library/library-id"; +import { InstancePath } from "@backend/lib/onshape/path"; export function accessDataQueryKey() { return ["access-data"]; diff --git a/src/frontend/lib/refresh.ts b/src/frontend/lib/refresh.ts index 3c0918edb..c7953f12c 100644 --- a/src/frontend/lib/refresh.ts +++ b/src/frontend/lib/refresh.ts @@ -10,7 +10,7 @@ import { } from "./query-keys"; import { accessDataQueryKey } from "./query-keys"; import { useLibraryId } from "../features/library/library-path"; -import type { LibraryId } from "../../backend/features/library/library-id"; +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 { diff --git a/src/frontend/lib/ui-state.ts b/src/frontend/lib/ui-state.ts index b51fc459c..9e67fca14 100644 --- a/src/frontend/lib/ui-state.ts +++ b/src/frontend/lib/ui-state.ts @@ -1,7 +1,7 @@ import { useSyncExternalStore } from "react"; import * as z from "zod"; -import { AccessLevel } from "../../backend/features/auth/access-level"; -import { Vendor } from "../../backend/features/library/vendors"; +import { AccessLevel } from "@backend/features/auth/access-level"; +import { Vendor } from "@backend/features/library/vendors"; // Increment this when a breaking change is made to the schema const LATEST_VERSION = 3; diff --git a/src/frontend/lib/url.tsx b/src/frontend/lib/url.tsx index a97061735..849075bbd 100644 --- a/src/frontend/lib/url.tsx +++ b/src/frontend/lib/url.tsx @@ -7,8 +7,8 @@ import { InstanceType, ConfigurablePath, isConfigurablePath -} from "../../backend/lib/onshape/path"; -import { encodeConfigurationForQuery } from "../../backend/features/configurations/utils"; +} from "@backend/lib/onshape/path"; +import { encodeConfigurationForQuery } from "@backend/features/configurations/utils"; import { notifications } from "@mantine/notifications"; import { IconLink } from "@tabler/icons-react"; import { IconSize } from "./style-constants"; diff --git a/src/frontend/lib/utils.ts b/src/frontend/lib/utils.ts index 664e94768..ac44b1f8b 100644 --- a/src/frontend/lib/utils.ts +++ b/src/frontend/lib/utils.ts @@ -4,13 +4,13 @@ import { Dispatch, SyntheticEvent } from "react"; import { queryClient } from "./query-client"; import { QueryKey } from "@tanstack/react-query"; -export { createSearchParams } from "../../backend/lib/query-params"; +export { createSearchParams } from "@backend/lib/query-params"; export type { URLSearchParamsInit, ParamKeyValuePair, QueryOptions, PostOptions -} from "../../backend/lib/query-params"; +} from "@backend/lib/query-params"; /** * Capitalizes the first letter of a string and lower cases everything else. diff --git a/src/frontend/routes/__root.tsx b/src/frontend/routes/__root.tsx index a411148bc..a76b120e4 100644 --- a/src/frontend/routes/__root.tsx +++ b/src/frontend/routes/__root.tsx @@ -12,8 +12,8 @@ import { ReactNode, useMemo } from "react"; import { queryClient } from "../lib/query-client"; import { createAppTheme } from "../theme"; import { getColorTheme } from "../lib/onshape-params"; -import { DEFAULT_LIBRARY_ID } from "../../backend/features/library/library-id"; -import { DEFAULT_SETTINGS } from "../../backend/features/users/settings"; +import { DEFAULT_LIBRARY_ID } from "@backend/features/library/library-id"; +import { DEFAULT_SETTINGS } from "@backend/features/users/settings"; import { NotFoundError, RootCrash } from "../components/root-error"; export const Route = createRootRoute({ diff --git a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx index 866dce81c..ae9276d88 100644 --- a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx +++ b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx @@ -19,11 +19,8 @@ import { } from "../../../../../lib/style-constants"; import { ReactNode } from "react"; import { SearchResults } from "../../../../../features/search/components/search-results"; -import { - GroupOut, - Insertables -} from "../../../../../../backend/features/library/dto"; -import { hasEditorAccess } from "../../../../../../backend/features/auth/access-level"; +import { GroupOut, Insertables } from "@backend/features/library/dto"; +import { hasEditorAccess } from "@backend/features/auth/access-level"; import { filterInsertables } from "../../../../../features/search/filter"; import { GroupMenuItems } from "../../../../../features/library/components/group-card"; import { InsertableCard } from "../../../../../features/library/components/insertable-card"; diff --git a/src/frontend/routes/app/library/$libraryId/route.tsx b/src/frontend/routes/app/library/$libraryId/route.tsx index 844573022..7aaa203e2 100644 --- a/src/frontend/routes/app/library/$libraryId/route.tsx +++ b/src/frontend/routes/app/library/$libraryId/route.tsx @@ -10,7 +10,7 @@ import { getSearchDbQuery } from "../../../../features/search/queries"; import { DEFAULT_LIBRARY_ID, LibraryId -} from "../../../../../backend/features/library/library-id"; +} from "@backend/features/library/library-id"; import { getUiState } from "../../../../lib/ui-state"; /** Restoring the last group is an entry behavior, so it happens once per load. */ diff --git a/src/frontend/theme.ts b/src/frontend/theme.ts index 37e4f1c9d..323431780 100644 --- a/src/frontend/theme.ts +++ b/src/frontend/theme.ts @@ -1,5 +1,5 @@ import { createTheme, type MantineColorsTuple } from "@mantine/core"; -import { LibraryId } from "../backend/features/library/library-id"; +import { LibraryId } from "@backend/features/library/library-id"; /** * FRCDesign brand green ramp (index 6 = #4cae4f, the brand color). diff --git a/tsconfig.backend.json b/tsconfig.backend.json index c27523431..3d3e7e0ac 100644 --- a/tsconfig.backend.json +++ b/tsconfig.backend.json @@ -1,6 +1,10 @@ { "extends": "./tsconfig.node.json", "compilerOptions": { + "paths": { + "@backend/*": ["./src/backend/*"], + "@frontend/*": ["./src/frontend/*"] + }, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.backend.tsbuildinfo", "types": ["node", "./worker-configuration.d.ts"] }, diff --git a/tsconfig.frontend.json b/tsconfig.frontend.json index 9b0906ae9..4d47dbe81 100644 --- a/tsconfig.frontend.json +++ b/tsconfig.frontend.json @@ -1,5 +1,9 @@ { "compilerOptions": { + "paths": { + "@backend/*": ["./src/backend/*"], + "@frontend/*": ["./src/frontend/*"] + }, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.frontend.tsbuildinfo", "target": "es2023", "lib": ["ES2023", "DOM"], diff --git a/tsconfig.test.json b/tsconfig.test.json index 3a4a06211..04833b0aa 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -1,5 +1,9 @@ { "compilerOptions": { + "paths": { + "@backend/*": ["./src/backend/*"], + "@frontend/*": ["./src/frontend/*"] + }, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.test.tsbuildinfo", "target": "es2023", "lib": ["ES2023", "DOM"], diff --git a/vite.config.ts b/vite.config.ts index cc729f8dd..c7a2dd84b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,7 @@ import react from "@vitejs/plugin-react"; import { cloudflare } from "@cloudflare/vite-plugin"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import { existsSync, readFileSync } from "fs"; +import { fileURLToPath } from "url"; // Only enable https when localhost exists const httpsKeyPath = "localhost-key.pem"; @@ -15,8 +16,18 @@ const httpsDevServer = } : undefined; +const srcPath = (dir: string) => + fileURLToPath(new URL(`./src/${dir}`, import.meta.url)); + +/** Only the frontend -> backend contract imports use these; see AGENTS.md. */ +export const alias = { + "@backend": srcPath("backend"), + "@frontend": srcPath("frontend") +}; + // https://vite.dev/config/ export default defineConfig({ + resolve: { alias }, plugins: [ tanstackRouter({ routesDirectory: "src/frontend/routes", diff --git a/vitest.config.ts b/vitest.config.ts index 4747bbe33..bb858c06f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,11 +3,13 @@ import { readD1Migrations } from "@cloudflare/vitest-pool-workers"; import { defineConfig } from "vitest/config"; +import { alias } from "./vite.config"; export default defineConfig({ test: { projects: [ { + resolve: { alias }, // Frontend logic needs no bindings, so it runs in a fast Node environment. test: { name: "node", @@ -18,6 +20,7 @@ export default defineConfig({ { // Backend tests run in the Workers runtime with real, per-test // isolated D1/R2/KV bindings from wrangler.jsonc. + resolve: { alias }, plugins: [ cloudflareTest(async () => { const migrations = await readD1Migrations("./drizzle"); From 93ddd5e8aa103e9b5756797a002fb398d02e55ad Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 18:33:37 +0000 Subject: [PATCH 04/56] docs: describe the feature layout Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- AGENTS.md | 18 +++++++++++++ docs/GUIDE.md | 33 +++++++++++------------ docs/REFERENCE.md | 69 ++++++++++++++++++++++------------------------- 3 files changed, 65 insertions(+), 55 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 08aa63da2..8acc6b476 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,24 @@ doc comment when the signature already says it (e.g. write "returns the access level, respecting the cache" — not a paragraph re-deriving the caching). Aggressively delete comments that narrate obvious implementation details. +## Layout + +`src/` has two sides, `backend/` (the Worker) and `frontend/` (the SPA). There +is no shared directory: the backend owns the contract, and the frontend imports +it through the `@backend/*` alias. Imports within a side stay relative. + +Both sides are organized the same way: + +- `features//` — everything one feature owns. Backend features hold + `routes.ts` plus their storage, models and DTOs; frontend features hold + `queries.ts` and `components/`. +- `lib/` — cross-cutting plumbing that belongs to no single feature. +- `components/` (frontend only) — UI used by more than one feature. + +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. + # 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/docs/GUIDE.md b/docs/GUIDE.md index 38f9145ad..aee72f8be 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -40,7 +40,7 @@ In the database, Groups correspond to Documents (or more specifically Versions o ### Path types in the codebase -These are defined in `src/shared/onshape-path.ts`: +These are defined in `src/backend/lib/onshape/path.ts`: ```ts // Just a document @@ -78,10 +78,7 @@ The `apiPath()` function in `src/backend/onshape-api/api-path.ts` assembles thes ```ts import { apiPath } from "../api-path"; -import { - toInstanceApiPath, - toElementApiPath -} from "../../../shared/onshape-path"; +import { toInstanceApiPath, toElementApiPath } from "./path"; // Produces: /assemblies/d/{did}/w/{wid}/e/{eid}/features apiPath("assemblies", elementPath, toElementApiPath, { endRoute: "features" }); @@ -90,7 +87,7 @@ apiPath("assemblies", elementPath, toElementApiPath, { endRoute: "features" }); apiPath("documents", instancePath, toInstanceApiPath, { endRoute: "elements" }); ``` -The serializer functions (`toDocumentApiPath`, `toInstanceApiPath`, `toElementApiPath`) are all defined in `src/shared/onshape-path.ts` and convert a path object into its URL segment string. +The serializer functions (`toDocumentApiPath`, `toInstanceApiPath`, `toElementApiPath`) are all defined in `src/backend/lib/onshape/path.ts` and convert a path object into its URL segment string. ### Calling the Onshape API @@ -116,7 +113,7 @@ Use this when you need to expose new functionality to the frontend via a new API ### 1. Add the handler to a routes file -Open the relevant file in `src/backend/routes/` (or create a new one if the functionality is in a new area). Each file creates a Hono sub-app and registers handlers on it: +Open the `routes.ts` of the feature that owns the functionality, under `src/backend/features/` (or add a new feature directory if it belongs to none of them). Each `routes.ts` creates a Hono sub-app and registers handlers on it: ```ts export const myRoutes = getApp(); @@ -160,9 +157,9 @@ import { myRoutes } from "./routes/my-routes"; app.route("/api", myRoutes); ``` -### 3. Define the response type in shared +### 3. Define the response type -If the frontend needs to consume this endpoint, define a TypeScript interface for the response in `src/shared/api-models.ts` so both sides agree on the shape. +If the frontend needs to consume this endpoint, define a TypeScript interface for the response in the feature's `dto.ts` (e.g. `src/backend/features/library/dto.ts`). The backend owns the contract; the frontend imports it through `@backend/features//dto`, so both sides agree on the shape. --- @@ -230,7 +227,7 @@ The schema is the source of truth for what's stored in D1. Drizzle ORM reads it ### 1. Edit the schema -Open `src/shared/schema.ts`. Tables are defined using Drizzle's SQLite helpers: +Open `src/backend/db/schema.ts`. Tables are defined using Drizzle's SQLite helpers: ```ts import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; @@ -266,7 +263,7 @@ This runs all pending migrations against your local D1 database (used by `npx wr ### 4. Update API response types if needed -If the new data needs to be returned to the frontend, update the relevant interface in `src/shared/api-models.ts` and modify the query in `src/backend/library-data.ts` (if it's part of the main library response) or in the appropriate route handler. +If the new data needs to be returned to the frontend, update the relevant interface in the feature's `dto.ts` and modify the query in `src/backend/features/library/db.ts` (if it's part of the main library response) or in the appropriate route handler. ## Adding a Frontend Route @@ -349,20 +346,20 @@ function GroupPage() { If you just need to fetch data inside an existing component (without creating a new route), follow this pattern. -### 1. Define the query in `queries.ts` +### 1. Define the query in the feature's `queries.ts` -Open `src/frontend/queries.ts` and add a query definition: +Add the key to `src/frontend/lib/query-keys.ts`, then add the query to `src/frontend/features//queries.ts`: ```ts export function getMyDataQuery(someId: string) { return queryOptions({ - queryKey: ["my-data", someId], + queryKey: myDataQueryKey(someId), queryFn: () => apiGet("/my-thing/" + someId) }); } ``` -Keeping query definitions in `queries.ts` means the same query can be used in multiple components and they'll all share the same cache. +Keeping query definitions in the feature's `queries.ts` means the same query can be used in multiple components and they'll all share the same cache. Keys live in `lib/query-keys.ts` so the refresh flows can invalidate across features. ### 2. Use it in a component @@ -426,7 +423,7 @@ function useMyMutation(someId: string) { } ``` -`getQueryUpdater` (from `src/frontend/common/utils.ts`) wraps Immer's `produce()` into a function that React Query's `setQueryData` accepts. Immer allows you to mutate query results directly rather than mutating an original cache value, which is much cleaner for nested data. +`getQueryUpdater` (from `src/frontend/lib/utils.ts`) wraps Immer's `produce()` into a function that React Query's `setQueryData` accepts. Immer allows you to mutate query results directly rather than mutating an original cache value, which is much cleaner for nested data. **Why cancel queries in `onMutate`?** If a background refetch lands after the optimistic update, it will overwrite the cache with stale data. Canceling outstanding queries for that key prevents this race condition. @@ -602,7 +599,7 @@ If rule 2 tempts you to call a hook from a utility function, make the utility fu A custom hook is just a regular TypeScript function that starts with `use` and calls other hooks inside. You write them to extract repeated stateful logic out of components so it can be shared and tested independently. -Example from this codebase — `useUiState()` in `src/frontend/api-utils/ui-state.ts`: +Example from this codebase — `useUiState()` in `src/frontend/lib/ui-state.ts`: ```tsx // In ui-state.ts (a .ts file — no JSX, so no .tsx needed) @@ -656,7 +653,7 @@ Prefer Mantine component props (`c=`, `bg=`, `p=`, `radius=`) over adding new SC ## The `apiGet` / `apiPost` / `apiDelete` Helpers -These are thin wrappers around `fetch` defined in `src/frontend/api-utils/api.ts`. They: +These are thin wrappers around `fetch` defined in `src/frontend/lib/api-client.ts`. They: - Automatically prepend `/api` to the path (so you write `"/context-data"` not `"/api/context-data"`) - Serialize query parameters via `URLSearchParams` diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index c531099d9..147f2085a 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -28,7 +28,7 @@ The app uses five Cloudflare products. Each one is declared as a **binding** in D1 is Cloudflare's managed SQLite database. It is the app's primary persistent store — everything about libraries, groups, parts, users, and favorites lives here. -Queries go through **Drizzle ORM** so you write TypeScript instead of raw SQL. The schema is defined in `src/shared/schema.ts`. SQL migration files live in `drizzle/` and are applied automatically on deploy. +Queries go through **Drizzle ORM** so you write TypeScript instead of raw SQL. The schema is defined in `src/backend/db/schema.ts`. SQL migration files live in `drizzle/` and are applied automatically on deploy. ### KV — Session & Token Storage (`c.env.KV`) @@ -72,7 +72,7 @@ All rendering happens inside the workflow, which keeps Onshape's thumbnail id se ### Workflows — Background Jobs -Cloudflare Workflows let you run a long-running background job that survives beyond a single HTTP request's time limit. They are the only async primitive here — there are no Queues, Durable Objects, or cron triggers. All three are defined in `src/backend/load/workflows.ts`: +Cloudflare Workflows let you run a long-running background job that survives beyond a single HTTP request's time limit. They are the only async primitive here — there are no Queues, Durable Objects, or cron triggers. All three are defined in `src/backend/features/library/workflows/`: | Binding | Class | What it does | | ----------------------- | --------------------- | ------------------------------------------------------------------------------------ | @@ -125,51 +125,46 @@ Once the backend confirms authentication and serves the React app, the frontend ## Storage at a Glance -| Store | What it holds | Lifetime | Who reads/writes it | -| ------------------ | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| **D1** | Library data, groups, parts (insertables), configurations, user preferences, favorites | Permanent (until explicitly changed) | Backend Worker on every API request | -| **KV** | OAuth session state (during login) and auth tokens (after login) | Login state: 10 minutes. Tokens: 30 days. | Backend Worker in `src/backend/auth.ts` | -| **R2** | Thumbnail images and per-library search indexes | Defaults and indexes permanent; configuration thumbnails ~90 days | Backend Worker in `src/backend/routes/thumbnails.ts` and `src/backend/library-data.ts` | -| **localStorage** | UI state: open/closed panels, active search query, vendor filters, last-opened group | Persists across browser sessions | Frontend only, via `src/frontend/api-utils/ui-state.ts` | -| **sessionStorage** | Not used | — | — | +| Store | What it holds | Lifetime | Who reads/writes it | +| ------------------ | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| **D1** | Library data, groups, parts (insertables), configurations, user preferences, favorites | Permanent (until explicitly changed) | Backend Worker on every API request | +| **KV** | OAuth session state (during login) and auth tokens (after login) | Login state: 10 minutes. Tokens: 30 days. | Backend Worker in `src/backend/features/auth/session.ts` | +| **R2** | Thumbnail images and per-library search indexes | Defaults and indexes permanent; configuration thumbnails ~90 days | Backend Worker in `src/backend/features/thumbnails/` and `src/backend/features/library/db.ts` | +| **localStorage** | UI state: open/closed panels, active search query, vendor filters, last-opened group | Persists across browser sessions | Frontend only, via `src/frontend/lib/ui-state.ts` | +| **sessionStorage** | Not used | — | — | ## Codebase Map -The source code lives in three directories under `src/`: +The source lives in two directories under `src/`: `backend/` (the Cloudflare +Worker) and `frontend/` (the React SPA). There is no shared directory — the +backend owns the contract, and the frontend imports it through `@backend/*`. -### `src/shared/` - -Code used by both the frontend and backend. Key files: - -- `schema.ts` — Drizzle table definitions (the database schema) -- `types.ts` — shared enums (`AccessLevel`, `Vendor`, `Theme`) and core interfaces -- `api-models.ts` — TypeScript types for API request/response shapes -- `onshape-path.ts` — `ElementPath`, `InstancePath` types and the serialization helpers used to build Onshape REST URLs +Both sides use the same shape: `features//` for everything one feature +owns, `lib/` for cross-cutting plumbing, and a small set of files at the root. ### `src/backend/` -The Cloudflare Worker. Key files and folders: - -- `index.ts` — Worker entry point; exports the Hono app and the Workflow class -- `auth.ts` — OAuth flow, session cookie management, token storage/retrieval -- `services.ts` — provides `getOnshapeApi()`, `getUserId()`, `getAccessLevel()` to route handlers -- `library-data.ts` — assembles the full library response (groups + insertables + configurations) -- `routes/` — endpoints callable by the frontend -- `onshape-api/` — all code that communicates with Onshape's REST API (`onshape-api.ts` for the client class, `api-path.ts` for URL construction, `endpoints/` for per-category wrappers) -- `load/` — the Workflows and what they run: `workflows.ts` defines all three, `load-group.ts` and `load-insertable.ts` do the work, `load-steps.ts` holds the retry policies, `job-tracker.ts` tracks what is running -- `parse/` — pure functions turning Onshape responses into what we store: configurations, configuration records, vendors, document contents, build checks -- `sign-in-utils.ts` / `access-level-utils.ts` — the two authorization gates: signed in to Onshape at all, versus on the admin team +- `index.ts` — Worker entry point; exports the default app and the three Workflow classes +- `app.ts` — composition root: injects per-request services, mounts every feature's routes, and handles errors +- `db/` — `client.ts` (the Drizzle client) and `schema.ts` (table definitions) +- `lib/` — request plumbing shared by every feature: `context.ts` (bindings and typed context), `cache.ts` (cache-control middleware), `route-params.ts`, `query-params.ts` +- `lib/onshape/` — everything that talks to Onshape's REST API: `client.ts` (the client class), `api-path.ts`, `path.ts` (`ElementPath`/`InstancePath` and their serializers), `endpoints/` (per-category wrappers), `objects/` (feature and query builders) +- `features/` — one directory per feature, each holding its own `routes.ts` plus whatever it owns: + - `auth/` — OAuth flow (`onshape-oauth.ts`), session storage (`session.ts`), and the two authorization gates: `sign-in.ts` (signed in to Onshape at all) and `access-control.ts` (on the admin team) + - `users/` — user preferences and the `Settings` model + - `library/` — the library response (`db.ts`), its DTOs, groups and insertables, and `workflows/` (the three Workflows, their retry policies, and the job tracker) + - `configurations/` — configuration models, canonicalization, combination enumeration, and the Onshape parsers + - `thumbnails/` — thumbnail routes plus the R2 key and URL scheme the client shares + - `build-checker/` — build issues, the checks that raise them, and the build-status endpoint + - `favorites/`, `search/` ### `src/frontend/` -The React SPA. Key files and folders: - - `main.tsx` — React root; wraps the app in `QueryClientProvider` and `MantineProvider` -- `queries.ts` — React Query query definitions shared across the app -- `routes/` — file-based routes (`__root.tsx`, `init.tsx`, `app/route.tsx`, `app/groups/index.tsx`, `app/groups/$groupId.tsx`) -- `api-utils/` — helpers for talking to the backend (`api.ts` for fetch wrappers, `ui-state.ts` for localStorage state, `library.ts` for library ID helpers) - -Everything else under `src/frontend/` is organized by feature: `cards/`, `insert/`, `favorites/`, `groups/`, `search/`, `settings/`. +- `routes/` — file-based TanStack Router routes +- `lib/` — cross-cutting helpers: `api-client.ts` (fetch wrappers), `query-keys.ts` (every query key in one place), `query-client.ts`, `ui-state.ts` (localStorage state), `refresh.ts`, `notifications.tsx` +- `components/` — UI used by more than one feature, plus the app shell (`app-navbar.tsx`, `alerts.tsx`, `root-error.tsx`) +- `features/` — `library/`, `favorites/`, `insert/`, `search/`, `settings/`, `thumbnails/`, `build-status/`, `auth/`, each with a `queries.ts` and a `components/` directory Other top-level files: @@ -180,6 +175,6 @@ Other top-level files: The app has three access levels, checked on every protected API call: **ADMIN**, **EDITOR**, and **USER**. Admin and editor access currently grant the same permissions (adding, removing, and renaming groups, toggling insertable visibility), but they are kept separate so permissions can be tightened in the future if needed. USER access allows anyone who logs in via OAuth to browse the library, insert parts, and manage their own favorites. -The Worker determines a user's access level in `src/backend/services.ts` by calling the Onshape API to check team membership against the `ADMIN_TEAM` binding. Backend routes that require elevated access are wrapped with `requireEditorMiddleware` or `requireAdminMiddleware` from `src/backend/access-level-utils.ts`. +The Worker determines a user's access level in `src/backend/features/auth/services.ts` by calling the Onshape API to check team membership against the `ADMIN_TEAM` binding. Backend routes that require elevated access are wrapped with `requireEditorMiddleware` or `requireAdminMiddleware` from `src/backend/features/auth/access-control.ts`. During local development, you can bypass the team membership check by setting `ACCESS_LEVEL_OVERRIDE=admin` (or `editor`/`user`) in your `.env` file. From 9c35cd4ef632b5d53eabb5ca8ffdf1fffa750b9d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 18:35:34 +0000 Subject: [PATCH 05/56] refactor: point a stale comment at the moved module Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- src/backend/features/library/workflows/load-insertable.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/features/library/workflows/load-insertable.test.ts b/src/backend/features/library/workflows/load-insertable.test.ts index 4f63686e9..24b7b1acd 100644 --- a/src/backend/features/library/workflows/load-insertable.test.ts +++ b/src/backend/features/library/workflows/load-insertable.test.ts @@ -152,7 +152,7 @@ describe("saveInsertable", () => { expect(config?.records).toEqual([record("PN-default")]); }); - // library-data reads `records` without re-checking that the insertable is + // features/library/db.ts reads `records` without re-checking that the insertable is // still indexed, so an empty reload must drop the row, not blank it. it("drops the configuration row when there are no parameters or records", async () => { await saveInsertable( From 9d5423df77c779f4b03c83c87f087ed4d94aa3c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 19:38:35 +0000 Subject: [PATCH 06/56] refactor: consolidate Onshape parsing into a load feature Every module that turns an Onshape response into what we store now lives in features/load: the parse-* modules (document contents, configurations, configuration records, vendors, fasten info), the per-group and per-insertable loaders, and the Workflows and job tracker that drive them. Two boundaries were untangled to make that a clean feature: - ThumbnailWorkflow moved to features/thumbnails, and the render/upload helpers moved out of its routes into store.ts. load and thumbnails previously imported each other in a cycle; the dependency is now one-way from thumbnails to load. - parse-fasten mixed parsing with insert-time query building. getFastenQuery moved to features/library/insertables/fasten-query.ts, leaving parse-fasten to parsing alone. configurations keeps the domain the frontend shares (models, canonicalization, combinations, input parser); its Onshape parsers moved to load. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- docs/REFERENCE.md | 9 +- src/__test_utils__/insertable-fixtures.ts | 4 +- .../features/library/groups/routes.test.ts | 2 +- src/backend/features/library/groups/routes.ts | 2 +- .../library/insertables/fasten-query.test.ts | 46 +++++ .../library/insertables/fasten-query.ts | 26 +++ .../features/library/insertables/routes.ts | 5 +- .../workflows => load}/context.test.ts | 0 .../{library/workflows => load}/context.ts | 12 +- .../workflows => load}/job-tracker.test.ts | 2 +- .../workflows => load}/job-tracker.ts | 6 +- .../workflows => load}/load-group.test.ts | 16 +- .../{library/workflows => load}/load-group.ts | 20 +- .../load-insertable.test.ts | 10 +- .../workflows => load}/load-insertable.ts | 32 +-- .../parse-configuration-records.test.ts} | 9 +- .../parse-configuration-records.ts} | 13 +- .../parse-configuration.test.ts | 6 +- .../parse-configuration.ts | 4 +- .../parse-document-contents.test.ts | 2 +- .../parse-document-contents.ts | 4 +- .../insertables => load}/parse-fasten.test.ts | 47 +---- .../insertables => load}/parse-fasten.ts | 38 +--- .../{library => load}/parse-vendors.test.ts | 2 +- .../{library => load}/parse-vendors.ts | 2 +- .../{library/workflows => load}/steps.test.ts | 4 +- .../{library/workflows => load}/steps.ts | 6 +- .../workflows/index.ts => load/workflows.ts} | 93 ++------- .../features/thumbnails/routes.test.ts | 2 +- src/backend/features/thumbnails/routes.ts | 186 +----------------- src/backend/features/thumbnails/store.ts | 182 +++++++++++++++++ src/backend/features/thumbnails/workflow.ts | 82 ++++++++ src/backend/index.ts | 6 +- src/backend/lib/context.ts | 6 +- 34 files changed, 461 insertions(+), 425 deletions(-) create mode 100644 src/backend/features/library/insertables/fasten-query.test.ts create mode 100644 src/backend/features/library/insertables/fasten-query.ts rename src/backend/features/{library/workflows => load}/context.test.ts (100%) rename src/backend/features/{library/workflows => load}/context.ts (83%) rename src/backend/features/{library/workflows => load}/job-tracker.test.ts (98%) rename src/backend/features/{library/workflows => load}/job-tracker.ts (95%) rename src/backend/features/{library/workflows => load}/load-group.test.ts (95%) rename src/backend/features/{library/workflows => load}/load-group.ts (93%) rename src/backend/features/{library/workflows => load}/load-insertable.test.ts (94%) rename src/backend/features/{library/workflows => load}/load-insertable.ts (89%) rename src/backend/features/{configurations/records.test.ts => load/parse-configuration-records.test.ts} (98%) rename src/backend/features/{configurations/records.ts => load/parse-configuration-records.ts} (97%) rename src/backend/features/{configurations => load}/parse-configuration.test.ts (98%) rename src/backend/features/{configurations => load}/parse-configuration.ts (98%) rename src/backend/features/{library/workflows => load}/parse-document-contents.test.ts (98%) rename src/backend/features/{library/workflows => load}/parse-document-contents.ts (93%) rename src/backend/features/{library/insertables => load}/parse-fasten.test.ts (80%) rename src/backend/features/{library/insertables => load}/parse-fasten.ts (73%) rename src/backend/features/{library => load}/parse-vendors.test.ts (98%) rename src/backend/features/{library => load}/parse-vendors.ts (95%) rename src/backend/features/{library/workflows => load}/steps.test.ts (94%) rename src/backend/features/{library/workflows => load}/steps.ts (92%) rename src/backend/features/{library/workflows/index.ts => load/workflows.ts} (70%) create mode 100644 src/backend/features/thumbnails/store.ts create mode 100644 src/backend/features/thumbnails/workflow.ts diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 147f2085a..702a00dd8 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -72,7 +72,7 @@ All rendering happens inside the workflow, which keeps Onshape's thumbnail id se ### Workflows — Background Jobs -Cloudflare Workflows let you run a long-running background job that survives beyond a single HTTP request's time limit. They are the only async primitive here — there are no Queues, Durable Objects, or cron triggers. All three are defined in `src/backend/features/library/workflows/`: +Cloudflare Workflows let you run a long-running background job that survives beyond a single HTTP request's time limit. They are the only async primitive here — there are no Queues, Durable Objects, or cron triggers. The two load workflows live in `src/backend/features/load/workflows.ts`; the thumbnail one lives with the feature it serves, in `src/backend/features/thumbnails/workflow.ts`: | Binding | Class | What it does | | ----------------------- | --------------------- | ------------------------------------------------------------------------------------ | @@ -152,9 +152,10 @@ owns, `lib/` for cross-cutting plumbing, and a small set of files at the root. - `features/` — one directory per feature, each holding its own `routes.ts` plus whatever it owns: - `auth/` — OAuth flow (`onshape-oauth.ts`), session storage (`session.ts`), and the two authorization gates: `sign-in.ts` (signed in to Onshape at all) and `access-control.ts` (on the admin team) - `users/` — user preferences and the `Settings` model - - `library/` — the library response (`db.ts`), its DTOs, groups and insertables, and `workflows/` (the three Workflows, their retry policies, and the job tracker) - - `configurations/` — configuration models, canonicalization, combination enumeration, and the Onshape parsers - - `thumbnails/` — thumbnail routes plus the R2 key and URL scheme the client shares + - `library/` — the library response (`db.ts`), its DTOs, and the groups and insertables endpoints + - `load/` — everything that turns Onshape into what we store: the `parse-*` modules (document contents, configurations, configuration records, vendors, fasten info), the per-group and per-insertable loaders, the Workflows that drive them, their retry policies, and the job tracker + - `configurations/` — the configuration domain the frontend shares: models, canonicalization, combination enumeration, and the input parser + - `thumbnails/` — rendering and R2 storage (`store.ts`), its Workflow, the routes, and the key and URL scheme the client shares - `build-checker/` — build issues, the checks that raise them, and the build-status endpoint - `favorites/`, `search/` diff --git a/src/__test_utils__/insertable-fixtures.ts b/src/__test_utils__/insertable-fixtures.ts index d77e956cd..a61b9cb78 100644 --- a/src/__test_utils__/insertable-fixtures.ts +++ b/src/__test_utils__/insertable-fixtures.ts @@ -2,8 +2,8 @@ * Factories for the load pipeline's insertable shapes. Import directly: the * barrel re-exports Workers-only helpers these tests cannot resolve. */ -import type { InsertableTarget } from "@backend/features/library/workflows/context"; -import type { ParsedInsertable } from "@backend/features/library/workflows/load-insertable"; +import type { InsertableTarget } from "@backend/features/load/context"; +import type { ParsedInsertable } from "@backend/features/load/load-insertable"; import { ElementType } from "@backend/lib/onshape/element-type"; import { TEST_GROUP_ID, diff --git a/src/backend/features/library/groups/routes.test.ts b/src/backend/features/library/groups/routes.test.ts index c9bee3905..f88f54c67 100644 --- a/src/backend/features/library/groups/routes.test.ts +++ b/src/backend/features/library/groups/routes.test.ts @@ -18,7 +18,7 @@ import type { JobStatus } from "../dto"; import { searchIndexKey } from "../db"; import { SEARCH_OPTIONS, type SearchDocument } from "../../search/search-index"; import * as DocumentsEndpoint from "../../../lib/onshape/endpoints/documents"; -import * as JobTracker from "../workflows/job-tracker"; +import * as JobTracker from "../../load/job-tracker"; const db = getDb(env.DB); diff --git a/src/backend/features/library/groups/routes.ts b/src/backend/features/library/groups/routes.ts index 9189ba782..6d93e19cb 100644 --- a/src/backend/features/library/groups/routes.ts +++ b/src/backend/features/library/groups/routes.ts @@ -14,7 +14,7 @@ import { getJobStatus, isReloadRunning, trackJob -} from "../workflows/job-tracker"; +} from "../../load/job-tracker"; import { z } from "zod"; import { zValidator } from "@hono/zod-validator"; diff --git a/src/backend/features/library/insertables/fasten-query.test.ts b/src/backend/features/library/insertables/fasten-query.test.ts new file mode 100644 index 000000000..4abc33530 --- /dev/null +++ b/src/backend/features/library/insertables/fasten-query.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { ElementType } from "../../../lib/onshape/element-type"; +import { FastenInfo, MateLocation } from "./fasten"; +import { getFastenQuery } from "./fasten-query"; + +describe("getFastenQuery", () => { + const fasten: FastenInfo = { + mateConnectorId: "mc", + mateLocation: MateLocation.Feature, + path: ["fp"] + }; + + it("builds a part-studio mate connector query for a part studio target", () => { + expect(getFastenQuery(ElementType.PART_STUDIO, ["np"], fasten)).toEqual( + { + btType: "BTMPartStudioMateConnectorQuery-1324", + featureId: "mc", + path: ["np"] + } + ); + }); + + it("uses a part-studio query for a Part mate in an assembly (combined path)", () => { + const partFasten: FastenInfo = { + mateConnectorId: "mc", + mateLocation: MateLocation.Part, + path: ["fp"] + }; + expect( + getFastenQuery(ElementType.ASSEMBLY, ["np"], partFasten) + ).toEqual({ + btType: "BTMPartStudioMateConnectorQuery-1324", + featureId: "mc", + path: ["np", "fp"] + }); + }); + + it("uses a feature-occurrence query for a Feature mate in an assembly", () => { + expect(getFastenQuery(ElementType.ASSEMBLY, ["np"], fasten)).toEqual({ + btType: "BTMFeatureQueryWithOccurrence-157", + path: ["np", "fp"], + queryData: "", + featureId: "mc" + }); + }); +}); diff --git a/src/backend/features/library/insertables/fasten-query.ts b/src/backend/features/library/insertables/fasten-query.ts new file mode 100644 index 000000000..d5993055a --- /dev/null +++ b/src/backend/features/library/insertables/fasten-query.ts @@ -0,0 +1,26 @@ +/** Turns a stored FastenInfo into the Onshape query an insert mates against. */ +import { ElementType } from "../../../lib/onshape/element-type"; +import { + featureOccurrenceQuery, + partStudioMateConnectorQuery +} from "../../../lib/onshape/objects/assembly-features"; +import { FastenInfo, MateLocation } from "./fasten"; + +export function getFastenQuery( + targetElementType: ElementType, + path: string[], + fastenInfo: FastenInfo +): object { + if (targetElementType === ElementType.PART_STUDIO) { + return partStudioMateConnectorQuery(fastenInfo.mateConnectorId, path); + } + + const assemblyPath = [...path, ...fastenInfo.path]; + if (fastenInfo.mateLocation === MateLocation.Part) { + return partStudioMateConnectorQuery( + fastenInfo.mateConnectorId, + assemblyPath + ); + } + return featureOccurrenceQuery(fastenInfo.mateConnectorId, assemblyPath); +} diff --git a/src/backend/features/library/insertables/routes.ts b/src/backend/features/library/insertables/routes.ts index e89994c93..103864b2a 100644 --- a/src/backend/features/library/insertables/routes.ts +++ b/src/backend/features/library/insertables/routes.ts @@ -21,7 +21,7 @@ import { decideIndexing, parseConfigurationRecords, type ConfigurationRecordsResult -} from "../../configurations/records"; +} from "../../load/parse-configuration-records"; import { type OnshapeApi } from "../../../lib/onshape/client"; import { ElementType } from "../../../lib/onshape/element-type"; import { DerivedFeature } from "../../../lib/onshape/objects/derive-feature"; @@ -36,7 +36,8 @@ import { } from "../../../lib/onshape/endpoints/documents"; import { encodeConfiguration } from "../../../lib/onshape/endpoints/configurations"; import { FastenMateBuilder } from "../../../lib/onshape/objects/assembly-features"; -import { getFastenQuery, parseFastenInfo } from "./parse-fasten"; +import { parseFastenInfo } from "../../load/parse-fasten"; +import { getFastenQuery } from "./fasten-query"; import { addBuildIssue, clearBuildIssue } from "../../build-checker/issues"; import { checkIndexedPartNumber } from "../../build-checker/checks"; diff --git a/src/backend/features/library/workflows/context.test.ts b/src/backend/features/load/context.test.ts similarity index 100% rename from src/backend/features/library/workflows/context.test.ts rename to src/backend/features/load/context.test.ts diff --git a/src/backend/features/library/workflows/context.ts b/src/backend/features/load/context.ts similarity index 83% rename from src/backend/features/library/workflows/context.ts rename to src/backend/features/load/context.ts index 37c8c1ac7..73498acdd 100644 --- a/src/backend/features/library/workflows/context.ts +++ b/src/backend/features/load/context.ts @@ -1,10 +1,10 @@ import type { WorkflowStep } from "cloudflare:workers"; -import type { AppBindings } from "../../../lib/context"; -import { getOnshapeApiFromSessionId } from "../../auth/onshape-oauth"; -import type { OnshapeApi } from "../../../lib/onshape/client"; -import type { ElementType } from "../../../lib/onshape/element-type"; -import type { LibraryId } from "../library-id"; -import type { ElementPath, InstancePath } from "../../../lib/onshape/path"; +import type { AppBindings } from "../../lib/context"; +import { getOnshapeApiFromSessionId } from "../auth/onshape-oauth"; +import type { OnshapeApi } from "../../lib/onshape/client"; +import type { ElementType } from "../../lib/onshape/element-type"; +import type { LibraryId } from "../library/library-id"; +import type { ElementPath, InstancePath } from "../../lib/onshape/path"; /** How many insertables a load reads from Onshape at once. */ export const LOAD_CONCURRENCY = 15; diff --git a/src/backend/features/library/workflows/job-tracker.test.ts b/src/backend/features/load/job-tracker.test.ts similarity index 98% rename from src/backend/features/library/workflows/job-tracker.test.ts rename to src/backend/features/load/job-tracker.test.ts index 4f2bd648b..4faa39ebd 100644 --- a/src/backend/features/library/workflows/job-tracker.test.ts +++ b/src/backend/features/load/job-tracker.test.ts @@ -6,7 +6,7 @@ import { trackJob, untrackJob } from "./job-tracker"; -import { TEST_LIBRARY_ID } from "../../../../__test_utils__"; +import { TEST_LIBRARY_ID } from "../../../__test_utils__"; interface Job { id: string; diff --git a/src/backend/features/library/workflows/job-tracker.ts b/src/backend/features/load/job-tracker.ts similarity index 95% rename from src/backend/features/library/workflows/job-tracker.ts rename to src/backend/features/load/job-tracker.ts index 137c85b4d..ff8c981d0 100644 --- a/src/backend/features/library/workflows/job-tracker.ts +++ b/src/backend/features/load/job-tracker.ts @@ -1,6 +1,6 @@ -import type { AppBindings } from "../../../lib/context"; -import type { LibraryId } from "../library-id"; -import type { JobStatus } from "../dto"; +import type { AppBindings } from "../../lib/context"; +import type { LibraryId } from "../library/library-id"; +import type { JobStatus } from "../library/dto"; /** * Backstop for a job that crashes before untracking itself; must outlast the diff --git a/src/backend/features/library/workflows/load-group.test.ts b/src/backend/features/load/load-group.test.ts similarity index 95% rename from src/backend/features/library/workflows/load-group.test.ts rename to src/backend/features/load/load-group.test.ts index 3b57c6db5..66ebaa38c 100644 --- a/src/backend/features/library/workflows/load-group.test.ts +++ b/src/backend/features/load/load-group.test.ts @@ -6,13 +6,13 @@ import { type OnshapeElement, OnshapeElementType, OnshapeFolderEntryType -} from "../../../lib/onshape/types"; -import * as DocumentEndpoints from "../../../lib/onshape/endpoints/documents"; -import * as ConfigurationEndpoints from "../../../lib/onshape/endpoints/configurations"; -import * as PartsEndpoints from "../../../lib/onshape/endpoints/parts"; -import { getDb } from "../../../db/client"; -import { group, insertables } from "../../../db/schema"; -import { BuildIssueType } from "../../build-checker/issues"; +} from "../../lib/onshape/types"; +import * as DocumentEndpoints from "../../lib/onshape/endpoints/documents"; +import * as ConfigurationEndpoints from "../../lib/onshape/endpoints/configurations"; +import * as PartsEndpoints from "../../lib/onshape/endpoints/parts"; +import { getDb } from "../../db/client"; +import { group, insertables } from "../../db/schema"; +import { BuildIssueType } from "../build-checker/issues"; import { type StoredInsertable, findRemovedInsertables, @@ -30,7 +30,7 @@ import { resetDb, seedGroup, seedInsertable -} from "../../../../__test_utils__"; +} from "../../../__test_utils__"; const GROUP: GroupTarget = { libraryId: TEST_LIBRARY_ID, diff --git a/src/backend/features/library/workflows/load-group.ts b/src/backend/features/load/load-group.ts similarity index 93% rename from src/backend/features/library/workflows/load-group.ts rename to src/backend/features/load/load-group.ts index 4882c771c..fbcccb057 100644 --- a/src/backend/features/library/workflows/load-group.ts +++ b/src/backend/features/load/load-group.ts @@ -1,18 +1,18 @@ import { eq, inArray } from "drizzle-orm"; import type { BatchItem } from "drizzle-orm/batch"; -import { type Db, getDb } from "../../../db/client"; -import { ElementType } from "../../../lib/onshape/element-type"; -import type { ThumbnailUrls } from "../../thumbnails/types"; +import { type Db, getDb } from "../../db/client"; +import { ElementType } from "../../lib/onshape/element-type"; +import type { ThumbnailUrls } from "../thumbnails/types"; import { addBuildIssue, type BuildIssue, BuildIssueType -} from "../../build-checker/issues"; -import { group, insertables } from "../../../db/schema"; -import { uploadDocumentThumbnails } from "../../thumbnails/routes"; -import { getContents } from "../../../lib/onshape/endpoints/documents"; -import type { OnshapeElement } from "../../../lib/onshape/types"; -import { checkGroup } from "../../build-checker/checks"; +} from "../build-checker/issues"; +import { group, insertables } from "../../db/schema"; +import { uploadDocumentThumbnails } from "../thumbnails/store"; +import { getContents } from "../../lib/onshape/endpoints/documents"; +import type { OnshapeElement } from "../../lib/onshape/types"; +import { checkGroup } from "../build-checker/checks"; import { parseInsertableTabs } from "./parse-document-contents"; import { loadInsertable } from "./load-insertable"; import { @@ -22,7 +22,7 @@ import { getOnshapeApiFromContext } from "./context"; import { uploadThumbnailsStep } from "./steps"; -import type { InstancePath } from "../../../lib/onshape/path"; +import type { InstancePath } from "../../lib/onshape/path"; export interface GroupLoadResult { loadedElements: number; diff --git a/src/backend/features/library/workflows/load-insertable.test.ts b/src/backend/features/load/load-insertable.test.ts similarity index 94% rename from src/backend/features/library/workflows/load-insertable.test.ts rename to src/backend/features/load/load-insertable.test.ts index 24b7b1acd..45b3a1e00 100644 --- a/src/backend/features/library/workflows/load-insertable.test.ts +++ b/src/backend/features/load/load-insertable.test.ts @@ -1,19 +1,19 @@ import { env } from "cloudflare:workers"; import { eq } from "drizzle-orm"; import { beforeEach, describe, expect, it } from "vitest"; -import { getDb } from "../../../db/client"; -import { configurations, insertables } from "../../../db/schema"; -import type { ConfigurationRecord } from "../../configurations/models"; +import { getDb } from "../../db/client"; +import { configurations, insertables } from "../../db/schema"; +import type { ConfigurationRecord } from "../configurations/models"; import { TEST_PARAMETERS, TEST_PART_STUDIO_ID, resetDb, seedGroup -} from "../../../../__test_utils__"; +} from "../../../__test_utils__"; import { insertableTarget, parsedInsertable -} from "../../../../__test_utils__/insertable-fixtures"; +} from "../../../__test_utils__/insertable-fixtures"; import { saveInsertable } from "./load-insertable"; const db = getDb(env.DB); diff --git a/src/backend/features/library/workflows/load-insertable.ts b/src/backend/features/load/load-insertable.ts similarity index 89% rename from src/backend/features/library/workflows/load-insertable.ts rename to src/backend/features/load/load-insertable.ts index 409172a0c..8eb82293e 100644 --- a/src/backend/features/library/workflows/load-insertable.ts +++ b/src/backend/features/load/load-insertable.ts @@ -1,32 +1,32 @@ import { eq } from "drizzle-orm"; -import { type Db, getDb } from "../../../db/client"; +import { type Db, getDb } from "../../db/client"; import type { Configuration, ConfigurationParameter -} from "../../configurations/models"; +} from "../configurations/models"; import { addBuildIssue, type BuildIssue, BuildIssueType -} from "../../build-checker/issues"; -import { ElementType } from "../../../lib/onshape/element-type"; -import type { FastenInfo } from "../insertables/fasten"; -import type { ThumbnailUrls } from "../../thumbnails/types"; -import type { Vendor } from "../vendors"; -import { configurations, insertables } from "../../../db/schema"; -import { uploadThumbnails } from "../../thumbnails/routes"; -import { getConfiguration } from "../../../lib/onshape/endpoints/configurations"; -import { getParts } from "../../../lib/onshape/endpoints/parts"; -import { checkInsertable } from "../../build-checker/checks"; -import { parseOnshapeConfiguration } from "../../configurations/parse-configuration"; -import { parseVendors } from "../parse-vendors"; -import { parseFastenInfo } from "../insertables/parse-fasten"; +} from "../build-checker/issues"; +import { ElementType } from "../../lib/onshape/element-type"; +import type { FastenInfo } from "../library/insertables/fasten"; +import type { ThumbnailUrls } from "../thumbnails/types"; +import type { Vendor } from "../library/vendors"; +import { configurations, insertables } from "../../db/schema"; +import { uploadThumbnails } from "../thumbnails/store"; +import { getConfiguration } from "../../lib/onshape/endpoints/configurations"; +import { getParts } from "../../lib/onshape/endpoints/parts"; +import { checkInsertable } from "../build-checker/checks"; +import { parseOnshapeConfiguration } from "./parse-configuration"; +import { parseVendors } from "./parse-vendors"; +import { parseFastenInfo } from "./parse-fasten"; import { NO_RECORDS, computeOpenComposite, decideIndexing, loadConfigurationRecords -} from "../../configurations/records"; +} from "./parse-configuration-records"; import { type InsertableTarget, type LoadContext, diff --git a/src/backend/features/configurations/records.test.ts b/src/backend/features/load/parse-configuration-records.test.ts similarity index 98% rename from src/backend/features/configurations/records.test.ts rename to src/backend/features/load/parse-configuration-records.test.ts index 692f25c8f..78eb11e6f 100644 --- a/src/backend/features/configurations/records.test.ts +++ b/src/backend/features/load/parse-configuration-records.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { countConfigurations } from "./combinations"; +import { countConfigurations } from "../configurations/combinations"; import * as PartsEndpoints from "../../lib/onshape/endpoints/parts"; import * as MetadataEndpoints from "../../lib/onshape/endpoints/metadata"; import { OnshapeApi } from "../../lib/onshape/client"; @@ -8,7 +8,10 @@ import type { OnshapePart } from "../../lib/onshape/types"; import { ElementPath } from "../../lib/onshape/path"; -import { ParameterValues, ConfigurationParameter } from "./models"; +import { + ParameterValues, + ConfigurationParameter +} from "../configurations/models"; import { enumParam } from "../../../__test_utils__/configuration-fixtures"; import { ElementType } from "../../lib/onshape/element-type"; import { BuildIssueType } from "../build-checker/issues"; @@ -17,7 +20,7 @@ import { parseAssemblyRecord, parseConfigurationRecords, parsePartStudioRecord -} from "./records"; +} from "./parse-configuration-records"; const PATH: ElementPath = { documentId: "d", diff --git a/src/backend/features/configurations/records.ts b/src/backend/features/load/parse-configuration-records.ts similarity index 97% rename from src/backend/features/configurations/records.ts rename to src/backend/features/load/parse-configuration-records.ts index 7edcdd285..5162fc04c 100644 --- a/src/backend/features/configurations/records.ts +++ b/src/backend/features/load/parse-configuration-records.ts @@ -9,7 +9,7 @@ import { ParameterValues, ConfigurationParameter, ConfigurationRecord -} from "./models"; +} from "../configurations/models"; import { addBuildIssue, type BuildIssue, @@ -19,19 +19,16 @@ import { countConfigurations, IndexingBand, isIndexingEnabled -} from "./combinations"; -import { canonicalizeConfiguration } from "./canonical"; +} from "../configurations/combinations"; +import { canonicalizeConfiguration } from "../configurations/canonical"; import { getParts } from "../../lib/onshape/endpoints/parts"; import { getElementMetadata } from "../../lib/onshape/endpoints/metadata"; import type { OnshapeMetadataObject, OnshapePart } from "../../lib/onshape/types"; -import { - type LoadContext, - getOnshapeApiFromContext -} from "../library/workflows/context"; -import { ONSHAPE_STEP_RETRIES } from "../library/workflows/steps"; +import { type LoadContext, getOnshapeApiFromContext } from "./context"; +import { ONSHAPE_STEP_RETRIES } from "./steps"; /** Configurations fetched per workflow step. */ const BATCH_SIZE = 20; diff --git a/src/backend/features/configurations/parse-configuration.test.ts b/src/backend/features/load/parse-configuration.test.ts similarity index 98% rename from src/backend/features/configurations/parse-configuration.test.ts rename to src/backend/features/load/parse-configuration.test.ts index 59ff22199..17c4176a4 100644 --- a/src/backend/features/configurations/parse-configuration.test.ts +++ b/src/backend/features/load/parse-configuration.test.ts @@ -5,9 +5,9 @@ import { ParameterType, VisibilityCondition, VisibilityType -} from "./models"; -import { LogicalOp, QuantityType, Unit } from "./enums"; -import { evaluateCondition } from "./utils"; +} from "../configurations/models"; +import { LogicalOp, QuantityType, Unit } from "../configurations/enums"; +import { evaluateCondition } from "../configurations/utils"; import { parseOnshapeConfiguration } from "./parse-configuration"; import { OnshapeConfigurationResponse, diff --git a/src/backend/features/configurations/parse-configuration.ts b/src/backend/features/load/parse-configuration.ts similarity index 98% rename from src/backend/features/configurations/parse-configuration.ts rename to src/backend/features/load/parse-configuration.ts index 2f30d3eaf..0efdd08f7 100644 --- a/src/backend/features/configurations/parse-configuration.ts +++ b/src/backend/features/load/parse-configuration.ts @@ -6,8 +6,8 @@ import { ParameterType, type VisibilityCondition, VisibilityType -} from "./models"; -import { getUnitDisplayStr } from "./enums"; +} from "../configurations/models"; +import { getUnitDisplayStr } from "../configurations/enums"; import { type OnshapeConfigurationResponse, type OnshapeEnumOptionVisibilityConditionList, diff --git a/src/backend/features/library/workflows/parse-document-contents.test.ts b/src/backend/features/load/parse-document-contents.test.ts similarity index 98% rename from src/backend/features/library/workflows/parse-document-contents.test.ts rename to src/backend/features/load/parse-document-contents.test.ts index 88b93f3bc..4ad180515 100644 --- a/src/backend/features/library/workflows/parse-document-contents.test.ts +++ b/src/backend/features/load/parse-document-contents.test.ts @@ -6,7 +6,7 @@ import { type OnshapeFolderEntry, OnshapeElementType, OnshapeFolderEntryType -} from "../../../lib/onshape/types"; +} from "../../lib/onshape/types"; import { parseInsertableTabs } from "./parse-document-contents"; function element( diff --git a/src/backend/features/library/workflows/parse-document-contents.ts b/src/backend/features/load/parse-document-contents.ts similarity index 93% rename from src/backend/features/library/workflows/parse-document-contents.ts rename to src/backend/features/load/parse-document-contents.ts index f843333d2..bbb24503d 100644 --- a/src/backend/features/library/workflows/parse-document-contents.ts +++ b/src/backend/features/load/parse-document-contents.ts @@ -1,13 +1,13 @@ /** * Extracts the insertable tabs from a document's contents listing. */ -import { ElementType } from "../../../lib/onshape/element-type"; +import { ElementType } from "../../lib/onshape/element-type"; import { type OnshapeDocumentContents, type OnshapeElement, type OnshapeFolderEntry, OnshapeFolderEntryType -} from "../../../lib/onshape/types"; +} from "../../lib/onshape/types"; const VALID_ELEMENT_TYPES = new Set([ ElementType.ASSEMBLY, diff --git a/src/backend/features/library/insertables/parse-fasten.test.ts b/src/backend/features/load/parse-fasten.test.ts similarity index 80% rename from src/backend/features/library/insertables/parse-fasten.test.ts rename to src/backend/features/load/parse-fasten.test.ts index ead589d21..c4fd26c2b 100644 --- a/src/backend/features/library/insertables/parse-fasten.test.ts +++ b/src/backend/features/load/parse-fasten.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from "vitest"; -import { ElementType } from "../../../lib/onshape/element-type"; -import { FastenInfo, MateLocation } from "./fasten"; + +import { MateLocation } from "../library/insertables/fasten"; import { - getFastenQuery, parseFastenInfoFromPartStudio, parseFastenInfoFromAssembly } from "./parse-fasten"; @@ -176,45 +175,3 @@ describe("parseFastenInfoFromAssembly", () => { expect(() => parseFastenInfoFromAssembly(rawAssemblyInfo)).toThrow(); }); }); - -describe("getFastenQuery", () => { - const fasten: FastenInfo = { - mateConnectorId: "mc", - mateLocation: MateLocation.Feature, - path: ["fp"] - }; - - it("builds a part-studio mate connector query for a part studio target", () => { - expect(getFastenQuery(ElementType.PART_STUDIO, ["np"], fasten)).toEqual( - { - btType: "BTMPartStudioMateConnectorQuery-1324", - featureId: "mc", - path: ["np"] - } - ); - }); - - it("uses a part-studio query for a Part mate in an assembly (combined path)", () => { - const partFasten: FastenInfo = { - mateConnectorId: "mc", - mateLocation: MateLocation.Part, - path: ["fp"] - }; - expect( - getFastenQuery(ElementType.ASSEMBLY, ["np"], partFasten) - ).toEqual({ - btType: "BTMPartStudioMateConnectorQuery-1324", - featureId: "mc", - path: ["np", "fp"] - }); - }); - - it("uses a feature-occurrence query for a Feature mate in an assembly", () => { - expect(getFastenQuery(ElementType.ASSEMBLY, ["np"], fasten)).toEqual({ - btType: "BTMFeatureQueryWithOccurrence-157", - path: ["np", "fp"], - queryData: "", - featureId: "mc" - }); - }); -}); diff --git a/src/backend/features/library/insertables/parse-fasten.ts b/src/backend/features/load/parse-fasten.ts similarity index 73% rename from src/backend/features/library/insertables/parse-fasten.ts rename to src/backend/features/load/parse-fasten.ts index b65997512..3061d850a 100644 --- a/src/backend/features/library/insertables/parse-fasten.ts +++ b/src/backend/features/load/parse-fasten.ts @@ -1,18 +1,15 @@ -import { ElementType } from "../../../lib/onshape/element-type"; -import { FastenInfo, MateLocation } from "./fasten"; -import { type ElementPath } from "../../../lib/onshape/path"; -import { getAssembly } from "../../../lib/onshape/endpoints/assemblies"; -import { getFeatures } from "../../../lib/onshape/endpoints/part-studios"; -import { - featureOccurrenceQuery, - partStudioMateConnectorQuery -} from "../../../lib/onshape/objects/assembly-features"; -import { OnshapeApi } from "../../../lib/onshape/client"; +import { ElementType } from "../../lib/onshape/element-type"; +import { FastenInfo, MateLocation } from "../library/insertables/fasten"; +import { type ElementPath } from "../../lib/onshape/path"; +import { getAssembly } from "../../lib/onshape/endpoints/assemblies"; +import { getFeatures } from "../../lib/onshape/endpoints/part-studios"; + +import { OnshapeApi } from "../../lib/onshape/client"; import { OnshapeAssemblyDefinition, OnshapeAssemblyFeature, OnshapeFeatureListResponse -} from "../../../lib/onshape/types"; +} from "../../lib/onshape/types"; export async function parseFastenInfo( onshapeApi: OnshapeApi, @@ -109,22 +106,3 @@ export function parseFastenInfoFromAssembly( "Failed to find a valid Mate connector feature or instance." ); } - -export function getFastenQuery( - targetElementType: ElementType, - path: string[], - fastenInfo: FastenInfo -): object { - if (targetElementType === ElementType.PART_STUDIO) { - return partStudioMateConnectorQuery(fastenInfo.mateConnectorId, path); - } - - const assemblyPath = [...path, ...fastenInfo.path]; - if (fastenInfo.mateLocation === MateLocation.Part) { - return partStudioMateConnectorQuery( - fastenInfo.mateConnectorId, - assemblyPath - ); - } - return featureOccurrenceQuery(fastenInfo.mateConnectorId, assemblyPath); -} diff --git a/src/backend/features/library/parse-vendors.test.ts b/src/backend/features/load/parse-vendors.test.ts similarity index 98% rename from src/backend/features/library/parse-vendors.test.ts rename to src/backend/features/load/parse-vendors.test.ts index 2b43db2a7..d4ff62235 100644 --- a/src/backend/features/library/parse-vendors.test.ts +++ b/src/backend/features/load/parse-vendors.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { Vendor } from "./vendors"; +import { Vendor } from "../library/vendors"; import { ParameterType } from "../configurations/models"; import { QuantityType, Unit } from "../configurations/enums"; import { parseNameVendor, parseVendors } from "./parse-vendors"; diff --git a/src/backend/features/library/parse-vendors.ts b/src/backend/features/load/parse-vendors.ts similarity index 95% rename from src/backend/features/library/parse-vendors.ts rename to src/backend/features/load/parse-vendors.ts index f8aa9715c..157e88a20 100644 --- a/src/backend/features/library/parse-vendors.ts +++ b/src/backend/features/load/parse-vendors.ts @@ -1,4 +1,4 @@ -import { Vendor, getVendorName } from "./vendors"; +import { Vendor, getVendorName } from "../library/vendors"; import { ParameterType, type ConfigurationParameter diff --git a/src/backend/features/library/workflows/steps.test.ts b/src/backend/features/load/steps.test.ts similarity index 94% rename from src/backend/features/library/workflows/steps.test.ts rename to src/backend/features/load/steps.test.ts index f173917c6..113cf978e 100644 --- a/src/backend/features/library/workflows/steps.test.ts +++ b/src/backend/features/load/steps.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { OnshapeRateLimitError } from "../../../lib/onshape/client"; -import { NoSuchConfigurationError } from "../../../lib/onshape/endpoints/thumbnails"; +import { OnshapeRateLimitError } from "../../lib/onshape/client"; +import { NoSuchConfigurationError } from "../../lib/onshape/endpoints/thumbnails"; import { ONSHAPE_STEP_RETRIES, THUMBNAIL_STEP_RETRIES } from "./steps"; /** The delay before the retry that follows attempt `attempt`. */ diff --git a/src/backend/features/library/workflows/steps.ts b/src/backend/features/load/steps.ts similarity index 92% rename from src/backend/features/library/workflows/steps.ts rename to src/backend/features/load/steps.ts index 1284adbe7..577ef9f9b 100644 --- a/src/backend/features/library/workflows/steps.ts +++ b/src/backend/features/load/steps.ts @@ -1,6 +1,6 @@ -import { OnshapeRateLimitError } from "../../../lib/onshape/client"; -import type { ThumbnailUrls } from "../../thumbnails/types"; -import { NoSuchConfigurationError } from "../../../lib/onshape/endpoints/thumbnails"; +import { OnshapeRateLimitError } from "../../lib/onshape/client"; +import type { ThumbnailUrls } from "../thumbnails/types"; +import { NoSuchConfigurationError } from "../../lib/onshape/endpoints/thumbnails"; import type { LoadContext } from "./context"; /** The retry input a Workflow `delay` callback receives. */ diff --git a/src/backend/features/library/workflows/index.ts b/src/backend/features/load/workflows.ts similarity index 70% rename from src/backend/features/library/workflows/index.ts rename to src/backend/features/load/workflows.ts index b6e4e04d0..3ec635514 100644 --- a/src/backend/features/library/workflows/index.ts +++ b/src/backend/features/load/workflows.ts @@ -4,15 +4,19 @@ import { type WorkflowStep } from "cloudflare:workers"; import { eq } from "drizzle-orm"; -import type { AppBindings } from "../../../lib/context"; -import { getDb } from "../../../db/client"; -import type { LibraryId } from "../library-id"; -import { bumpLibraryVersion, placeNewGroup, rebuildSearchDb } from "../db"; -import { getDocument } from "../../../lib/onshape/endpoints/documents"; -import { getLatestVersionId } from "../../../lib/onshape/endpoints/versions"; -import type { InstancePath } from "../../../lib/onshape/path"; -import { group, insertables, libraries } from "../../../db/schema"; -import { uploadConfigurationThumbnails } from "../../thumbnails/routes"; +import type { AppBindings } from "../../lib/context"; +import { getDb } from "../../db/client"; +import type { LibraryId } from "../library/library-id"; +import { + bumpLibraryVersion, + placeNewGroup, + rebuildSearchDb +} from "../library/db"; +import { getDocument } from "../../lib/onshape/endpoints/documents"; +import { getLatestVersionId } from "../../lib/onshape/endpoints/versions"; +import type { InstancePath } from "../../lib/onshape/path"; +import { group, libraries } from "../../db/schema"; + import { type GroupTarget, type LoadContext, @@ -22,7 +26,6 @@ import { } from "./context"; import { untrackJob } from "./job-tracker"; import { loadGroup } from "./load-group"; -import { THUMBNAIL_STEP_RETRIES } from "./steps"; export interface LoadLibraryParams { libraryId: LibraryId; @@ -229,73 +232,3 @@ async function finalizeLibrary( await rebuildSearchDb(env.BLOB, db, libraryId); await bumpLibraryVersion(db, libraryId); } - -/** The render to run, plus the session whose Onshape tokens it runs under. */ -export interface ThumbnailWorkflowParams { - insertableId: string; - /** Part of the key, so a render lands where the request looked for it. */ - microversionId: string; - /** Never the default, which loads eagerly with the element. */ - canonicalConfiguration: string; - sessionId: string; -} - -/** - * Outside a request, since Onshape can take minutes. Until it finishes, - * requests fall back to the element's default thumbnail. - */ -export class ThumbnailWorkflow extends WorkflowEntrypoint< - AppBindings, - ThumbnailWorkflowParams -> { - async run( - event: WorkflowEvent, - step: WorkflowStep - ): Promise { - const { - insertableId, - microversionId, - canonicalConfiguration, - sessionId - } = event.payload; - - const elementPath = await step.do("resolve-element", async () => { - const row = await getDb(this.env.DB) - .select({ - documentId: insertables.documentId, - versionId: insertables.versionId, - elementId: insertables.elementId - }) - .from(insertables) - .where(eq(insertables.id, insertableId)) - .get(); - if (!row) { - throw new Error(`No insertable ${insertableId}`); - } - return { - documentId: row.documentId, - instanceId: row.versionId, - instanceType: "v" as const, - elementId: row.elementId - }; - }); - - await step.do( - "render-thumbnails", - { retries: THUMBNAIL_STEP_RETRIES }, - async () => - uploadConfigurationThumbnails( - this.env.BLOB, - await getOnshapeApiFromContext({ - env: this.env, - sessionId, - step, - limit: createLimiter(1) - }), - elementPath, - microversionId, - canonicalConfiguration - ) - ); - } -} diff --git a/src/backend/features/thumbnails/routes.test.ts b/src/backend/features/thumbnails/routes.test.ts index c2286fa88..54c3b354c 100644 --- a/src/backend/features/thumbnails/routes.test.ts +++ b/src/backend/features/thumbnails/routes.test.ts @@ -12,7 +12,7 @@ import { DEFAULT_CANONICAL_CONFIGURATION, canonicalConfigurationKey } from "../configurations/canonical"; -import { uploadConfigurationThumbnails } from "./routes"; +import { uploadConfigurationThumbnails } from "./store"; import type { OnshapeApi } from "../../lib/onshape/client"; const SIZE = ThumbnailSize.LARGE; diff --git a/src/backend/features/thumbnails/routes.ts b/src/backend/features/thumbnails/routes.ts index b8a7ab1bc..763566938 100644 --- a/src/backend/features/thumbnails/routes.ts +++ b/src/backend/features/thumbnails/routes.ts @@ -1,205 +1,35 @@ import { eq } from "drizzle-orm"; import { z } from "zod"; import { zValidator } from "@hono/zod-validator"; -import { - CachePolicy, - cacheMiddleware, - immutableCacheControl, - setCacheTtl -} from "../../lib/cache"; +import { CachePolicy, cacheMiddleware, setCacheTtl } from "../../lib/cache"; import { getApp } from "../../lib/context"; import { getInsertableParam, insertableRoute } from "../../lib/route-params"; import { getInsertableElementPath } from "../library/insertables/routes"; import { getDb } from "../../db/client"; import { requireEditorMiddleware } from "../auth/access-control"; import { bumpLibraryVersion } from "../library/db"; -import { - getElementThumbnail, - getThumbnailFromId, - getThumbnailId -} from "../../lib/onshape/endpoints/thumbnails"; -import { - getDocument, - getContents -} from "../../lib/onshape/endpoints/documents"; -import { type ElementPath, type InstancePath } from "../../lib/onshape/path"; + +import { type InstancePath } from "../../lib/onshape/path"; import { group, insertables } from "../../db/schema"; import { HTTPException } from "hono/http-exception"; import { HttpStatus } from "http-status-ts"; -import { ThumbnailSize, ThumbnailUrls } from "./types"; +import { ThumbnailSize } from "./types"; import { THUMBNAIL_FALLBACK_CACHE_TTL, THUMBNAIL_FALLBACK_HEADER, - thumbnailKey, - thumbnailUrl + thumbnailKey } from "./keys"; import { DEFAULT_CANONICAL_CONFIGURATION, DEFAULT_CONFIGURATION_KEY, canonicalConfigurationKey } from "../configurations/canonical"; -import { OnshapeApi } from "../../lib/onshape/client"; + import type { AppContext } from "../../lib/context"; -import type { ThumbnailWorkflowParams } from "../library/workflows/index"; +import type { ThumbnailWorkflowParams } from "./workflow"; import { getSessionId } from "../auth/session"; import { BuildIssueType, clearBuildIssue } from "../build-checker/issues"; - -/** Stores one rendered thumbnail, tagging it with what produced it. */ -async function putThumbnail( - bucket: R2Bucket, - key: string, - thumbnail: ArrayBuffer, - metadata: Record -): Promise { - await bucket.put(key, thumbnail, { - httpMetadata: { - contentType: "image/gif", - cacheControl: immutableCacheControl(CachePolicy.PUBLIC_CACHE) - }, - customMetadata: metadata - }); -} - -/** Throws until Onshape has rendered them, which drives the load step's retries. */ -export async function uploadThumbnails( - bucket: R2Bucket, - onshapeApi: OnshapeApi, - elementPath: ElementPath, - microversionId: string -): Promise { - const [small, large] = await Promise.all([ - getElementThumbnail(onshapeApi, elementPath, ThumbnailSize.SMALL), - getElementThumbnail(onshapeApi, elementPath, ThumbnailSize.LARGE) - ]); - if (!small || !large) { - throw new Error("Failed to find thumbnails. Try again later."); - } - - const { elementId } = elementPath; - await Promise.all([ - putThumbnail( - bucket, - thumbnailKey(elementId, microversionId, ThumbnailSize.SMALL), - small, - { microversionId } - ), - putThumbnail( - bucket, - thumbnailKey(elementId, microversionId, ThumbnailSize.LARGE), - large, - { microversionId } - ) - ]); - - return { - small: thumbnailUrl({ - elementId, - microversionId, - size: ThumbnailSize.SMALL, - canonicalConfiguration: DEFAULT_CANONICAL_CONFIGURATION - }), - large: thumbnailUrl({ - elementId, - microversionId, - size: ThumbnailSize.LARGE, - canonicalConfiguration: DEFAULT_CANONICAL_CONFIGURATION - }) - }; -} - -/** Whether every key is already stored, so the render can be skipped. */ -async function allStored(bucket: R2Bucket, keys: string[]): Promise { - const heads = await Promise.all(keys.map((key) => bucket.head(key))); - return heads.every((head) => head !== null); -} - -/** - * Both sizes, so a row and its hover never disagree. The two-stage id flow is the - * only Onshape path taking a configuration; either call can fail mid-render. - */ -export async function uploadConfigurationThumbnails( - bucket: R2Bucket, - onshapeApi: OnshapeApi, - elementPath: ElementPath, - microversionId: string, - canonicalConfiguration: string -): Promise { - const configurationKey = canonicalConfigurationKey(canonicalConfiguration); - const { elementId } = elementPath; - const targets = [ThumbnailSize.SMALL, ThumbnailSize.LARGE].map((size) => ({ - size, - key: thumbnailKey(elementId, microversionId, size, configurationKey) - })); - const keys = targets.map((target) => target.key); - - // Runs are no longer deduplicated by id, and Onshape is the expensive part. - if (await allStored(bucket, keys)) { - return; - } - - const thumbnailId = await getThumbnailId( - onshapeApi, - elementPath, - canonicalConfiguration - ); - const rendered = await Promise.all( - targets.map(async ({ size, key }) => ({ - key, - thumbnail: await getThumbnailFromId(onshapeApi, thumbnailId, size) - })) - ); - - // The render above takes minutes, long enough to have been beaten to it. - if (await allStored(bucket, keys)) { - return; - } - - await Promise.all( - rendered.map(({ key, thumbnail }) => - putThumbnail(bucket, key, thumbnail, { - microversionId, - canonicalConfiguration - }) - ) - ); -} - -/** Falls back to the first element when the document designates no thumbnail. */ -export async function uploadDocumentThumbnails( - bucket: R2Bucket, - onshapeApi: OnshapeApi, - versionPath: InstancePath -): Promise { - const [onshapeDocument, contents] = await Promise.all([ - getDocument(onshapeApi, versionPath), - getContents(onshapeApi, versionPath) - ]); - - let thumbnailElementId = onshapeDocument.documentThumbnailElementId; - if (!thumbnailElementId) { - if (contents.elements.length < 1) - throw new Error( - `Document ${onshapeDocument.name} has no elements to use as a thumbnail.` - ); - thumbnailElementId = contents.elements[0].id; - } - - const element = contents.elements.find((e) => e.id === thumbnailElementId); - if (!element) { - throw new Error("Unexpectedly failed to find the thumbnail element."); - } - - const thumbnailPath: ElementPath = { - ...versionPath, - elementId: thumbnailElementId - }; - return uploadThumbnails( - bucket, - onshapeApi, - thumbnailPath, - element.microversionId - ); -} +import { uploadDocumentThumbnails, uploadThumbnails } from "./store"; export const thumbnailRoutes = getApp(); diff --git a/src/backend/features/thumbnails/store.ts b/src/backend/features/thumbnails/store.ts new file mode 100644 index 000000000..ec464639a --- /dev/null +++ b/src/backend/features/thumbnails/store.ts @@ -0,0 +1,182 @@ +/** + * Renders thumbnails through Onshape and stores them in R2. Kept out of the + * routes so the load workflows can reach it without importing the app. + */ + +import { CachePolicy, immutableCacheControl } from "../../lib/cache"; + +import { + getElementThumbnail, + getThumbnailFromId, + getThumbnailId +} from "../../lib/onshape/endpoints/thumbnails"; +import { + getDocument, + getContents +} from "../../lib/onshape/endpoints/documents"; +import { type ElementPath, type InstancePath } from "../../lib/onshape/path"; + +import { ThumbnailSize, ThumbnailUrls } from "./types"; +import { thumbnailKey, thumbnailUrl } from "./keys"; +import { + DEFAULT_CANONICAL_CONFIGURATION, + canonicalConfigurationKey +} from "../configurations/canonical"; +import { OnshapeApi } from "../../lib/onshape/client"; + +/** Stores one rendered thumbnail, tagging it with what produced it. */ +async function putThumbnail( + bucket: R2Bucket, + key: string, + thumbnail: ArrayBuffer, + metadata: Record +): Promise { + await bucket.put(key, thumbnail, { + httpMetadata: { + contentType: "image/gif", + cacheControl: immutableCacheControl(CachePolicy.PUBLIC_CACHE) + }, + customMetadata: metadata + }); +} + +/** Throws until Onshape has rendered them, which drives the load step's retries. */ +export async function uploadThumbnails( + bucket: R2Bucket, + onshapeApi: OnshapeApi, + elementPath: ElementPath, + microversionId: string +): Promise { + const [small, large] = await Promise.all([ + getElementThumbnail(onshapeApi, elementPath, ThumbnailSize.SMALL), + getElementThumbnail(onshapeApi, elementPath, ThumbnailSize.LARGE) + ]); + if (!small || !large) { + throw new Error("Failed to find thumbnails. Try again later."); + } + + const { elementId } = elementPath; + await Promise.all([ + putThumbnail( + bucket, + thumbnailKey(elementId, microversionId, ThumbnailSize.SMALL), + small, + { microversionId } + ), + putThumbnail( + bucket, + thumbnailKey(elementId, microversionId, ThumbnailSize.LARGE), + large, + { microversionId } + ) + ]); + + return { + small: thumbnailUrl({ + elementId, + microversionId, + size: ThumbnailSize.SMALL, + canonicalConfiguration: DEFAULT_CANONICAL_CONFIGURATION + }), + large: thumbnailUrl({ + elementId, + microversionId, + size: ThumbnailSize.LARGE, + canonicalConfiguration: DEFAULT_CANONICAL_CONFIGURATION + }) + }; +} + +/** Whether every key is already stored, so the render can be skipped. */ +async function allStored(bucket: R2Bucket, keys: string[]): Promise { + const heads = await Promise.all(keys.map((key) => bucket.head(key))); + return heads.every((head) => head !== null); +} + +/** + * Both sizes, so a row and its hover never disagree. The two-stage id flow is the + * only Onshape path taking a configuration; either call can fail mid-render. + */ +export async function uploadConfigurationThumbnails( + bucket: R2Bucket, + onshapeApi: OnshapeApi, + elementPath: ElementPath, + microversionId: string, + canonicalConfiguration: string +): Promise { + const configurationKey = canonicalConfigurationKey(canonicalConfiguration); + const { elementId } = elementPath; + const targets = [ThumbnailSize.SMALL, ThumbnailSize.LARGE].map((size) => ({ + size, + key: thumbnailKey(elementId, microversionId, size, configurationKey) + })); + const keys = targets.map((target) => target.key); + + // Runs are no longer deduplicated by id, and Onshape is the expensive part. + if (await allStored(bucket, keys)) { + return; + } + + const thumbnailId = await getThumbnailId( + onshapeApi, + elementPath, + canonicalConfiguration + ); + const rendered = await Promise.all( + targets.map(async ({ size, key }) => ({ + key, + thumbnail: await getThumbnailFromId(onshapeApi, thumbnailId, size) + })) + ); + + // The render above takes minutes, long enough to have been beaten to it. + if (await allStored(bucket, keys)) { + return; + } + + await Promise.all( + rendered.map(({ key, thumbnail }) => + putThumbnail(bucket, key, thumbnail, { + microversionId, + canonicalConfiguration + }) + ) + ); +} + +/** Falls back to the first element when the document designates no thumbnail. */ +export async function uploadDocumentThumbnails( + bucket: R2Bucket, + onshapeApi: OnshapeApi, + versionPath: InstancePath +): Promise { + const [onshapeDocument, contents] = await Promise.all([ + getDocument(onshapeApi, versionPath), + getContents(onshapeApi, versionPath) + ]); + + let thumbnailElementId = onshapeDocument.documentThumbnailElementId; + if (!thumbnailElementId) { + if (contents.elements.length < 1) + throw new Error( + `Document ${onshapeDocument.name} has no elements to use as a thumbnail.` + ); + thumbnailElementId = contents.elements[0].id; + } + + const element = contents.elements.find((e) => e.id === thumbnailElementId); + if (!element) { + throw new Error("Unexpectedly failed to find the thumbnail element."); + } + + const thumbnailPath: ElementPath = { + ...versionPath, + elementId: thumbnailElementId + }; + return uploadThumbnails( + bucket, + onshapeApi, + thumbnailPath, + element.microversionId + ); +} diff --git a/src/backend/features/thumbnails/workflow.ts b/src/backend/features/thumbnails/workflow.ts new file mode 100644 index 000000000..7347a805a --- /dev/null +++ b/src/backend/features/thumbnails/workflow.ts @@ -0,0 +1,82 @@ +import { + WorkflowEntrypoint, + type WorkflowEvent, + type WorkflowStep +} from "cloudflare:workers"; +import { eq } from "drizzle-orm"; +import type { AppBindings } from "../../lib/context"; +import { getDb } from "../../db/client"; +import { insertables } from "../../db/schema"; +import { createLimiter, getOnshapeApiFromContext } from "../load/context"; +import { THUMBNAIL_STEP_RETRIES } from "../load/steps"; +import { uploadConfigurationThumbnails } from "./store"; + +/** The render to run, plus the session whose Onshape tokens it runs under. */ +export interface ThumbnailWorkflowParams { + insertableId: string; + /** Part of the key, so a render lands where the request looked for it. */ + microversionId: string; + /** Never the default, which loads eagerly with the element. */ + canonicalConfiguration: string; + sessionId: string; +} + +/** + * Outside a request, since Onshape can take minutes. Until it finishes, + * requests fall back to the element's default thumbnail. + */ +export class ThumbnailWorkflow extends WorkflowEntrypoint< + AppBindings, + ThumbnailWorkflowParams +> { + async run( + event: WorkflowEvent, + step: WorkflowStep + ): Promise { + const { + insertableId, + microversionId, + canonicalConfiguration, + sessionId + } = event.payload; + + const elementPath = await step.do("resolve-element", async () => { + const row = await getDb(this.env.DB) + .select({ + documentId: insertables.documentId, + versionId: insertables.versionId, + elementId: insertables.elementId + }) + .from(insertables) + .where(eq(insertables.id, insertableId)) + .get(); + if (!row) { + throw new Error(`No insertable ${insertableId}`); + } + return { + documentId: row.documentId, + instanceId: row.versionId, + instanceType: "v" as const, + elementId: row.elementId + }; + }); + + await step.do( + "render-thumbnails", + { retries: THUMBNAIL_STEP_RETRIES }, + async () => + uploadConfigurationThumbnails( + this.env.BLOB, + await getOnshapeApiFromContext({ + env: this.env, + sessionId, + step, + limit: createLimiter(1) + }), + elementPath, + microversionId, + canonicalConfiguration + ) + ); + } +} diff --git a/src/backend/index.ts b/src/backend/index.ts index 911db041b..b212f5781 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -1,8 +1,8 @@ export { AddGroupWorkflow, - LoadLibraryWorkflow, - ThumbnailWorkflow -} from "./features/library/workflows"; + LoadLibraryWorkflow +} from "./features/load/workflows"; +export { ThumbnailWorkflow } from "./features/thumbnails/workflow"; import { createApp } from "./app"; import { productionServices } from "./features/auth/services"; diff --git a/src/backend/lib/context.ts b/src/backend/lib/context.ts index 9fcbe64c8..642245979 100644 --- a/src/backend/lib/context.ts +++ b/src/backend/lib/context.ts @@ -1,9 +1,9 @@ import { type Context, Hono } from "hono"; import type { AddGroupParams, - LoadLibraryParams, - ThumbnailWorkflowParams -} from "../features/library/workflows/index"; + LoadLibraryParams +} from "../features/load/workflows"; +import type { ThumbnailWorkflowParams } from "../features/thumbnails/workflow"; import { type AccessLevel } from "../features/auth/access-level"; import { type OAuthApi } from "./onshape/client"; From afb728becf4a37e555e2ef53a6a99e5644b139d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 20:34:03 +0000 Subject: [PATCH 07/56] refactor: split auth by role, and reduce app.ts to assembly auth/ had three files that all answered 'is this caller allowed' without a line between them. Split by role instead: - session.ts the session cookie and its KV records - onshape-oauth.ts the handshake only - caller.ts resolving who is calling, with the KV memoization, plus productionCaller (was services.ts) - guards.ts both gates, which were one-per-file in sign-in.ts and access-control.ts - routes.ts the OAuth redirects and /access-data /access-data reports access level and sign-in state, so it moves from users to auth. What is left of users is the caller's stored preferences, so it becomes settings/. app.ts was a composition root plus the /init handler plus error handling. /init is now features/entry (its test came along, as routes.test.ts rather than app.test.ts), the error handler is lib/errors.ts, and the caller binding is lib/context.ts, leaving app.ts to mount and nothing else. AppServices is renamed Caller: it is the request's caller, not a service registry. not-signed-in.test.ts is split into the modules it covers, guards.test.ts and routes.test.ts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- docs/REFERENCE.md | 11 +- src/__test_utils__/test-app.ts | 4 +- src/backend/app.ts | 139 ++++---------- src/backend/db/schema.ts | 2 +- src/backend/features/auth/access-control.ts | 54 ------ src/backend/features/auth/caller.ts | 176 ++++++++++++++++++ .../{not-signed-in.test.ts => guards.test.ts} | 23 +-- src/backend/features/auth/guards.ts | 32 ++++ src/backend/features/auth/onshape-oauth.ts | 80 +------- src/backend/features/auth/routes.test.ts | 48 +++++ src/backend/features/auth/routes.ts | 15 ++ src/backend/features/auth/services.ts | 38 ---- src/backend/features/auth/sign-in.ts | 50 ----- src/backend/features/build-checker/routes.ts | 2 +- .../entry/routes.test.ts} | 10 +- src/backend/features/entry/routes.ts | 51 +++++ src/backend/features/favorites/routes.ts | 2 +- src/backend/features/library/groups/routes.ts | 2 +- .../features/library/insertables/routes.ts | 4 +- src/backend/features/load/context.ts | 2 +- .../{users => settings}/routes.test.ts | 28 +-- .../features/{users => settings}/routes.ts | 18 +- .../features/{users => settings}/settings.ts | 0 src/backend/features/thumbnails/routes.ts | 2 +- src/backend/index.ts | 4 +- src/backend/lib/context.ts | 29 ++- src/backend/lib/errors.ts | 27 +++ .../settings/components/settings-menu.tsx | 4 +- .../features/settings/local-settings.ts | 2 +- src/frontend/features/settings/settings.ts | 2 +- src/frontend/lib/onshape-params.ts | 2 +- src/frontend/routes/__root.tsx | 2 +- 32 files changed, 448 insertions(+), 417 deletions(-) delete mode 100644 src/backend/features/auth/access-control.ts create mode 100644 src/backend/features/auth/caller.ts rename src/backend/features/auth/{not-signed-in.test.ts => guards.test.ts} (67%) create mode 100644 src/backend/features/auth/guards.ts create mode 100644 src/backend/features/auth/routes.test.ts delete mode 100644 src/backend/features/auth/services.ts delete mode 100644 src/backend/features/auth/sign-in.ts rename src/backend/{app.test.ts => features/entry/routes.test.ts} (93%) create mode 100644 src/backend/features/entry/routes.ts rename src/backend/features/{users => settings}/routes.test.ts (54%) rename src/backend/features/{users => settings}/routes.ts (50%) rename src/backend/features/{users => settings}/settings.ts (100%) create mode 100644 src/backend/lib/errors.ts diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 702a00dd8..e7355fb32 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -145,13 +145,14 @@ owns, `lib/` for cross-cutting plumbing, and a small set of files at the root. ### `src/backend/` - `index.ts` — Worker entry point; exports the default app and the three Workflow classes -- `app.ts` — composition root: injects per-request services, mounts every feature's routes, and handles errors +- `app.ts` — composition root, and nothing else: binds the caller onto each request, mounts every feature's routes, and installs the error handler - `db/` — `client.ts` (the Drizzle client) and `schema.ts` (table definitions) -- `lib/` — request plumbing shared by every feature: `context.ts` (bindings and typed context), `cache.ts` (cache-control middleware), `route-params.ts`, `query-params.ts` +- `lib/` — request plumbing shared by every feature: `context.ts` (bindings, typed context, and the caller binding), `cache.ts` (cache-control middleware), `errors.ts`, `route-params.ts`, `query-params.ts` - `lib/onshape/` — everything that talks to Onshape's REST API: `client.ts` (the client class), `api-path.ts`, `path.ts` (`ElementPath`/`InstancePath` and their serializers), `endpoints/` (per-category wrappers), `objects/` (feature and query builders) - `features/` — one directory per feature, each holding its own `routes.ts` plus whatever it owns: - - `auth/` — OAuth flow (`onshape-oauth.ts`), session storage (`session.ts`), and the two authorization gates: `sign-in.ts` (signed in to Onshape at all) and `access-control.ts` (on the admin team) - - `users/` — user preferences and the `Settings` model + - `auth/` — split by role: `session.ts` stores the session cookie and its KV records, `onshape-oauth.ts` runs the handshake, `caller.ts` resolves who is calling (and exports `productionCaller`, the wiring `createApp` binds), `guards.ts` holds both gates, and `routes.ts` serves the OAuth redirects plus `/access-data` + - `entry/` — `/init`, where Onshape lands: gates on auth, then resumes the caller in the library and theme they last used + - `settings/` — the caller's stored preferences and the `Settings` model - `library/` — the library response (`db.ts`), its DTOs, and the groups and insertables endpoints - `load/` — everything that turns Onshape into what we store: the `parse-*` modules (document contents, configurations, configuration records, vendors, fasten info), the per-group and per-insertable loaders, the Workflows that drive them, their retry policies, and the job tracker - `configurations/` — the configuration domain the frontend shares: models, canonicalization, combination enumeration, and the input parser @@ -176,6 +177,6 @@ Other top-level files: The app has three access levels, checked on every protected API call: **ADMIN**, **EDITOR**, and **USER**. Admin and editor access currently grant the same permissions (adding, removing, and renaming groups, toggling insertable visibility), but they are kept separate so permissions can be tightened in the future if needed. USER access allows anyone who logs in via OAuth to browse the library, insert parts, and manage their own favorites. -The Worker determines a user's access level in `src/backend/features/auth/services.ts` by calling the Onshape API to check team membership against the `ADMIN_TEAM` binding. Backend routes that require elevated access are wrapped with `requireEditorMiddleware` or `requireAdminMiddleware` from `src/backend/features/auth/access-control.ts`. +The Worker determines a user's access level in `src/backend/features/auth/caller.ts` by calling the Onshape API to check team membership against the `ADMIN_TEAM` binding. Backend routes that require elevated access are wrapped with `requireEditorMiddleware` or `requireAdminMiddleware` from `src/backend/features/auth/guards.ts`. During local development, you can bypass the team membership check by setting `ACCESS_LEVEL_OVERRIDE=admin` (or `editor`/`user`) in your `.env` file. diff --git a/src/__test_utils__/test-app.ts b/src/__test_utils__/test-app.ts index 076b3a605..82f352d75 100644 --- a/src/__test_utils__/test-app.ts +++ b/src/__test_utils__/test-app.ts @@ -19,8 +19,8 @@ export interface TestAppOptions { } /** - * The real app from `createApp`, with Onshape, userId and access level mocked. - * Drive it with `app.request(path, init, env)`. + * The real app from `createApp`, with a stub caller in place of + * `productionCaller`. Drive it with `app.request(path, init, env)`. */ export function createTestApp(options: TestAppOptions = {}) { const signedIn = options.signedIn ?? true; diff --git a/src/backend/app.ts b/src/backend/app.ts index c763948a9..fd44c0fb7 100644 --- a/src/backend/app.ts +++ b/src/backend/app.ts @@ -1,118 +1,49 @@ -import { HTTPException } from "hono/http-exception"; -import { HttpStatus } from "http-status-ts"; -import { eq } from "drizzle-orm"; -import { getDb } from "./db/client"; -import { users } from "./db/schema"; -import { DEFAULT_LIBRARY_ID } from "./features/library/library-id"; -import { DEFAULT_SETTINGS } from "./features/users/settings"; -import { authRoutes } from "./features/auth/routes"; -import { getSessionCompanyId } from "./features/auth/session"; -import { cacheMiddleware } from "./lib/cache"; -import { - getApp, - type AppContext, - type AppServicesFactory -} from "./lib/context"; -import { OnshapeRateLimitError } from "./lib/onshape/client"; -import { userRoutes } from "./features/users/routes"; -import { libraryRoutes } from "./features/library/routes"; -import { favoriteRoutes } from "./features/favorites/routes"; -import { thumbnailRoutes } from "./features/thumbnails/routes"; -import { insertableRoutes } from "./features/library/insertables/routes"; -import { groupRoutes } from "./features/library/groups/routes"; -import { configurationRoutes } from "./features/configurations/routes"; -import { buildStatusRoutes } from "./features/build-checker/routes"; - /** - * Returns the relative URL of the given requestUrl. - * Used as a workaround to get the current URL without breaking in local dev due to Cloudflare stripping the port number. + * Composition root: binds the caller onto every request and mounts each + * feature's routes. Everything it wires lives in a feature or in lib. */ -function getRelativeUrl(requestUrl: string) { - const { pathname, search } = new URL(requestUrl); - return pathname + search; -} +import { accessRoutes, authRoutes } from "./features/auth/routes"; +import { buildStatusRoutes } from "./features/build-checker/routes"; +import { configurationRoutes } from "./features/configurations/routes"; +import { entryRoutes } from "./features/entry/routes"; +import { favoriteRoutes } from "./features/favorites/routes"; +import { groupRoutes } from "./features/library/groups/routes"; +import { insertableRoutes } from "./features/library/insertables/routes"; +import { libraryRoutes } from "./features/library/routes"; +import { settingsRoutes } from "./features/settings/routes"; +import { thumbnailRoutes } from "./features/thumbnails/routes"; +import { cacheMiddleware } from "./lib/cache"; +import { bindCaller, getApp, type CallerFactory } from "./lib/context"; +import { errorHandler } from "./lib/errors"; + +const apiRoutes = [ + accessRoutes, + settingsRoutes, + libraryRoutes, + groupRoutes, + insertableRoutes, + configurationRoutes, + thumbnailRoutes, + favoriteRoutes, + buildStatusRoutes +]; + +export function createApp(makeCaller: CallerFactory) { + const app = getApp(); -/** Builds the url the caller resumes at, seeded with the library and theme they last used. */ -async function getEntryUrl(c: AppContext): Promise { - const db = getDb(c.env.DB); - const user = await db - .select({ libraryId: users.libraryId, theme: users.theme }) - .from(users) - .where(eq(users.id, await c.var.getUserId())) - .get(); + app.use("*", bindCaller(makeCaller)); - const search = new URL(c.req.url).searchParams; - const systemTheme = search.get("theme"); - if (systemTheme !== null) { - search.set("systemTheme", systemTheme); + for (const routes of apiRoutes) { + app.route("/api", routes); } - const currentTheme = user?.theme ?? DEFAULT_SETTINGS.theme; - search.set("theme", currentTheme); - - const libraryId = user?.libraryId ?? DEFAULT_LIBRARY_ID; - return `/app/library/${libraryId}?${search.toString()}`; -} - -/** - * Composition root. `makeServices` is bound onto each request's context, so - * handlers reach Onshape and access level through `c.var`. - */ -export function createApp(makeServices: AppServicesFactory) { - const app = getApp(); - - app.use("*", async (c, next) => { - const services = makeServices(c); - c.set("getOnshapeApi", services.getOnshapeApi); - c.set("getUserId", services.getUserId); - c.set("getAccessLevel", services.getAccessLevel); - c.set("isAuthenticated", services.isAuthenticated); - await next(); - }); - // Mount all API routes - app.route("/api", userRoutes); - app.route("/api", libraryRoutes); - app.route("/api", favoriteRoutes); - app.route("/api", thumbnailRoutes); - app.route("/api", groupRoutes); - app.route("/api", insertableRoutes); - app.route("/api", configurationRoutes); - app.route("/api", buildStatusRoutes); // Per-request redirects carrying OAuth state; never reusable. app.use("/auth/*", cacheMiddleware()); app.route("/auth", authRoutes); - // `/init` is the auth-gated entry point. - app.on("GET", "/init", cacheMiddleware(), async (c) => { - if (!(await c.var.isAuthenticated())) { - const currentUrl = getRelativeUrl(c.req.url); - const signInUrl = `/auth/sign-in?redirectUrl=${encodeURIComponent(currentUrl)}&sessionCompanyId=${getSessionCompanyId(c)}`; - return c.redirect(signInUrl); - } - return c.redirect(await getEntryUrl(c)); - }); + app.route("/", entryRoutes); - app.onError((err, c) => { - // Surface an Onshape rate limit as a 429 the client can retry, rather - // than blocking the request thread waiting it out. - if (err instanceof OnshapeRateLimitError) { - return c.json( - { - error: "Onshape rate limit reached. Please try again shortly.", - retryAfterSeconds: err.retryAfterSeconds - }, - HttpStatus.TOO_MANY_REQUESTS - ); - } - if (err instanceof HTTPException) { - return err.getResponse(); - } - console.error(err); - return c.json( - { error: "Internal Server Error" }, - HttpStatus.INTERNAL_SERVER_ERROR - ); - }); + app.onError(errorHandler); return app; } diff --git a/src/backend/db/schema.ts b/src/backend/db/schema.ts index f05cf26d8..fc97b5734 100644 --- a/src/backend/db/schema.ts +++ b/src/backend/db/schema.ts @@ -2,7 +2,7 @@ import { sqliteTable, text, integer, unique } from "drizzle-orm/sqlite-core"; import { ElementType } from "../lib/onshape/element-type"; import { FastenInfo } from "../features/library/insertables/fasten"; import { DEFAULT_LIBRARY_ID, LibraryId } from "../features/library/library-id"; -import { DEFAULT_SETTINGS, Theme } from "../features/users/settings"; +import { DEFAULT_SETTINGS, Theme } from "../features/settings/settings"; import { Vendor } from "../features/library/vendors"; import { ParameterValues, diff --git a/src/backend/features/auth/access-control.ts b/src/backend/features/auth/access-control.ts deleted file mode 100644 index 9d1a89fb9..000000000 --- a/src/backend/features/auth/access-control.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { HttpStatus } from "http-status-ts"; -import type { MiddlewareHandler } from "hono"; -import { HTTPException } from "hono/http-exception"; -import type { AppContext, AppContextEnv } from "../../lib/context"; -import { getOnshapeApi } from "./onshape-oauth"; -import { getSessionId } from "./session"; -import { getAccessLevel } from "../../lib/onshape/endpoints/users"; -import { hasEditorAccess, type AccessLevel } from "./access-level"; - -/** How long a resolved access level is cached in KV. */ -const ACCESS_LEVEL_TTL_SECONDS = 60 * 60; - -function accessLevelKey(sessionId: string): string { - return `access-level:${sessionId}`; -} - -/** Returns the caller's access level, memoized in KV by session. */ -export async function getCachedAccessLevel( - c: AppContext -): Promise { - const key = accessLevelKey(getSessionId(c)); - - const cached = await c.env.KV.get(key); - if (cached) return cached as AccessLevel; - - const level = await getAccessLevel( - await getOnshapeApi(c), - c.env.ADMIN_TEAM - ); - await c.env.KV.put(key, level, { - expirationTtl: ACCESS_LEVEL_TTL_SECONDS - }); - return level; -} - -async function requireEditorAccess(c: AppContext): Promise { - const level = await c.var.getAccessLevel(); - if (!hasEditorAccess(level)) { - throw new HTTPException(HttpStatus.FORBIDDEN, { - message: "You must be on the admin team to use this functionality" - }); - } -} - -/** - * Middleware which requires users to be an editor or an admin. - */ -export const requireEditorMiddleware: MiddlewareHandler = async ( - c, - next -) => { - await requireEditorAccess(c); - await next(); -}; diff --git a/src/backend/features/auth/caller.ts b/src/backend/features/auth/caller.ts new file mode 100644 index 000000000..7acc7847f --- /dev/null +++ b/src/backend/features/auth/caller.ts @@ -0,0 +1,176 @@ +/** + * Resolves who is calling from their session, memoizing the Onshape lookups in + * KV. `productionCaller` is what `createApp` binds onto every request; the + * guards and routes read the results through `c.var`. + */ +import { env as processEnv } from "process"; +import { OAuthApi } from "../../lib/onshape/client"; +import { + getAccessLevel, + getSessionInfo, + getUserId +} from "../../lib/onshape/endpoints/users"; +import { type AppContext, type CallerFactory } from "../../lib/context"; +import { AccessLevel } from "./access-level"; +import { + getOauthClient, + makeAuthTokens, + TOKEN_ENDPOINT +} from "./onshape-oauth"; +import { + SESSION_TTL, + getSessionCompanyId, + getSessionId, + getTokens, + saveTokens +} from "./session"; + +/** How long a resolved access level is cached in KV. */ +const ACCESS_LEVEL_TTL_SECONDS = 60 * 60; + +/** Stable fake user id used for FORCE_SIGNED_IN testing sessions. */ +export const FORCE_SIGNED_IN_USER_ID = "force-signed-in-user"; + +export async function getOnshapeApiFromSessionId( + kv: KVNamespace, + sessionId: string +): Promise { + const tokens = await getTokens(kv, sessionId); + + const refreshCallback = async () => { + const oauthClient = getOauthClient(); + const newTokens = await oauthClient + .refreshAccessToken(TOKEN_ENDPOINT, tokens.refreshToken, []) + .then((refreshed) => makeAuthTokens(refreshed)); + + void saveTokens(kv, sessionId, newTokens); + + return newTokens.accessToken; + }; + + let accessToken = tokens.accessToken; + // If the token expired in the past, refresh immediately + if (tokens.expiresAt <= Date.now()) { + accessToken = await refreshCallback(); + } + + return new OAuthApi(accessToken, refreshCallback); +} + +/** + * Creates/caches an Onshape API instance from the AppContext. + * + * Note this function should not be called directly, as it is bound to the context directly. + */ +export async function getOnshapeApi(c: AppContext): Promise { + const cached = c.get("onshapeApi"); + if (cached) return cached; + const api = await getOnshapeApiFromSessionId(c.env.KV, getSessionId(c)); + c.set("onshapeApi", api); + return api; +} + +function userIdKey(sessionId: string): string { + return `user-id:${sessionId}`; +} + +/** Returns the caller's Onshape user id, memoized in KV by session. */ +export async function getCachedUserId(c: AppContext): Promise { + const key = userIdKey(getSessionId(c)); + + const cached = await c.env.KV.get(key); + if (cached) return cached; + + const userId = await getUserId(await getOnshapeApi(c)); + await c.env.KV.put(key, userId, { expirationTtl: SESSION_TTL }); + return userId; +} + +export async function isAuthenticated(c: AppContext): Promise { + try { + const onshapeApi = await c.var.getOnshapeApi(); + const sessionInfo = await getSessionInfo(onshapeApi); + const tokenCompanyId = sessionInfo.company?.id ?? "cad"; + return getSessionCompanyId(c) === tokenCompanyId; + } catch { + return false; + } +} + +/** FORCE_SIGNED_IN is a dev-only escape hatch, ignored in production. */ +export function isForceSignedIn(c: AppContext): boolean { + return !!c.env.FORCE_SIGNED_IN && processEnv.NODE_ENV !== "production"; +} + +/** + * Whether the caller has a valid Onshape session, memoized on the request. + * `FORCE_SIGNED_IN` forces it true for testing. + */ +export async function isSignedIn(c: AppContext): Promise { + const cached = c.get("signedIn"); + if (cached !== undefined) return cached; + + let signedIn: boolean; + if (isForceSignedIn(c)) { + signedIn = true; + } else { + try { + await c.var.getOnshapeApi(); + signedIn = true; + } catch { + signedIn = false; + } + } + + c.set("signedIn", signedIn); + return signedIn; +} + +function accessLevelKey(sessionId: string): string { + return `access-level:${sessionId}`; +} + +/** Returns the caller's access level, memoized in KV by session. */ +export async function getCachedAccessLevel( + c: AppContext +): Promise { + const key = accessLevelKey(getSessionId(c)); + + const cached = await c.env.KV.get(key); + if (cached) return cached as AccessLevel; + + const level = await getAccessLevel( + await getOnshapeApi(c), + c.env.ADMIN_TEAM + ); + await c.env.KV.put(key, level, { + expirationTtl: ACCESS_LEVEL_TTL_SECONDS + }); + return level; +} + +/** + * Production wiring. getUserId only runs behind requireSignInMiddleware; + * getAccessLevel falls back to USER for anyone without a real Onshape session. + */ +export const productionCaller: CallerFactory = (c) => ({ + getOnshapeApi: () => getOnshapeApi(c), + getUserId: () => { + // FORCE_SIGNED_IN has no real Onshape session; use a stable fake id. + if (isForceSignedIn(c)) { + return Promise.resolve(FORCE_SIGNED_IN_USER_ID); + } + return getCachedUserId(c); + }, + getAccessLevel: async () => { + const override = c.env.ACCESS_LEVEL_OVERRIDE; + if (override) return override as AccessLevel; + // 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))) { + return getCachedAccessLevel(c); + } + return AccessLevel.USER; + }, + isAuthenticated: () => isAuthenticated(c) +}); diff --git a/src/backend/features/auth/not-signed-in.test.ts b/src/backend/features/auth/guards.test.ts similarity index 67% rename from src/backend/features/auth/not-signed-in.test.ts rename to src/backend/features/auth/guards.test.ts index d91fec2ce..7d21ec834 100644 --- a/src/backend/features/auth/not-signed-in.test.ts +++ b/src/backend/features/auth/guards.test.ts @@ -1,8 +1,7 @@ import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it } from "vitest"; -import { AccessLevel } from "./access-level"; import { LibraryId } from "../library/library-id"; -import { Theme } from "../users/settings"; +import { Theme } from "../settings/settings"; import { createTestApp, jsonRequest, @@ -13,29 +12,11 @@ import { getDb } from "../../db/client"; const db = getDb(env.DB); -describe("not-signed-in access", () => { +describe("requireSignInMiddleware", () => { beforeEach(async () => { await resetDb(db); }); - it("GET /access-data reports signedIn: false when not signed in", async () => { - const app = createTestApp({ - signedIn: false, - accessLevel: AccessLevel.USER - }); - - const res = await app.request( - "/api/access-data", - jsonRequest("GET"), - env - ); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ - maxAccessLevel: AccessLevel.USER, - signedIn: false - }); - }); - it("blocks sign-in-only routes with 401 when 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 new file mode 100644 index 000000000..e0bdff12a --- /dev/null +++ b/src/backend/features/auth/guards.ts @@ -0,0 +1,32 @@ +/** The two gates routes mount: signed in to Onshape at all, and on the admin team. */ +import type { MiddlewareHandler } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { HttpStatus } from "http-status-ts"; +import type { AppContextEnv } from "../../lib/context"; +import { hasEditorAccess } from "./access-level"; +import { isSignedIn } from "./caller"; + +export const requireSignInMiddleware: MiddlewareHandler = async ( + c, + next +) => { + if (!(await isSignedIn(c))) { + throw new HTTPException(HttpStatus.UNAUTHORIZED, { + message: + "You must be signed in to Onshape to use this functionality" + }); + } + await next(); +}; + +export const requireEditorMiddleware: MiddlewareHandler = async ( + c, + next +) => { + if (!hasEditorAccess(await c.var.getAccessLevel())) { + throw new HTTPException(HttpStatus.FORBIDDEN, { + message: "You must be on the admin team to use this functionality" + }); + } + await next(); +}; diff --git a/src/backend/features/auth/onshape-oauth.ts b/src/backend/features/auth/onshape-oauth.ts index e157400e9..9a4bccfdc 100644 --- a/src/backend/features/auth/onshape-oauth.ts +++ b/src/backend/features/auth/onshape-oauth.ts @@ -1,30 +1,24 @@ -/** The Onshape OAuth flow, and the API client a stored session produces. */ +/** The Onshape OAuth handshake: where we send the user, and what comes back. */ import { HttpStatus } from "http-status-ts"; import { generateState, OAuth2Client, OAuth2Tokens } from "arctic"; import { HTTPException } from "hono/http-exception"; import { env } from "cloudflare:workers"; -import { OAuthApi } from "../../lib/onshape/client"; -import { getSessionInfo, getUserId } from "../../lib/onshape/endpoints/users"; import { type AppContext } from "../../lib/context"; import { type AuthTokens, - SESSION_TTL, - getSessionCompanyId, - getSessionId, - getTokens, saveTokens, startLoginSession, takeLoginSession } from "./session"; const AUTH_ENDPOINT = "https://oauth.onshape.com/oauth/authorize"; -const TOKEN_ENDPOINT = "https://oauth.onshape.com/oauth/token"; +export const TOKEN_ENDPOINT = "https://oauth.onshape.com/oauth/token"; -function getOauthClient(): OAuth2Client { +export function getOauthClient(): OAuth2Client { return new OAuth2Client(env.OAUTH_CLIENT_ID, env.OAUTH_CLIENT_SECRET, null); } -function makeAuthTokens(tokens: OAuth2Tokens): AuthTokens { +export function makeAuthTokens(tokens: OAuth2Tokens): AuthTokens { return { accessToken: tokens.accessToken(), refreshToken: tokens.refreshToken(), @@ -32,72 +26,6 @@ function makeAuthTokens(tokens: OAuth2Tokens): AuthTokens { }; } -export async function getOnshapeApiFromSessionId( - kv: KVNamespace, - sessionId: string -): Promise { - const tokens = await getTokens(kv, sessionId); - - const refreshCallback = async () => { - const oauthClient = getOauthClient(); - const newTokens = await oauthClient - .refreshAccessToken(TOKEN_ENDPOINT, tokens.refreshToken, []) - .then((refreshed) => makeAuthTokens(refreshed)); - - void saveTokens(kv, sessionId, newTokens); - - return newTokens.accessToken; - }; - - let accessToken = tokens.accessToken; - // If the token expired in the past, refresh immediately - if (tokens.expiresAt <= Date.now()) { - accessToken = await refreshCallback(); - } - - return new OAuthApi(accessToken, refreshCallback); -} - -/** - * Creates/caches an Onshape API instance from the AppContext. - * - * Note this function should not be called directly, as it is bound to the context directly. - */ -export async function getOnshapeApi(c: AppContext): Promise { - const cached = c.get("onshapeApi"); - if (cached) return cached; - const api = await getOnshapeApiFromSessionId(c.env.KV, getSessionId(c)); - c.set("onshapeApi", api); - return api; -} - -function userIdKey(sessionId: string): string { - return `user-id:${sessionId}`; -} - -/** Returns the caller's Onshape user id, memoized in KV by session. */ -export async function getCachedUserId(c: AppContext): Promise { - const key = userIdKey(getSessionId(c)); - - const cached = await c.env.KV.get(key); - if (cached) return cached; - - const userId = await getUserId(await getOnshapeApi(c)); - await c.env.KV.put(key, userId, { expirationTtl: SESSION_TTL }); - return userId; -} - -export async function isAuthenticated(c: AppContext): Promise { - try { - const onshapeApi = await c.var.getOnshapeApi(); - const sessionInfo = await getSessionInfo(onshapeApi); - const tokenCompanyId = sessionInfo.company?.id ?? "cad"; - return getSessionCompanyId(c) === tokenCompanyId; - } catch { - return false; - } -} - /** * Stores the redirectUrl and state. * diff --git a/src/backend/features/auth/routes.test.ts b/src/backend/features/auth/routes.test.ts new file mode 100644 index 000000000..57cdcdfb3 --- /dev/null +++ b/src/backend/features/auth/routes.test.ts @@ -0,0 +1,48 @@ +import { env } from "cloudflare:workers"; +import { beforeEach, describe, expect, it } from "vitest"; +import { AccessLevel } from "./access-level"; +import { createTestApp, jsonRequest, resetDb } from "../../../__test_utils__"; +import { getDb } from "../../db/client"; + +const db = getDb(env.DB); + +describe("GET /access-data", () => { + beforeEach(async () => { + await resetDb(db); + }); + + it("returns the caller's access level", async () => { + const app = createTestApp({ accessLevel: AccessLevel.EDITOR }); + + const res = await app.request( + "/api/access-data", + jsonRequest("GET"), + env + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + maxAccessLevel: AccessLevel.EDITOR, + signedIn: true + }); + }); + + it("reports signedIn: false when not signed in", async () => { + const app = createTestApp({ + signedIn: false, + accessLevel: AccessLevel.USER + }); + + const res = await app.request( + "/api/access-data", + jsonRequest("GET"), + env + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + maxAccessLevel: AccessLevel.USER, + signedIn: false + }); + }); +}); diff --git a/src/backend/features/auth/routes.ts b/src/backend/features/auth/routes.ts index 3c9fdc1ab..aab7870e1 100644 --- a/src/backend/features/auth/routes.ts +++ b/src/backend/features/auth/routes.ts @@ -1,10 +1,25 @@ import { HttpStatus } from "http-status-ts"; import { HTTPException } from "hono/http-exception"; import { getApp } from "../../lib/context"; +import { cacheMiddleware } from "../../lib/cache"; +import { type AccessData } from "./access-level"; +import { isSignedIn } from "./caller"; import { doCallback, doSignIn } from "./onshape-oauth"; +/** The OAuth redirects, mounted at /auth. */ export const authRoutes = getApp(); +/** What the app needs to know about the caller, mounted at /api. */ +export const accessRoutes = getApp(); + +/** GET /api/access-data */ +accessRoutes.get("/access-data", cacheMiddleware(), async (c) => { + return c.json({ + maxAccessLevel: await c.var.getAccessLevel(), + signedIn: await isSignedIn(c) + } satisfies AccessData); +}); + authRoutes.get("/sign-in", async (c) => { const query = c.req.query(); diff --git a/src/backend/features/auth/services.ts b/src/backend/features/auth/services.ts deleted file mode 100644 index 72407c96c..000000000 --- a/src/backend/features/auth/services.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { AppServicesFactory } from "../../lib/context"; -import { - getCachedUserId, - getOnshapeApi, - isAuthenticated -} from "./onshape-oauth"; -import { getCachedAccessLevel } from "./access-control"; -import { isForceSignedIn, isSignedIn } from "./sign-in"; -import { AccessLevel } from "./access-level"; - -/** Stable fake user id used for FORCE_SIGNED_IN testing sessions. */ -export const FORCE_SIGNED_IN_USER_ID = "force-signed-in-user"; - -/** - * Production dependency wiring, memoizing the Onshape lookups in KV by session. - * getUserId only runs behind requireSignInMiddleware; getAccessLevel falls back to USER. - */ -export const productionServices: AppServicesFactory = (c) => ({ - getOnshapeApi: () => getOnshapeApi(c), - getUserId: () => { - // FORCE_SIGNED_IN has no real Onshape session; use a stable fake id. - if (isForceSignedIn(c)) { - return Promise.resolve(FORCE_SIGNED_IN_USER_ID); - } - return getCachedUserId(c); - }, - getAccessLevel: async () => { - const override = c.env.ACCESS_LEVEL_OVERRIDE; - if (override) return override as AccessLevel; - // 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))) { - return getCachedAccessLevel(c); - } - return AccessLevel.USER; - }, - isAuthenticated: () => isAuthenticated(c) -}); diff --git a/src/backend/features/auth/sign-in.ts b/src/backend/features/auth/sign-in.ts deleted file mode 100644 index a7e284bff..000000000 --- a/src/backend/features/auth/sign-in.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { MiddlewareHandler } from "hono"; -import { HTTPException } from "hono/http-exception"; -import { HttpStatus } from "http-status-ts"; -import { env } from "process"; -import type { AppContext, AppContextEnv } from "../../lib/context"; - -/** FORCE_SIGNED_IN is a dev-only escape hatch, ignored in production. */ -export function isForceSignedIn(c: AppContext): boolean { - return !!c.env.FORCE_SIGNED_IN && env.NODE_ENV !== "production"; -} - -/** - * Whether the caller has a valid Onshape session, memoized on the request. - * `FORCE_SIGNED_IN` forces it true for testing (see services.ts). - */ -export async function isSignedIn(c: AppContext): Promise { - const cached = c.get("signedIn"); - if (cached !== undefined) return cached; - - let signedIn: boolean; - if (isForceSignedIn(c)) { - signedIn = true; - } else { - try { - await c.var.getOnshapeApi(); - signedIn = true; - } catch { - signedIn = false; - } - } - - c.set("signedIn", signedIn); - return signedIn; -} - -/** - * Middleware which requires the caller to be signed in to Onshape. - */ -export const requireSignInMiddleware: MiddlewareHandler = async ( - c, - next -) => { - if (!(await isSignedIn(c))) { - throw new HTTPException(HttpStatus.UNAUTHORIZED, { - message: - "You must be signed in to Onshape to use this functionality" - }); - } - await next(); -}; diff --git a/src/backend/features/build-checker/routes.ts b/src/backend/features/build-checker/routes.ts index 96f7c06e5..a379cd246 100644 --- a/src/backend/features/build-checker/routes.ts +++ b/src/backend/features/build-checker/routes.ts @@ -3,7 +3,7 @@ import { CachePolicy, cacheMiddleware } from "../../lib/cache"; import { getApp } from "../../lib/context"; import { getLibraryParam, libraryRoute } from "../../lib/route-params"; import { getDb } from "../../db/client"; -import { requireEditorMiddleware } from "../auth/access-control"; +import { requireEditorMiddleware } from "../auth/guards"; import { group, insertables, configurations } from "../../db/schema"; import type { LibraryBuildStatus, diff --git a/src/backend/app.test.ts b/src/backend/features/entry/routes.test.ts similarity index 93% rename from src/backend/app.test.ts rename to src/backend/features/entry/routes.test.ts index 254d29087..07a28549b 100644 --- a/src/backend/app.test.ts +++ b/src/backend/features/entry/routes.test.ts @@ -1,17 +1,17 @@ import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it } from "vitest"; import { eq } from "drizzle-orm"; -import { users } from "./db/schema"; -import { LibraryId } from "./features/library/library-id"; -import { Theme } from "./features/users/settings"; +import { users } from "../../db/schema"; +import { LibraryId } from "../library/library-id"; +import { Theme } from "../settings/settings"; import { TEST_USER_ID, createTestApp, jsonRequest, resetDb, seedUser -} from "../__test_utils__"; -import { getDb } from "./db/client"; +} from "../../../__test_utils__"; +import { getDb } from "../../db/client"; const db = getDb(env.DB); diff --git a/src/backend/features/entry/routes.ts b/src/backend/features/entry/routes.ts new file mode 100644 index 000000000..1cb3ef210 --- /dev/null +++ b/src/backend/features/entry/routes.ts @@ -0,0 +1,51 @@ +/** + * `/init` is where Onshape lands. It gates on auth, then resumes the caller in + * the library and theme they last used. + */ +import { eq } from "drizzle-orm"; +import { getDb } from "../../db/client"; +import { users } from "../../db/schema"; +import { cacheMiddleware } from "../../lib/cache"; +import { getApp, type AppContext } from "../../lib/context"; +import { getSessionCompanyId } from "../auth/session"; +import { DEFAULT_LIBRARY_ID } from "../library/library-id"; +import { DEFAULT_SETTINGS } from "../settings/settings"; + +/** Cloudflare strips the port in local dev, so redirect back relatively. */ +function getRelativeUrl(requestUrl: string) { + const { pathname, search } = new URL(requestUrl); + return pathname + search; +} + +/** Builds the url the caller resumes at, seeded with the library and theme they last used. */ +async function getEntryUrl(c: AppContext): Promise { + const db = getDb(c.env.DB); + const user = await db + .select({ libraryId: users.libraryId, theme: users.theme }) + .from(users) + .where(eq(users.id, await c.var.getUserId())) + .get(); + + const search = new URL(c.req.url).searchParams; + const systemTheme = search.get("theme"); + if (systemTheme !== null) { + search.set("systemTheme", systemTheme); + } + search.set("theme", user?.theme ?? DEFAULT_SETTINGS.theme); + + const libraryId = user?.libraryId ?? DEFAULT_LIBRARY_ID; + return `/app/library/${libraryId}?${search.toString()}`; +} + +export const entryRoutes = getApp(); + +/** GET /init */ +entryRoutes.get("/init", cacheMiddleware(), async (c) => { + if (!(await c.var.isAuthenticated())) { + const redirectUrl = encodeURIComponent(getRelativeUrl(c.req.url)); + return c.redirect( + `/auth/sign-in?redirectUrl=${redirectUrl}&sessionCompanyId=${getSessionCompanyId(c)}` + ); + } + return c.redirect(await getEntryUrl(c)); +}); diff --git a/src/backend/features/favorites/routes.ts b/src/backend/features/favorites/routes.ts index 0bbda450f..bc4f5cfd5 100644 --- a/src/backend/features/favorites/routes.ts +++ b/src/backend/features/favorites/routes.ts @@ -8,7 +8,7 @@ import type { Favorite, FavoritesData } from "./dto"; import type { LibraryId } from "../library/library-id"; import { HttpStatus } from "http-status-ts"; import { type ParameterValues } from "../configurations/models"; -import { requireSignInMiddleware } from "../auth/sign-in"; +import { requireSignInMiddleware } from "../auth/guards"; export const favoriteRoutes = getApp(); diff --git a/src/backend/features/library/groups/routes.ts b/src/backend/features/library/groups/routes.ts index 6d93e19cb..3294aff7b 100644 --- a/src/backend/features/library/groups/routes.ts +++ b/src/backend/features/library/groups/routes.ts @@ -5,7 +5,7 @@ import { getLibraryParam, libraryRoute } from "../../../lib/route-params"; import { getDb } from "../../../db/client"; import { getSessionId } from "../../auth/session"; import { getDocument } from "../../../lib/onshape/endpoints/documents"; -import { requireEditorMiddleware } from "../../auth/access-control"; +import { requireEditorMiddleware } from "../../auth/guards"; import { type DocumentPath } from "../../../lib/onshape/path"; import { group, insertables, libraries, favorites } from "../../../db/schema"; import { bumpLibraryVersion, rebuildSearchDb } from "../db"; diff --git a/src/backend/features/library/insertables/routes.ts b/src/backend/features/library/insertables/routes.ts index 103864b2a..7e9747ae3 100644 --- a/src/backend/features/library/insertables/routes.ts +++ b/src/backend/features/library/insertables/routes.ts @@ -6,8 +6,8 @@ import z from "zod"; import { getApp } from "../../../lib/context"; import { getInsertableParam, insertableRoute } from "../../../lib/route-params"; import { getDb, type Db } from "../../../db/client"; -import { requireEditorMiddleware } from "../../auth/access-control"; -import { requireSignInMiddleware } from "../../auth/sign-in"; +import { requireEditorMiddleware } from "../../auth/guards"; +import { requireSignInMiddleware } from "../../auth/guards"; import { insertables, configurations } from "../../../db/schema"; import { bumpLibraryVersion, rebuildSearchDb } from "../db"; import { type ElementPath, INSTANCE_TYPES } from "../../../lib/onshape/path"; diff --git a/src/backend/features/load/context.ts b/src/backend/features/load/context.ts index 73498acdd..7c14b5e46 100644 --- a/src/backend/features/load/context.ts +++ b/src/backend/features/load/context.ts @@ -1,6 +1,6 @@ import type { WorkflowStep } from "cloudflare:workers"; import type { AppBindings } from "../../lib/context"; -import { getOnshapeApiFromSessionId } from "../auth/onshape-oauth"; +import { getOnshapeApiFromSessionId } from "../auth/caller"; import type { OnshapeApi } from "../../lib/onshape/client"; import type { ElementType } from "../../lib/onshape/element-type"; import type { LibraryId } from "../library/library-id"; diff --git a/src/backend/features/users/routes.test.ts b/src/backend/features/settings/routes.test.ts similarity index 54% rename from src/backend/features/users/routes.test.ts rename to src/backend/features/settings/routes.test.ts index 09aa3399d..7fe8f5c3a 100644 --- a/src/backend/features/users/routes.test.ts +++ b/src/backend/features/settings/routes.test.ts @@ -2,45 +2,23 @@ import { eq } from "drizzle-orm"; import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it } from "vitest"; import { users } from "../../db/schema"; -import { AccessLevel } from "../auth/access-level"; + import { Theme } from "./settings"; import { TEST_USER_ID, createTestApp, jsonRequest, - resetDb, - seedLibrary, - seedUser + resetDb } from "../../../__test_utils__"; import { getDb } from "../../db/client"; const db = getDb(env.DB); -describe("user routes", () => { +describe("settings routes", () => { beforeEach(async () => { await resetDb(db); }); - it("GET /access-data returns the caller's access level", async () => { - await seedLibrary(db); - await seedUser(db); - const app = createTestApp({ accessLevel: AccessLevel.ADMIN }); - - const res = await app.request( - "/api/access-data", - jsonRequest("GET"), - env - ); - expect(res.status).toBe(200); - - expect(await res.json()).toEqual({ - maxAccessLevel: AccessLevel.ADMIN, - signedIn: true - }); - // Per-user, and Workers Cache keys ignore cookies. - expect(res.headers.get("Cache-Control")).toBe("private, no-store"); - }); - it("POST /user-data updates the user's settings", async () => { const app = createTestApp(); diff --git a/src/backend/features/users/routes.ts b/src/backend/features/settings/routes.ts similarity index 50% rename from src/backend/features/users/routes.ts rename to src/backend/features/settings/routes.ts index ee27fe8d7..a8d688d29 100644 --- a/src/backend/features/users/routes.ts +++ b/src/backend/features/settings/routes.ts @@ -1,24 +1,14 @@ import { eq } from "drizzle-orm"; -import { cacheMiddleware } from "../../lib/cache"; import { getApp } from "../../lib/context"; import { getDb } from "../../db/client"; import { users } from "../../db/schema"; -import type { AccessData } from "../auth/access-level"; +import { requireSignInMiddleware } from "../auth/guards"; import type { SettingsUpdate } from "./settings"; -import { isSignedIn, requireSignInMiddleware } from "../auth/sign-in"; -export const userRoutes = getApp(); +export const settingsRoutes = getApp(); -/** GET /api/access-data */ -userRoutes.get("/access-data", cacheMiddleware(), async (c) => { - return c.json({ - maxAccessLevel: await c.var.getAccessLevel(), - signedIn: await isSignedIn(c) - } satisfies AccessData); -}); - -/** POST /api/user-data — update settings */ -userRoutes.post("/user-data", requireSignInMiddleware, async (c) => { +/** POST /api/user-data — update the caller's stored settings */ +settingsRoutes.post("/user-data", requireSignInMiddleware, async (c) => { const userId = await c.var.getUserId(); const body = await c.req.json(); diff --git a/src/backend/features/users/settings.ts b/src/backend/features/settings/settings.ts similarity index 100% rename from src/backend/features/users/settings.ts rename to src/backend/features/settings/settings.ts diff --git a/src/backend/features/thumbnails/routes.ts b/src/backend/features/thumbnails/routes.ts index 763566938..a22aead76 100644 --- a/src/backend/features/thumbnails/routes.ts +++ b/src/backend/features/thumbnails/routes.ts @@ -6,7 +6,7 @@ import { getApp } from "../../lib/context"; import { getInsertableParam, insertableRoute } from "../../lib/route-params"; import { getInsertableElementPath } from "../library/insertables/routes"; import { getDb } from "../../db/client"; -import { requireEditorMiddleware } from "../auth/access-control"; +import { requireEditorMiddleware } from "../auth/guards"; import { bumpLibraryVersion } from "../library/db"; import { type InstancePath } from "../../lib/onshape/path"; diff --git a/src/backend/index.ts b/src/backend/index.ts index b212f5781..4fc3f2b61 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -4,6 +4,6 @@ export { } from "./features/load/workflows"; export { ThumbnailWorkflow } from "./features/thumbnails/workflow"; import { createApp } from "./app"; -import { productionServices } from "./features/auth/services"; +import { productionCaller } from "./features/auth/caller"; -export default createApp(productionServices); +export default createApp(productionCaller); diff --git a/src/backend/lib/context.ts b/src/backend/lib/context.ts index 642245979..025817d67 100644 --- a/src/backend/lib/context.ts +++ b/src/backend/lib/context.ts @@ -1,4 +1,4 @@ -import { type Context, Hono } from "hono"; +import { type Context, type MiddlewareHandler, Hono } from "hono"; import type { AddGroupParams, LoadLibraryParams @@ -24,13 +24,13 @@ export interface AppBindings { } interface AppVariables { - /** Internal cache for {@link getOnshapeApi} in features/auth/onshape-oauth.ts. */ + /** Internal cache for `getOnshapeApi` in features/auth/caller.ts. */ onshapeApi?: OAuthApi; - /** Internal cache for isSignedIn in features/auth/sign-in.ts. */ + /** Internal cache for `isSignedIn` in features/auth/caller.ts. */ signedIn?: boolean; /** Set by `setCacheTtl`; read by `cacheMiddleware`. */ cacheTtl?: number; - /** Injected getters — see {@link AppServices} / `createApp`. */ + /** Injected by {@link bindCaller}; see {@link Caller}. */ getOnshapeApi: () => Promise; getUserId: () => Promise; getAccessLevel: () => Promise; @@ -45,16 +45,31 @@ export interface AppContextEnv { export type AppContext = Context; /** - * Per-request dependencies injected into the app. + * Who is making the request, injected per request so tests can substitute a + * caller without an Onshape session. `productionCaller` is the real one. */ -export interface AppServices { +export interface Caller { getOnshapeApi: () => Promise; getUserId: () => Promise; getAccessLevel: () => Promise; isAuthenticated: () => Promise; } -export type AppServicesFactory = (c: AppContext) => AppServices; +export type CallerFactory = (c: AppContext) => Caller; + +/** Binds the caller's lookups onto each request, behind `c.var`. */ +export function bindCaller( + makeCaller: CallerFactory +): MiddlewareHandler { + return async (c, next) => { + const caller = makeCaller(c); + c.set("getOnshapeApi", caller.getOnshapeApi); + c.set("getUserId", caller.getUserId); + c.set("getAccessLevel", caller.getAccessLevel); + c.set("isAuthenticated", caller.isAuthenticated); + await next(); + }; +} export function getApp() { return new Hono(); diff --git a/src/backend/lib/errors.ts b/src/backend/lib/errors.ts new file mode 100644 index 000000000..94d57f719 --- /dev/null +++ b/src/backend/lib/errors.ts @@ -0,0 +1,27 @@ +import type { ErrorHandler } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { HttpStatus } from "http-status-ts"; +import { OnshapeRateLimitError } from "./onshape/client"; +import type { AppContextEnv } from "./context"; + +export const errorHandler: ErrorHandler = (err, c) => { + // Surface an Onshape rate limit as a 429 the client can retry, rather + // than blocking the request thread waiting it out. + if (err instanceof OnshapeRateLimitError) { + return c.json( + { + error: "Onshape rate limit reached. Please try again shortly.", + retryAfterSeconds: err.retryAfterSeconds + }, + HttpStatus.TOO_MANY_REQUESTS + ); + } + if (err instanceof HTTPException) { + return err.getResponse(); + } + console.error(err); + return c.json( + { error: "Internal Server Error" }, + HttpStatus.INTERNAL_SERVER_ERROR + ); +}; diff --git a/src/frontend/features/settings/components/settings-menu.tsx b/src/frontend/features/settings/components/settings-menu.tsx index b867a4c94..fbdd57a5e 100644 --- a/src/frontend/features/settings/components/settings-menu.tsx +++ b/src/frontend/features/settings/components/settings-menu.tsx @@ -1,10 +1,10 @@ import { useNavigate, useRouterState } from "@tanstack/react-router"; -import { DEFAULT_SETTINGS } from "@backend/features/users/settings"; +import { DEFAULT_SETTINGS } from "@backend/features/settings/settings"; import { Divider, Group, Text, Title } from "@mantine/core"; import { modals } from "@mantine/modals"; import { FontWeight } from "../../../lib/style-constants"; import { Dispatch, ReactNode, useMemo } from "react"; -import { Theme } from "@backend/features/users/settings"; +import { Theme } from "@backend/features/settings/settings"; import { hasEditorAccess } from "@backend/features/auth/access-level"; import { isWithinAccessLevel } from "@backend/features/auth/access-level"; import { AccessLevel } from "@backend/features/auth/access-level"; diff --git a/src/frontend/features/settings/local-settings.ts b/src/frontend/features/settings/local-settings.ts index 32159041c..6f4d171a1 100644 --- a/src/frontend/features/settings/local-settings.ts +++ b/src/frontend/features/settings/local-settings.ts @@ -6,7 +6,7 @@ import { DEFAULT_SETTINGS, type SettingsUpdate, type Theme -} from "@backend/features/users/settings"; +} from "@backend/features/settings/settings"; const SETTINGS_STORAGE_KEY = "frc-design-app-settings"; diff --git a/src/frontend/features/settings/settings.ts b/src/frontend/features/settings/settings.ts index 8e446a056..ba86815d2 100644 --- a/src/frontend/features/settings/settings.ts +++ b/src/frontend/features/settings/settings.ts @@ -1,5 +1,5 @@ import { useMutation } from "@tanstack/react-query"; -import type { SettingsUpdate } from "@backend/features/users/settings"; +import type { SettingsUpdate } from "@backend/features/settings/settings"; import { showErrorToast } from "../../lib/notifications"; import { apiPost } from "../../lib/api-client"; import { useIsSignedIn } from "../auth/access-level"; diff --git a/src/frontend/lib/onshape-params.ts b/src/frontend/lib/onshape-params.ts index 36c7c221a..7f7d9db33 100644 --- a/src/frontend/lib/onshape-params.ts +++ b/src/frontend/lib/onshape-params.ts @@ -1,6 +1,6 @@ import { useSearch } from "@tanstack/react-router"; import { ElementType } from "@backend/lib/onshape/element-type"; -import { Theme } from "@backend/features/users/settings"; +import { Theme } from "@backend/features/settings/settings"; import { ElementPath, isElementPath } from "@backend/lib/onshape/path"; /** diff --git a/src/frontend/routes/__root.tsx b/src/frontend/routes/__root.tsx index a76b120e4..f94f7cfa8 100644 --- a/src/frontend/routes/__root.tsx +++ b/src/frontend/routes/__root.tsx @@ -13,7 +13,7 @@ import { queryClient } from "../lib/query-client"; import { createAppTheme } from "../theme"; import { getColorTheme } from "../lib/onshape-params"; import { DEFAULT_LIBRARY_ID } from "@backend/features/library/library-id"; -import { DEFAULT_SETTINGS } from "@backend/features/users/settings"; +import { DEFAULT_SETTINGS } from "@backend/features/settings/settings"; import { NotFoundError, RootCrash } from "../components/root-error"; export const Route = createRootRoute({ From ba50b44502096bcfc3a39802bf43c304d9e424eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 20:59:34 +0000 Subject: [PATCH 08/56] refactor: rename POST /api/user-data to /api/settings Matches the feature that serves it and what it actually updates. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- src/backend/features/auth/guards.test.ts | 2 +- src/backend/features/settings/routes.test.ts | 4 ++-- src/backend/features/settings/routes.ts | 4 ++-- src/frontend/features/settings/settings.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/backend/features/auth/guards.test.ts b/src/backend/features/auth/guards.test.ts index 7d21ec834..d5a707ff3 100644 --- a/src/backend/features/auth/guards.test.ts +++ b/src/backend/features/auth/guards.test.ts @@ -28,7 +28,7 @@ describe("requireSignInMiddleware", () => { expect(favorites.status).toBe(401); const userData = await app.request( - "/api/user-data", + "/api/settings", jsonRequest("POST", { theme: Theme.DARK }), env ); diff --git a/src/backend/features/settings/routes.test.ts b/src/backend/features/settings/routes.test.ts index 7fe8f5c3a..4589bb980 100644 --- a/src/backend/features/settings/routes.test.ts +++ b/src/backend/features/settings/routes.test.ts @@ -19,11 +19,11 @@ describe("settings routes", () => { await resetDb(db); }); - it("POST /user-data updates the user's settings", async () => { + it("POST /settings updates the caller's settings", async () => { const app = createTestApp(); const res = await app.request( - "/api/user-data", + "/api/settings", jsonRequest("POST", { theme: Theme.DARK }), env ); diff --git a/src/backend/features/settings/routes.ts b/src/backend/features/settings/routes.ts index a8d688d29..2934789e4 100644 --- a/src/backend/features/settings/routes.ts +++ b/src/backend/features/settings/routes.ts @@ -7,8 +7,8 @@ import type { SettingsUpdate } from "./settings"; export const settingsRoutes = getApp(); -/** POST /api/user-data — update the caller's stored settings */ -settingsRoutes.post("/user-data", requireSignInMiddleware, async (c) => { +/** POST /api/settings — update the caller's stored settings */ +settingsRoutes.post("/settings", requireSignInMiddleware, async (c) => { const userId = await c.var.getUserId(); const body = await c.req.json(); diff --git a/src/frontend/features/settings/settings.ts b/src/frontend/features/settings/settings.ts index ba86815d2..7229507ae 100644 --- a/src/frontend/features/settings/settings.ts +++ b/src/frontend/features/settings/settings.ts @@ -9,14 +9,14 @@ export function useSaveSettings() { const isSignedIn = useIsSignedIn(); const { mutate } = useMutation({ - mutationKey: ["user-data"], + mutationKey: ["settings"], mutationFn: async (newSettings: SettingsUpdate) => { // Not signed in: no server-side user row; persist locally instead. if (!isSignedIn) { writeLocalSettings(newSettings); return; } - return apiPost("/user-data", { body: newSettings }); + return apiPost("/settings", { body: newSettings }); }, onError: () => { showErrorToast("Unexpectedly failed to update settings."); From e25293d1a545d2f6e78adba6a2faefa868df72ce Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 21:33:30 +0000 Subject: [PATCH 09/56] refactor: express configurability as a boolean, not a duplicated id configurations.id is a 1:1 FK to insertables.id, so LibraryOut's configurationId was always just the insertable's own id: a boolean wearing an id costume. Every consumer either truthiness-tested it or passed it straight back as the id. InsertableOut now carries isConfigurable, and callers fetch by insertable id. The route param follows suit (/api/configuration/:insertableId), and the configuration query keys gain insertableConfigurationQueryMatchKey for the prefix match insert-menu was spelling inline. Adds tests pinning the behavior this rests on: the library response marks configurability but carries no parameters or records, which stay in D1 until an insertable actually needs them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- src/backend/features/configurations/routes.ts | 12 +++--- src/backend/features/library/db.ts | 9 +++-- src/backend/features/library/dto.ts | 3 +- src/backend/features/library/routes.test.ts | 38 ++++++++++++++++++- .../favorites/components/favorite-card.tsx | 2 +- .../favorites/components/favorite-menu.tsx | 4 +- .../insert/components/configurations.tsx | 8 ++-- .../insert/components/insert-menu.tsx | 7 ++-- src/frontend/features/search/search.test.ts | 1 + src/frontend/lib/query-keys.ts | 15 ++++++-- 10 files changed, 73 insertions(+), 26 deletions(-) diff --git a/src/backend/features/configurations/routes.ts b/src/backend/features/configurations/routes.ts index 633ee0e50..321e9cbe3 100644 --- a/src/backend/features/configurations/routes.ts +++ b/src/backend/features/configurations/routes.ts @@ -13,15 +13,15 @@ import { HttpStatus } from "http-status-ts"; export const configurationRoutes = getApp(); -/** GET /api/configuration/:configurationId?v=:microversionId */ +/** GET /api/configuration/:insertableId?v=:microversionId — parameters and records */ configurationRoutes.get( - "/configuration/:configurationId", + "/configuration/:insertableId", cacheMiddleware(CachePolicy.PUBLIC_CACHE), async (c) => { - const configurationId = c.req.param("configurationId"); - if (!configurationId) { + const insertableId = c.req.param("insertableId"); + if (!insertableId) { throw new HTTPException(HttpStatus.BAD_REQUEST, { - message: "configurationId is required" + message: "insertableId is required" }); } @@ -32,7 +32,7 @@ configurationRoutes.get( records: configurations.records }) .from(configurations) - .where(eq(configurations.id, configurationId)) + .where(eq(configurations.id, insertableId)) .get(); if (!config) { diff --git a/src/backend/features/library/db.ts b/src/backend/features/library/db.ts index bfdc693de..ecca62838 100644 --- a/src/backend/features/library/db.ts +++ b/src/backend/features/library/db.ts @@ -34,8 +34,9 @@ export async function getLibraryOut( .where(eq(insertables.libraryId, libraryId)) .orderBy(asc(insertables.sortOrder)) .all(), - // A row can exist just to hold records, so "configurable" keys on - // having parameters. Tested in SQL to leave the payload in D1. + // Which insertables are configurable. A row can exist just to hold + // records, so this keys on having parameters — tested in SQL so the + // payload stays in D1 and is fetched per insertable when needed. db .select({ id: configurations.id }) .from(configurations) @@ -49,7 +50,7 @@ export async function getLibraryOut( .all() ]); - const configSet = new Set(allConfigurations.map((c) => c.id)); + const configurableIds = new Set(allConfigurations.map((c) => c.id)); const groupsOut: Groups = {}; for (const group of allGroups) { @@ -96,7 +97,7 @@ export async function getLibraryOut( elementType: ins.elementType, smallThumbnailUrl: ins.smallThumbnailUrl ?? undefined, largeThumbnailUrl: ins.largeThumbnailUrl ?? undefined, - configurationId: configSet.has(ins.id) ? ins.id : undefined, + isConfigurable: configurableIds.has(ins.id), vendors: ins.vendors } satisfies InsertableOut; } diff --git a/src/backend/features/library/dto.ts b/src/backend/features/library/dto.ts index 4a0087d46..4e3fefbc1 100644 --- a/src/backend/features/library/dto.ts +++ b/src/backend/features/library/dto.ts @@ -16,7 +16,8 @@ export interface InsertableOut { elementType: ElementType; smallThumbnailUrl?: string; largeThumbnailUrl?: string; - configurationId?: string; + /** Whether it has configuration parameters; they are fetched by `id`. */ + isConfigurable: boolean; vendors: Vendor[]; } diff --git a/src/backend/features/library/routes.test.ts b/src/backend/features/library/routes.test.ts index 1ef29282e..6ea1c6cc1 100644 --- a/src/backend/features/library/routes.test.ts +++ b/src/backend/features/library/routes.test.ts @@ -9,6 +9,7 @@ import { resetDb, seedTestData, seedConfiguration, + TEST_PARAMETERS, seedLibrary } from "../../../__test_utils__"; import { getDb } from "../../db/client"; @@ -41,11 +42,44 @@ describe("library routes", () => { expect(body.groupOrder).toContain(TEST_GROUP_ID); expect(Object.keys(body.insertables)).toContain(TEST_PART_STUDIO_ID); - expect(body.insertables[TEST_PART_STUDIO_ID].configurationId).toBe( - TEST_PART_STUDIO_ID + expect(body.insertables[TEST_PART_STUDIO_ID].isConfigurable).toBe(true); + }); + + it("marks an insertable with no parameters as not configurable", async () => { + await seedTestData(db); + const app = createTestApp(); + + const res = await app.request( + `/api/library-data/library/${TEST_LIBRARY_ID}?v=1`, + jsonRequest("GET"), + env + ); + const body: LibraryOut = await res.json(); + + expect(body.insertables[TEST_PART_STUDIO_ID].isConfigurable).toBe( + false ); }); + it("carries no configuration payload; it is fetched per insertable", async () => { + await seedTestData(db); + await seedConfiguration(db, TEST_PART_STUDIO_ID); + const app = createTestApp(); + + const res = await app.request( + `/api/library-data/library/${TEST_LIBRARY_ID}?v=1`, + jsonRequest("GET"), + env + ); + const body = await res.text(); + + // The seeded parameter's name is distinctive; the payload stays in D1 + // and is fetched from /api/configuration/:insertableId when needed. + expect(body).not.toContain(TEST_PARAMETERS[0].name); + expect(body).not.toContain("parameters"); + expect(body).not.toContain("records"); + }); + it("GET /search-db serves the library's index from R2 as plain JSON", async () => { await seedTestData(db); await seedConfiguration(db, TEST_PART_STUDIO_ID); diff --git a/src/frontend/features/favorites/components/favorite-card.tsx b/src/frontend/features/favorites/components/favorite-card.tsx index 1818ae94e..25108bee8 100644 --- a/src/frontend/features/favorites/components/favorite-card.tsx +++ b/src/frontend/features/favorites/components/favorite-card.tsx @@ -130,7 +130,7 @@ function FavoriteMenuItems(props: FavoriteMenuItemsProps): ReactNode { } onClick={() => { - if (insertable.configurationId === undefined) { + if (!insertable.isConfigurable) { openCannotEditDefaultConfigurationAlert(); return; } diff --git a/src/frontend/features/favorites/components/favorite-menu.tsx b/src/frontend/features/favorites/components/favorite-menu.tsx index 452a1e72b..c994599d6 100644 --- a/src/frontend/features/favorites/components/favorite-menu.tsx +++ b/src/frontend/features/favorites/components/favorite-menu.tsx @@ -158,7 +158,7 @@ function FavoriteMenuContent(props: FavoriteMenuContentProps): ReactNode { if (!insertable) { return null; } - if (!insertable.configurationId) { + if (!insertable.isConfigurable) { return ( diff --git a/src/frontend/features/insert/components/configurations.tsx b/src/frontend/features/insert/components/configurations.tsx index 527b97712..2de2d5819 100644 --- a/src/frontend/features/insert/components/configurations.tsx +++ b/src/frontend/features/insert/components/configurations.tsx @@ -53,7 +53,7 @@ import { SectionError } from "../../../components/app-zero-state"; import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; interface ConfigurationWrapperProps { - configurationId: string; + insertableId: string; microversionId: string; configuration?: ParameterValues; setConfiguration: Dispatch; @@ -70,7 +70,7 @@ interface ConfigurationWrapperProps { export function ConfigurationWrapper(props: ConfigurationWrapperProps) { const { - configurationId, + insertableId, microversionId, configuration, setConfiguration, @@ -79,9 +79,9 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { } = props; const query = useQuery({ - queryKey: configurationQueryKey(configurationId, microversionId), + queryKey: configurationQueryKey(insertableId, microversionId), queryFn: async () => { - return apiGet("/configuration/" + configurationId, { + return apiGet("/configuration/" + insertableId, { cacheId: microversionId }); }, diff --git a/src/frontend/features/insert/components/insert-menu.tsx b/src/frontend/features/insert/components/insert-menu.tsx index b37734231..1a9ce387c 100644 --- a/src/frontend/features/insert/components/insert-menu.tsx +++ b/src/frontend/features/insert/components/insert-menu.tsx @@ -8,6 +8,7 @@ import { IconInfoCircle, IconPlus } from "@tabler/icons-react"; import { FontWeight, IconSize } from "../../../lib/style-constants"; import { modals } from "@mantine/modals"; import { useIsFetching } from "@tanstack/react-query"; +import { insertableConfigurationQueryMatchKey } from "../../../lib/query-keys"; import { PreviewImageCard } from "../../thumbnails/components/thumbnail"; import { FavoriteButton } from "../../favorites/components/favorite-button"; import { @@ -137,10 +138,10 @@ function InsertMenuContent(props: InsertMenuContentProps): ReactNode { const favorite = getFavoriteForInsertable(favorites, insertable.id); let parameters: ReactNode = null; - if (insertable.configurationId) { + if (insertable.isConfigurable) { parameters = ( 0; const canFasten = diff --git a/src/frontend/features/search/search.test.ts b/src/frontend/features/search/search.test.ts index c22eb4339..2a53e8f54 100644 --- a/src/frontend/features/search/search.test.ts +++ b/src/frontend/features/search/search.test.ts @@ -114,6 +114,7 @@ function library(name = "Bracket"): LibraryOut { isVisible: true, supportsFasten: false, elementType: ElementType.PART_STUDIO, + isConfigurable: false, vendors: [] } } diff --git a/src/frontend/lib/query-keys.ts b/src/frontend/lib/query-keys.ts index 6f1b2cdd0..79f7dca8d 100644 --- a/src/frontend/lib/query-keys.ts +++ b/src/frontend/lib/query-keys.ts @@ -9,15 +9,24 @@ export function accessDataQueryKey() { return ["access-data"]; } +/** Every configuration, whichever insertable and microversion. */ export function configurationQueryMatchKey() { return ["configuration"]; } +/** One insertable's configuration, whichever microversion is cached. */ +export function insertableConfigurationQueryMatchKey(insertableId: string) { + return ["configuration", insertableId]; +} + export function configurationQueryKey( - configurationId?: string, - microversionId?: string + insertableId: string, + microversionId: string ) { - return ["configuration", configurationId, microversionId]; + return [ + ...insertableConfigurationQueryMatchKey(insertableId), + microversionId + ]; } export function unitInfoQueryKey(instancePath: InstancePath) { From cdbc70301110ef915118e56f3d2b5552bbd894a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 22:55:09 +0000 Subject: [PATCH 10/56] refactor: store an element's own part data on the insertable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An element's part number, name and material were kept as the first entry of configurations.records — the probe of its default configuration. Every probed insertable therefore carried a configurations row, including ones with no parameters to configure, and "is it configurable?" had to test the parameter count rather than the row's existence. That part data is not a configuration of the element, it is the element, so it moves to insertables.part_data. configurations is left holding only configuration data: a row exists exactly when there are parameters, and isConfigurable is now just whether the row is there. ConfigurationRecord becomes PartData plus the configuration that produced it, and toRecords() recomposes the full list — the element's own data as the record an unset configuration falls back to, then one per indexed configuration — for search and the configuration endpoint. Both paths are now covered by tests that fail without it. The migration backfills part_data from records[0] (always the default probe; see toResult), strips it from records, and drops the rows left with no parameters. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- docs/REFERENCE.md | 2 + drizzle/0006_split_part_data.sql | 22 + drizzle/meta/0006_snapshot.json | 538 ++++++++++++++++++ drizzle/meta/_journal.json | 101 ++-- src/__test_utils__/insertable-fixtures.ts | 1 + src/__test_utils__/seed.ts | 7 +- src/backend/db/schema.ts | 10 +- .../features/build-checker/checks.test.ts | 10 +- src/backend/features/build-checker/checks.ts | 16 +- src/backend/features/configurations/models.ts | 17 +- .../features/configurations/routes.test.ts | 35 ++ src/backend/features/configurations/routes.ts | 17 +- src/backend/features/configurations/utils.ts | 14 + src/backend/features/library/db.test.ts | 30 +- src/backend/features/library/db.ts | 30 +- .../library/insertables/routes.test.ts | 30 +- .../features/library/insertables/routes.ts | 11 +- .../features/load/load-insertable.test.ts | 46 +- src/backend/features/load/load-insertable.ts | 15 +- .../load/parse-configuration-records.test.ts | 44 +- .../load/parse-configuration-records.ts | 26 +- 21 files changed, 855 insertions(+), 167 deletions(-) create mode 100644 drizzle/0006_split_part_data.sql create mode 100644 drizzle/meta/0006_snapshot.json diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index e7355fb32..e93f61ea7 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -156,6 +156,8 @@ owns, `lib/` for cross-cutting plumbing, and a small set of files at the root. - `library/` — the library response (`db.ts`), its DTOs, and the groups and insertables endpoints - `load/` — everything that turns Onshape into what we store: the `parse-*` modules (document contents, configurations, configuration records, vendors, fasten info), the per-group and per-insertable loaders, the Workflows that drive them, their retry policies, and the job tracker - `configurations/` — the configuration domain the frontend shares: models, canonicalization, combination enumeration, and the input parser + + An element's own part number and material live on `insertables.part_data`; a `configurations` row exists exactly when the element has parameters to configure. - `thumbnails/` — rendering and R2 storage (`store.ts`), its Workflow, the routes, and the key and URL scheme the client shares - `build-checker/` — build issues, the checks that raise them, and the build-status endpoint - `favorites/`, `search/` diff --git a/drizzle/0006_split_part_data.sql b/drizzle/0006_split_part_data.sql new file mode 100644 index 000000000..163fcafb8 --- /dev/null +++ b/drizzle/0006_split_part_data.sql @@ -0,0 +1,22 @@ +/* + An element's own part number, name and material were stored as the first entry + of `configurations.records` — the probe of its default configuration. That made + every probed insertable carry a configurations row, even one with no parameters + to configure, and forced "is it configurable?" to test the parameter count. + + That part data moves to `insertables.part_data`, where it describes the element + rather than a configuration of it. `configurations` is left holding only real + configuration data, so a row exists exactly when there are parameters. + + The default record is always written first (see toResult), so `$[0]` is it. +*/ +ALTER TABLE `insertables` ADD `part_data` text;--> statement-breakpoint +UPDATE `insertables` SET `part_data` = ( + SELECT json_remove(json_extract(c.`records`, '$[0]'), '$.configuration') + FROM `configurations` c + WHERE c.`id` = `insertables`.`id` + AND json_array_length(c.`records`) > 0 +);--> statement-breakpoint +UPDATE `configurations` SET `records` = json_remove(`records`, '$[0]') + WHERE json_array_length(`records`) > 0;--> statement-breakpoint +DELETE FROM `configurations` WHERE json_array_length(`parameters`) = 0; diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 000000000..5798cfafd --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,538 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "2416035c-6b28-4014-a7b3-bdc33f803962", + "prevId": "0005b1c2-3d4e-4f50-9a6b-7c8d9e0f1a2b", + "tables": { + "configurations": { + "name": "configurations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parameters": { + "name": "parameters", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "records": { + "name": "records", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": {}, + "foreignKeys": { + "configurations_id_insertables_id_fk": { + "name": "configurations_id_insertables_id_fk", + "tableFrom": "configurations", + "tableTo": "insertables", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "favorites": { + "name": "favorites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "insertable_id": { + "name": "insertable_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_configuration": { + "name": "default_configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "favorites_user_id_library_id_insertable_id_unique": { + "name": "favorites_user_id_library_id_insertable_id_unique", + "columns": [ + "user_id", + "library_id", + "insertable_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "favorites_library_id_libraries_id_fk": { + "name": "favorites_library_id_libraries_id_fk", + "tableFrom": "favorites", + "tableTo": "libraries", + "columnsFrom": [ + "library_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "favorites_insertable_id_insertables_id_fk": { + "name": "favorites_insertable_id_insertables_id_fk", + "tableFrom": "favorites", + "tableTo": "insertables", + "columnsFrom": [ + "insertable_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groups": { + "name": "groups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_alphabetically": { + "name": "sort_alphabetically", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "small_thumbnail_url": { + "name": "small_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "large_thumbnail_url": { + "name": "large_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_loaded_at": { + "name": "last_loaded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "groups_document_id_library_id_unique": { + "name": "groups_document_id_library_id_unique", + "columns": [ + "document_id", + "library_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "groups_library_id_libraries_id_fk": { + "name": "groups_library_id_libraries_id_fk", + "tableFrom": "groups", + "tableTo": "libraries", + "columnsFrom": [ + "library_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "insertables": { + "name": "insertables", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_type": { + "name": "element_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "microversion_id": { + "name": "microversion_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_visible": { + "name": "is_visible", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_open_composite": { + "name": "is_open_composite", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "supports_fasten": { + "name": "supports_fasten", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "index_configurations": { + "name": "index_configurations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "vendors": { + "name": "vendors", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "small_thumbnail_url": { + "name": "small_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "large_thumbnail_url": { + "name": "large_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fasten_info": { + "name": "fasten_info", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "part_data": { + "name": "part_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_loaded_at": { + "name": "last_loaded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "insertables_group_id_groups_id_fk": { + "name": "insertables_group_id_groups_id_fk", + "tableFrom": "insertables", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "insertables_library_id_libraries_id_fk": { + "name": "insertables_library_id_libraries_id_fk", + "tableFrom": "insertables", + "tableTo": "libraries", + "columnsFrom": [ + "library_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "libraries": { + "name": "libraries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cache_version": { + "name": "cache_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'frc-design-lib'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9ea0bd3d8..391bafada 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -1,48 +1,55 @@ { - "version": "7", - "dialect": "sqlite", - "entries": [ - { - "idx": 0, - "version": "6", - "when": 1785982272297, - "tag": "0000_init", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1786201159430, - "tag": "0001_sad_arclight", - "breakpoints": true - }, - { - "idx": 2, - "version": "6", - "when": 1786201159431, - "tag": "0002_configuration_records", - "breakpoints": true - }, - { - "idx": 3, - "version": "6", - "when": 1786511719906, - "tag": "0003_drop_search_db", - "breakpoints": true - }, - { - "idx": 4, - "version": "6", - "when": 1786511719907, - "tag": "0004_explicit_thumbnail_urls", - "breakpoints": true - }, - { - "idx": 5, - "version": "6", - "when": 1786511719908, - "tag": "0005_index_configurations", - "breakpoints": true - } - ] -} + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1785982272297, + "tag": "0000_init", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1786201159430, + "tag": "0001_sad_arclight", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1786201159431, + "tag": "0002_configuration_records", + "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1786511719906, + "tag": "0003_drop_search_db", + "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1786511719907, + "tag": "0004_explicit_thumbnail_urls", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1786511719908, + "tag": "0005_index_configurations", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1787265951428, + "tag": "0006_split_part_data", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/src/__test_utils__/insertable-fixtures.ts b/src/__test_utils__/insertable-fixtures.ts index a61b9cb78..762b20b0d 100644 --- a/src/__test_utils__/insertable-fixtures.ts +++ b/src/__test_utils__/insertable-fixtures.ts @@ -39,6 +39,7 @@ export function parsedInsertable( fastenInfo: null, isOpenComposite: false, buildIssues: [], + partData: null, configuration: { parameters: [], records: [] }, ...overrides }; diff --git a/src/__test_utils__/seed.ts b/src/__test_utils__/seed.ts index 7d6466784..5db24a1e0 100644 --- a/src/__test_utils__/seed.ts +++ b/src/__test_utils__/seed.ts @@ -120,9 +120,12 @@ export async function seedInsertable( } /** Seeds the standard part-studio insertable (ensures library + group). */ -export async function seedPartStudio(db: Db): Promise { +export async function seedPartStudio( + db: Db, + overrides: Partial = {} +): Promise { await seedGroup(db); - return seedInsertable(db); + return seedInsertable(db, overrides); } /** Seeds the standard assembly insertable (ensures library + group). */ diff --git a/src/backend/db/schema.ts b/src/backend/db/schema.ts index fc97b5734..02e52a905 100644 --- a/src/backend/db/schema.ts +++ b/src/backend/db/schema.ts @@ -7,7 +7,8 @@ import { Vendor } from "../features/library/vendors"; import { ParameterValues, ConfigurationParameter, - ConfigurationRecord + ConfigurationRecord, + PartData } from "../features/configurations/models"; import { BuildIssue } from "../features/build-checker/issues"; @@ -93,6 +94,9 @@ export const insertables = sqliteTable("insertables", { fastenInfo: text("fasten_info", { mode: "json" }).$type(), + // The element's own part identity, probed from its defaults. Null until a + // probe succeeds; a configurable insertable left unindexed never gets one. + partData: text("part_data", { mode: "json" }).$type(), // Build-time issues flagged by the build checker, recomputed on reload. buildIssues: text("build_issues", { mode: "json" }) .$type() @@ -112,8 +116,8 @@ export const configurations = sqliteTable("configurations", { .$type() .notNull() .default([]), - // One record per configuration we probed (part number + metadata). Empty - // unless the insertable is indexed. Search dedupes these to a part-number map. + // One record per indexed configuration. Empty unless the insertable is + // indexed; the element's own part data lives on `insertables.partData`. records: text("records", { mode: "json" }) .$type() .notNull() diff --git a/src/backend/features/build-checker/checks.test.ts b/src/backend/features/build-checker/checks.test.ts index 16344d831..1a2fc1989 100644 --- a/src/backend/features/build-checker/checks.test.ts +++ b/src/backend/features/build-checker/checks.test.ts @@ -74,7 +74,7 @@ describe("checkInsertable", () => { const HEALTHY_INSERTABLE = { vendors: [Vendor.REV], thumbnailUrls: THUMBNAILS, - records: [record("217-2600")] + probed: [record("217-2600")] }; it("returns no issues when vendors are parsed and thumbnails generated", () => { @@ -100,7 +100,7 @@ describe("checkInsertable", () => { it("warns when a vendor part indexed without a part number", () => { const issues = checkInsertable({ ...HEALTHY_INSERTABLE, - records: [record(null), record(null)] + probed: [record(null), record(null)] }); expect(issues).toEqual([{ type: BuildIssueType.NO_PART_NUMBER }]); }); @@ -108,7 +108,7 @@ describe("checkInsertable", () => { it("does not warn when only some configurations lack one", () => { const issues = checkInsertable({ ...HEALTHY_INSERTABLE, - records: [record(null), record("217-2600")] + probed: [record(null), record("217-2600")] }); expect(issues).toEqual([]); }); @@ -118,14 +118,14 @@ describe("checkInsertable", () => { const issues = checkInsertable({ ...HEALTHY_INSERTABLE, vendors: [Vendor.CUSTOM], - records: [record(null)] + probed: [record(null)] }); expect(issues).toEqual([]); }); // Nothing was probed, so there is nothing to conclude. it("does not warn when the insertable is not indexed", () => { - const issues = checkInsertable({ ...HEALTHY_INSERTABLE, records: [] }); + const issues = checkInsertable({ ...HEALTHY_INSERTABLE, probed: [] }); expect(issues).toEqual([]); }); }); diff --git a/src/backend/features/build-checker/checks.ts b/src/backend/features/build-checker/checks.ts index d04b113f5..538ce0876 100644 --- a/src/backend/features/build-checker/checks.ts +++ b/src/backend/features/build-checker/checks.ts @@ -1,7 +1,7 @@ import { ThumbnailUrls } from "../thumbnails/types"; import { Vendor, isCustomPart } from "../library/vendors"; import { addBuildIssue, BuildIssue, BuildIssueType } from "./issues"; -import type { ConfigurationRecord } from "../configurations/models"; +import type { PartData } from "../configurations/models"; interface GroupCheckInput { /** Whether the Onshape document has a designated thumbnail tab/element. */ @@ -42,8 +42,8 @@ interface InsertableCheckInput { vendors: Vendor[]; /** The uploaded thumbnail URLs, or `null` when generation failed. */ thumbnailUrls: ThumbnailUrls | null; - /** Indexed configuration records; empty when the insertable isn't indexed. */ - records: ConfigurationRecord[]; + /** The element's own part data, plus one per indexed configuration. */ + probed: (PartData | null)[]; } /** @@ -65,7 +65,7 @@ export function checkInsertable(input: InsertableCheckInput): BuildIssue[] { issues = addBuildIssue( issues, - ...checkIndexedPartNumber(input.vendors, input.records) + ...checkIndexedPartNumber(input.vendors, input.probed) ); return issues; @@ -75,14 +75,16 @@ export function checkInsertable(input: InsertableCheckInput): BuildIssue[] { * A custom part is expected to have no part number; anything a vendor sells * should have one in at least one configuration. */ +/** `probed` is the element's own part data plus any indexed configuration's. */ export function checkIndexedPartNumber( vendors: Vendor[], - records: ConfigurationRecord[] + probed: (PartData | null)[] ): BuildIssue[] { - if (isCustomPart(vendors) || records.length === 0) { + const found = probed.filter((data) => data !== null); + if (isCustomPart(vendors) || found.length === 0) { return []; } - return records.some((record) => record.partNumber) + return found.some((data) => data.partNumber) ? [] : [{ type: BuildIssueType.NO_PART_NUMBER }]; } diff --git a/src/backend/features/configurations/models.ts b/src/backend/features/configurations/models.ts index 89d4cf9c2..148c9b24b 100644 --- a/src/backend/features/configurations/models.ts +++ b/src/backend/features/configurations/models.ts @@ -139,9 +139,12 @@ export type ParameterValues = Record; * What one probed configuration resolves to. Stored per probe, so search and the * UI can read it back without re-querying Onshape. */ -export interface ConfigurationRecord { - /** The parameter values that produce it; empty means the element's defaults. */ - configuration: ParameterValues; +/** + * What a probe reads off an element. Probed from the element's own defaults it + * describes the element itself; probed from a configuration it describes that + * configuration (see {@link ConfigurationRecord}). + */ +export interface PartData { partNumber: string | null; name: string | null; description: string | null; @@ -150,10 +153,16 @@ export interface ConfigurationRecord { vendor: string | null; /** True when a part studio resolved to more than one part. */ hasMultipleParts: boolean; - /** True when an open composite lost its composite in this configuration. */ + /** True when an open composite lost its composite here. */ isUnstableComposite: boolean; } +/** One configuration's part data, stored only for an indexed insertable. */ +export interface ConfigurationRecord extends PartData { + /** The parameter values that produce it. */ + configuration: ParameterValues; +} + /** * An insertable's configuration: the parameters it exposes and a record for each * configuration we probed. Mirrors the `configurations` row. diff --git a/src/backend/features/configurations/routes.test.ts b/src/backend/features/configurations/routes.test.ts index 6b9b24a97..fd642d972 100644 --- a/src/backend/features/configurations/routes.test.ts +++ b/src/backend/features/configurations/routes.test.ts @@ -39,6 +39,41 @@ describe("configuration routes", () => { expect(body).toEqual({ parameters: TEST_PARAMETERS, records: [] }); }); + // The element's own part data is the record an unset configuration falls + // back to, and it lives on the insertable, not in a configurations row. + it("GET /configuration/:id serves the element's own part data as a record", async () => { + await seedPartStudio(db, { + partData: { + partNumber: "WCP-0405", + name: "2x1 Tube", + description: null, + material: null, + vendor: null, + hasMultipleParts: false, + isUnstableComposite: false + } + }); + const app = createTestApp(); + + const res = await app.request( + `/api/configuration/${TEST_PART_STUDIO_ID}?v=abc123`, + jsonRequest("GET"), + env + ); + expect(res.status).toBe(200); + + expect(await res.json()).toEqual({ + parameters: [], + records: [ + { + partNumber: "WCP-0405", + name: "2x1 Tube", + configuration: {} + } + ] + }); + }); + it("GET /configuration/:id 404s for an unknown id", async () => { const app = createTestApp(); const res = await app.request( diff --git a/src/backend/features/configurations/routes.ts b/src/backend/features/configurations/routes.ts index 321e9cbe3..840dba296 100644 --- a/src/backend/features/configurations/routes.ts +++ b/src/backend/features/configurations/routes.ts @@ -3,9 +3,10 @@ import { CachePolicy, cacheMiddleware } from "../../lib/cache"; import { getApp } from "../../lib/context"; import { getDb } from "../../db/client"; import { getUnitInfo } from "../../lib/onshape/endpoints/documents"; -import { configurations } from "../../db/schema"; +import { configurations, insertables } from "../../db/schema"; import { type ConfigurationResult, type UnitInfo } from "./models"; import { toSearchRecords } from "../search/search-index"; +import { toRecords } from "./utils"; import { QuantityType, type Unit } from "./enums"; import { isInstancePath } from "../../lib/onshape/path"; import { HTTPException } from "hono/http-exception"; @@ -26,13 +27,17 @@ configurationRoutes.get( } const db = getDb(c.env.DB); + // Left join: the element's own part data is the fallback record, and it + // lives on the insertable whether or not it is configurable. const config = await db .select({ + partData: insertables.partData, parameters: configurations.parameters, records: configurations.records }) - .from(configurations) - .where(eq(configurations.id, insertableId)) + .from(insertables) + .leftJoin(configurations, eq(configurations.id, insertables.id)) + .where(eq(insertables.id, insertableId)) .get(); if (!config) { @@ -42,8 +47,10 @@ configurationRoutes.get( } const result: ConfigurationResult = { - parameters: config.parameters, - records: toSearchRecords(config.records) + parameters: config.parameters ?? [], + records: toSearchRecords( + toRecords(config.partData, config.records ?? []) + ) }; return c.json(result); } diff --git a/src/backend/features/configurations/utils.ts b/src/backend/features/configurations/utils.ts index 5354afa2f..a222ee0c1 100644 --- a/src/backend/features/configurations/utils.ts +++ b/src/backend/features/configurations/utils.ts @@ -1,4 +1,6 @@ import { + type ConfigurationRecord, + type PartData, ParameterValues, EnumOption, EnumParameter, @@ -187,3 +189,15 @@ export function getEvaluateOptions( ...minAndMax }; } + +/** + * An insertable's full record list: its own part data first — the record an + * unset configuration falls back to — then one per indexed configuration. + */ +export function toRecords( + partData: PartData | null, + records: ConfigurationRecord[] +): ConfigurationRecord[] { + if (!partData) return records; + return [{ ...partData, configuration: {} }, ...records]; +} diff --git a/src/backend/features/library/db.test.ts b/src/backend/features/library/db.test.ts index d4900946d..7acbbff67 100644 --- a/src/backend/features/library/db.test.ts +++ b/src/backend/features/library/db.test.ts @@ -6,11 +6,12 @@ import { group } from "../../db/schema"; import { resetDb, seedGroup, + seedInsertable, seedLibrary, TEST_GROUP_ID, TEST_LIBRARY_ID } from "../../../__test_utils__"; -import { placeNewGroup } from "./db"; +import { placeNewGroup, rebuildSearchDb } from "./db"; const db = getDb(env.DB); @@ -68,3 +69,30 @@ describe("placeNewGroup", () => { expect(rows.map((r) => r.sortOrder)).toEqual([0, 2, 3]); }); }); + +describe("rebuildSearchDb", () => { + beforeEach(async () => { + await resetDb(db); + }); + + // An unconfigurable insertable has no configurations row, so its part + // number reaches search only through the insertable's own part data. + it("indexes an unconfigurable insertable's part number", async () => { + await seedGroup(db); + await seedInsertable(db, { + partData: { + partNumber: "WCP-0405", + name: "2x1 Tube", + description: null, + material: null, + vendor: null, + hasMultipleParts: false, + isUnstableComposite: false + } + }); + + const searchDb = await rebuildSearchDb(env.BLOB, db, TEST_LIBRARY_ID); + + expect(searchDb).toContain("WCP-0405"); + }); +}); diff --git a/src/backend/features/library/db.ts b/src/backend/features/library/db.ts index ecca62838..217a7ba0c 100644 --- a/src/backend/features/library/db.ts +++ b/src/backend/features/library/db.ts @@ -1,9 +1,10 @@ -import { and, asc, eq, sql } from "drizzle-orm"; +import { asc, eq, sql } from "drizzle-orm"; import { type Db } from "../../db/client"; import { libraries, group, insertables, configurations } from "../../db/schema"; import { LibraryId } from "./library-id"; import { InsertableOut, LibraryOut, Insertables, Groups } from "./dto"; import { ConfigurationRecord } from "../configurations/models"; +import { toRecords } from "../configurations/utils"; import { buildSearchDb } from "../search/search-index"; /** @@ -34,19 +35,14 @@ export async function getLibraryOut( .where(eq(insertables.libraryId, libraryId)) .orderBy(asc(insertables.sortOrder)) .all(), - // Which insertables are configurable. A row can exist just to hold - // records, so this keys on having parameters — tested in SQL so the - // payload stays in D1 and is fetched per insertable when needed. + // Which insertables are configurable: a configurations row exists + // exactly when there are parameters. Only the ids — the payload stays + // in D1 and is fetched per insertable when one is actually opened. db .select({ id: configurations.id }) .from(configurations) .innerJoin(insertables, eq(configurations.id, insertables.id)) - .where( - and( - eq(insertables.libraryId, libraryId), - sql`json_array_length(${configurations.parameters}) > 0` - ) - ) + .where(eq(insertables.libraryId, libraryId)) .all() ]); @@ -188,8 +184,10 @@ export async function rebuildSearchDb( } /** - * Assembles the per-insertable configuration records `buildSearchDb` dedupes into - * the part-number search map. Only indexed insertables have records. + * Assembles the per-insertable records `buildSearchDb` dedupes into the + * part-number search map: the element's own part data, plus one per indexed + * configuration. A left join, since an unconfigurable element still has both a + * part number and no configurations row. */ async function getRecordsMap( db: Db, @@ -198,17 +196,19 @@ async function getRecordsMap( const rows = await db .select({ id: insertables.id, + partData: insertables.partData, records: configurations.records }) .from(insertables) - .innerJoin(configurations, eq(configurations.id, insertables.id)) + .leftJoin(configurations, eq(configurations.id, insertables.id)) .where(eq(insertables.libraryId, libraryId)) .all(); const recordsMap: Record = {}; for (const row of rows) { - if (row.records.length > 0) { - recordsMap[row.id] = row.records; + const records = toRecords(row.partData, row.records ?? []); + if (records.length > 0) { + recordsMap[row.id] = records; } } return recordsMap; diff --git a/src/backend/features/library/insertables/routes.test.ts b/src/backend/features/library/insertables/routes.test.ts index c36cc773f..966b5a9ef 100644 --- a/src/backend/features/library/insertables/routes.test.ts +++ b/src/backend/features/library/insertables/routes.test.ts @@ -182,19 +182,18 @@ describe("insertable routes", () => { const row = await readInsertable(TEST_PART_STUDIO_ID); expect(row?.indexConfigurations).toBe(true); - const config = await readConfig(TEST_PART_STUDIO_ID); - expect(config?.records).toEqual([ - { - configuration: {}, - partNumber: "PN-123", - name: null, - description: null, - material: null, - vendor: null, - hasMultipleParts: false, - isUnstableComposite: false - } - ]); + // Nothing to configure, so the part data lands on the insertable and + // no configurations row is manufactured to hold it. + expect(row?.partData).toEqual({ + partNumber: "PN-123", + name: null, + description: null, + material: null, + vendor: null, + hasMultipleParts: false, + isUnstableComposite: false + }); + expect(await readConfig(TEST_PART_STUDIO_ID)).toBeUndefined(); }); it("POST /index-configurations leaves the flag off when indexing fails", async () => { @@ -280,10 +279,9 @@ describe("insertable routes", () => { ); expect(res.status).toBe(200); - const config = await readConfig(TEST_PART_STUDIO_ID); - expect(config?.records).toHaveLength(1); - // Nobody sells it, so a missing part number is not worth flagging. const row = await readInsertable(TEST_PART_STUDIO_ID); + expect(row?.partData).not.toBeNull(); + // Nobody sells it, so a missing part number is not worth flagging. expect(row?.buildIssues).toEqual([]); }); diff --git a/src/backend/features/library/insertables/routes.ts b/src/backend/features/library/insertables/routes.ts index 7e9747ae3..f96f9de5b 100644 --- a/src/backend/features/library/insertables/routes.ts +++ b/src/backend/features/library/insertables/routes.ts @@ -160,13 +160,15 @@ insertableRoutes.post( ...indexed.buildIssues, ...indexing.buildIssues, // Vendors are read, not re-derived: the load path wrote them. - ...checkIndexedPartNumber(row.vendors, indexed.records) + ...checkIndexedPartNumber(row.vendors, [ + indexed.partData, + ...indexed.records + ]) ); - // Keep a configurations row while there's parameters or records to hold; - // a non-configurable insertable that stops indexing loses its row. + // A configurations row exists exactly when the insertable is configurable. const configWrite = - parameters.length > 0 || indexed.records.length > 0 + parameters.length > 0 ? db .insert(configurations) .values({ @@ -187,6 +189,7 @@ insertableRoutes.post( .update(insertables) .set({ indexConfigurations: body.indexConfigurations, + partData: indexed.partData, buildIssues }) .where(eq(insertables.id, insertableId)), diff --git a/src/backend/features/load/load-insertable.test.ts b/src/backend/features/load/load-insertable.test.ts index 45b3a1e00..7748e7fe4 100644 --- a/src/backend/features/load/load-insertable.test.ts +++ b/src/backend/features/load/load-insertable.test.ts @@ -3,7 +3,7 @@ import { eq } from "drizzle-orm"; import { beforeEach, describe, expect, it } from "vitest"; import { getDb } from "../../db/client"; import { configurations, insertables } from "../../db/schema"; -import type { ConfigurationRecord } from "../configurations/models"; +import type { ConfigurationRecord, PartData } from "../configurations/models"; import { TEST_PARAMETERS, TEST_PART_STUDIO_ID, @@ -26,13 +26,9 @@ function readInsertable() { .get(); } -/** Builds a configuration record with the given part number and defaults. */ -function record( - partNumber: string | null, - configuration: Record = {} -): ConfigurationRecord { +/** Builds an element's own part data with the given part number and defaults. */ +function partData(partNumber: string | null): PartData { return { - configuration, partNumber, name: null, description: null, @@ -43,6 +39,14 @@ function record( }; } +/** Builds a configuration record with the given part number and defaults. */ +function record( + partNumber: string | null, + configuration: Record = {} +): ConfigurationRecord { + return { ...partData(partNumber), configuration }; +} + describe("saveInsertable", () => { beforeEach(async () => { await resetDb(db); @@ -129,32 +133,30 @@ describe("saveInsertable", () => { expect(config?.records).toEqual(records); }); - // An indexed insertable with no varying parameters still needs its records - // stored, so a configurations row is kept even when `parameters` is empty. - it("keeps a configuration row for a non-configurable indexed insertable", async () => { + // The element's own part number is not a configuration of it, so probing an + // unconfigurable element must not manufacture a configurations row. + it("stores part data on the insertable without a configuration row", async () => { await saveInsertable( db, insertableTarget(), parsedInsertable({ - configuration: { - parameters: [], - records: [record("PN-default")] - } + partData: partData("PN-default"), + configuration: { parameters: [], records: [] } }) ); - const config = await db + const insertable = await db .select() - .from(configurations) - .where(eq(configurations.id, TEST_PART_STUDIO_ID)) + .from(insertables) + .where(eq(insertables.id, TEST_PART_STUDIO_ID)) .get(); - expect(config?.parameters).toEqual([]); - expect(config?.records).toEqual([record("PN-default")]); + expect(insertable?.partData).toEqual(partData("PN-default")); + expect(await db.select().from(configurations).all()).toHaveLength(0); }); - // features/library/db.ts reads `records` without re-checking that the insertable is - // still indexed, so an empty reload must drop the row, not blank it. - it("drops the configuration row when there are no parameters or records", async () => { + // features/library/db.ts treats the row's existence as "configurable", so an + // insertable that stops being configurable must lose the row, not blank it. + it("drops the configuration row when there are no parameters", async () => { await saveInsertable( db, insertableTarget(), diff --git a/src/backend/features/load/load-insertable.ts b/src/backend/features/load/load-insertable.ts index 8eb82293e..7662232fb 100644 --- a/src/backend/features/load/load-insertable.ts +++ b/src/backend/features/load/load-insertable.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { type Db, getDb } from "../../db/client"; import type { Configuration, + PartData, ConfigurationParameter } from "../configurations/models"; import { @@ -45,6 +46,8 @@ export interface ParsedInsertable { /** Whether the part studio resolves to an open composite. */ isOpenComposite: boolean; buildIssues: BuildIssue[]; + /** The element's own part data; null when nothing was probed. */ + partData: PartData | null; configuration: Configuration; } @@ -108,7 +111,7 @@ export async function loadInsertable( ? checkInsertable({ vendors, thumbnailUrls, - records: recordsResult.records + probed: [recordsResult.partData, ...recordsResult.records] }) : [{ type: BuildIssueType.NO_PARTS }], ...recordsResult.buildIssues, @@ -121,6 +124,7 @@ export async function loadInsertable( fastenInfo, isOpenComposite, buildIssues, + partData: recordsResult.partData, configuration: { parameters, records: recordsResult.records } }; @@ -226,6 +230,7 @@ export async function saveInsertable( largeThumbnailUrl: parsed.thumbnailUrls?.large ?? null, fastenInfo: parsed.fastenInfo, isOpenComposite: parsed.isOpenComposite, + partData: parsed.partData, buildIssues: parsed.buildIssues, lastLoadedAt: Date.now() }; @@ -251,13 +256,9 @@ export async function saveInsertable( set: reloaded }); - // Keep the row while it holds either parameters or records; an insertable - // that is neither configurable nor indexed needs none. + // A configurations row exists exactly when the insertable is configurable. let configurationWrite; - if ( - configuration.parameters.length > 0 || - configuration.records.length > 0 - ) { + if (configuration.parameters.length > 0) { configurationWrite = db .insert(configurations) .values({ id: target.insertableId, ...configuration }) diff --git a/src/backend/features/load/parse-configuration-records.test.ts b/src/backend/features/load/parse-configuration-records.test.ts index 78eb11e6f..02ce82687 100644 --- a/src/backend/features/load/parse-configuration-records.test.ts +++ b/src/backend/features/load/parse-configuration-records.test.ts @@ -199,7 +199,7 @@ function mockParts( } describe("parseConfigurationRecords", () => { - it("returns a record per configuration, default first, in enumeration order", async () => { + it("returns the element's part data plus a record per configuration", async () => { mockParts((configuration) => [ { partId: "p", partNumber: `PN-${configuration.A ?? "default"}` } ]); @@ -214,12 +214,10 @@ describe("parseConfigurationRecords", () => { ); expect(result.buildIssues).toEqual([]); - // "a1" is A's default, so that combination is the default probe under - // another name and is not probed again. - expect(result.records.map((r) => r.partNumber)).toEqual([ - "PN-default", - "PN-a2" - ]); + // "a1" is A's default, so that combination is the element's own probe + // under another name and is not probed again. + expect(result.partData?.partNumber).toBe("PN-default"); + expect(result.records.map((r) => r.partNumber)).toEqual(["PN-a2"]); }); it("probes every combination when none of them is the default", async () => { @@ -238,10 +236,8 @@ describe("parseConfigurationRecords", () => { false ); - expect(result.records.map((r) => r.partNumber)).toEqual([ - "PN-default", - "PN-a1" - ]); + expect(result.partData?.partNumber).toBe("PN-default"); + expect(result.records.map((r) => r.partNumber)).toEqual(["PN-a1"]); }); it("flags a studio with more than one part in any configuration", async () => { @@ -313,8 +309,8 @@ describe("parseConfigurationRecords", () => { ); expect(result.buildIssues).toEqual([]); - expect(result.records).toHaveLength(1); - expect(result.records[0].partNumber).toBe("PN-default"); + expect(result.records).toHaveLength(0); + expect(result.partData?.partNumber).toBe("PN-default"); expect(spy).toHaveBeenCalledTimes(1); }); @@ -334,18 +330,16 @@ describe("parseConfigurationRecords", () => { false ); - expect(result.records).toEqual([ - { - configuration: {}, - partNumber: "AM-1", - name: null, - description: null, - material: null, - vendor: null, - hasMultipleParts: false, - isUnstableComposite: false - } - ]); + expect(result.records).toEqual([]); + expect(result.partData).toEqual({ + partNumber: "AM-1", + name: null, + description: null, + material: null, + vendor: null, + hasMultipleParts: false, + isUnstableComposite: false + }); expect(spy).toHaveBeenCalledWith(CLIENT, PATH, {}); }); }); diff --git a/src/backend/features/load/parse-configuration-records.ts b/src/backend/features/load/parse-configuration-records.ts index 5162fc04c..9e3af4d72 100644 --- a/src/backend/features/load/parse-configuration-records.ts +++ b/src/backend/features/load/parse-configuration-records.ts @@ -8,6 +8,7 @@ import { ElementType } from "../../lib/onshape/element-type"; import { ParameterValues, ConfigurationParameter, + PartData, ConfigurationRecord } from "../configurations/models"; import { @@ -35,12 +36,16 @@ const BATCH_SIZE = 20; /** An insertable's configuration records, and the issues indexing them raised. */ export interface ConfigurationRecordsResult { + /** The element's own part data; null when nothing was probed. */ + partData: PartData | null; + /** One per indexed configuration; the element's own is `partData`. */ records: ConfigurationRecord[]; buildIssues: BuildIssue[]; } /** The result for an insertable that isn't indexed. */ export const NO_RECORDS: ConfigurationRecordsResult = { + partData: null, records: [], buildIssues: [] }; @@ -369,9 +374,21 @@ function toResult( batches: ConfigurationRecord[][], parameters: ConfigurationParameter[] ): ConfigurationRecordsResult { + // The element's own probe describes the element, not a configuration of it, + // so it sheds the (empty) configuration that produced it. + const partData: PartData = { + partNumber: defaultRecord.partNumber, + name: defaultRecord.name, + description: defaultRecord.description, + material: defaultRecord.material, + vendor: defaultRecord.vendor, + hasMultipleParts: defaultRecord.hasMultipleParts, + isUnstableComposite: defaultRecord.isUnstableComposite + }; + // Canonical, so a record addresses the same thumbnail the insert menu does // for the same selection. - const records = [defaultRecord, ...batches.flat()].map((record) => ({ + const records = batches.flat().map((record) => ({ ...record, configuration: canonicalizeConfiguration( record.configuration, @@ -381,17 +398,18 @@ function toResult( // A capped insertable never reaches here: decideIndexing turns indexing off // past the cap, and raises TOO_MANY_CONFIGURATIONS itself. + const probed: PartData[] = [partData, ...records]; let buildIssues: BuildIssue[] = []; - if (records.some((record) => record.hasMultipleParts)) { + if (probed.some((record) => record.hasMultipleParts)) { buildIssues = addBuildIssue(buildIssues, { type: BuildIssueType.MULTIPLE_PARTS }); } - if (records.some((record) => record.isUnstableComposite)) { + if (probed.some((record) => record.isUnstableComposite)) { buildIssues = addBuildIssue(buildIssues, { type: BuildIssueType.UNSTABLE_COMPOSITE }); } - return { records, buildIssues }; + return { partData, records, buildIssues }; } From 3f3d5ef936c982115bf2313ea5c2e8ae37c15730 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 00:45:36 +0000 Subject: [PATCH 11/56] refactor: drop the part_data backfill, matching 0004 The library reloads from Onshape, so the column starts null and repopulates on the next load rather than being carried over. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- drizzle/0006_split_part_data.sql | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/drizzle/0006_split_part_data.sql b/drizzle/0006_split_part_data.sql index 163fcafb8..f6785bcf0 100644 --- a/drizzle/0006_split_part_data.sql +++ b/drizzle/0006_split_part_data.sql @@ -5,18 +5,8 @@ to configure, and forced "is it configurable?" to test the parameter count. That part data moves to `insertables.part_data`, where it describes the element - rather than a configuration of it. `configurations` is left holding only real - configuration data, so a row exists exactly when there are parameters. - - The default record is always written first (see toResult), so `$[0]` is it. + rather than a configuration of it. Nothing is carried over: the column starts + null and repopulates on the next load, which is also when a configurations row + that holds no parameters is dropped. */ -ALTER TABLE `insertables` ADD `part_data` text;--> statement-breakpoint -UPDATE `insertables` SET `part_data` = ( - SELECT json_remove(json_extract(c.`records`, '$[0]'), '$.configuration') - FROM `configurations` c - WHERE c.`id` = `insertables`.`id` - AND json_array_length(c.`records`) > 0 -);--> statement-breakpoint -UPDATE `configurations` SET `records` = json_remove(`records`, '$[0]') - WHERE json_array_length(`records`) > 0;--> statement-breakpoint -DELETE FROM `configurations` WHERE json_array_length(`parameters`) = 0; +ALTER TABLE `insertables` ADD `part_data` text; From 36482647058c204d78c563677b6a55c48167574e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 03:15:48 +0000 Subject: [PATCH 12/56] refactor: swap Tabler for Phosphor icons, and untangle the home accordions Icons: all 34 Tabler icons map to a Phosphor equivalent, each name checked against the installed package rather than guessed. A filled heart becomes weight="fill" and Tabler's title prop becomes Phosphor's alt, which renders the same element. notifications.tsx imported ReactNode from @tabler/icons-react, which does not export it; it now comes from react. Home page: the library and search sections were two near-identical Accordion.Item blocks, with their open state read from a hand-built array and written back through a branch on whether a search was active. Both are now one list of sections, each carrying where its own open state lives, so the value and onChange plumbing stops branching. The accordion divider was styled onto the panel content, so a collapsed section had no line under it. It moves to the control, which is rendered either way; content keeps the closing line when open. Verified in the running app. Also deletes the beta-complete page, which nothing linked to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- package-lock.json | 71 ++------ package.json | 2 +- src/frontend/components/app-menu.tsx | 4 +- src/frontend/components/app-navbar.tsx | 8 +- src/frontend/components/app-zero-state.tsx | 6 +- src/frontend/components/change-order.tsx | 18 +- src/frontend/components/open-url-button.tsx | 4 +- src/frontend/components/root-error.tsx | 4 +- .../build-status/components/build-status.tsx | 30 ++-- .../favorites/components/favorite-button.tsx | 12 +- .../favorites/components/favorite-card.tsx | 4 +- .../favorites/components/favorite-menu.tsx | 4 +- .../favorites/components/favorites-list.tsx | 7 +- .../insert/components/insert-menu.tsx | 8 +- .../library/components/add-group-menu.tsx | 8 +- .../library/components/card-components.tsx | 30 ++-- .../library/components/group-card.tsx | 15 +- .../components/reload-groups-button.tsx | 4 +- .../search/components/search-errors.tsx | 8 +- .../settings/components/vendor-filters.tsx | 8 +- .../thumbnails/components/thumbnail.tsx | 4 +- src/frontend/lib/notifications.tsx | 14 +- src/frontend/lib/style-constants.ts | 4 +- src/frontend/lib/url.tsx | 4 +- src/frontend/routeTree.gen.ts | 21 --- src/frontend/routes/_pages/beta-complete.tsx | 23 --- .../library/$libraryId/groups/$groupId.tsx | 15 +- .../routes/app/library/$libraryId/index.tsx | 160 +++++++++--------- 28 files changed, 195 insertions(+), 305 deletions(-) delete mode 100644 src/frontend/routes/_pages/beta-complete.tsx diff --git a/package-lock.json b/package-lock.json index e7a0fccba..b4e92ea7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@mantine/hooks": "^9.5.1", "@mantine/modals": "^9.5.1", "@mantine/notifications": "^9.5.1", - "@tabler/icons-react": "^3.44.0", + "@phosphor-icons/react": "^2.1.10", "@tanstack/react-query": "^5.100.10", "@tanstack/react-router": "^1.169.2", "@tanstack/react-router-devtools": "^1.166.13", @@ -2837,9 +2837,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2861,9 +2858,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2885,9 +2879,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2909,9 +2900,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3030,6 +3018,19 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@phosphor-icons/react": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz", + "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">= 16.8", + "react-dom": ">= 16.8" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -3152,9 +3153,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3172,9 +3170,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3192,9 +3187,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3212,9 +3204,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3362,32 +3351,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tabler/icons": { - "version": "3.44.0", - "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.44.0.tgz", - "integrity": "sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/codecalm" - } - }, - "node_modules/@tabler/icons-react": { - "version": "3.44.0", - "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.44.0.tgz", - "integrity": "sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==", - "license": "MIT", - "dependencies": { - "@tabler/icons": "3.44.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/codecalm" - }, - "peerDependencies": { - "react": ">= 16" - } - }, "node_modules/@tanstack/history": { "version": "1.162.0", "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.0.tgz", @@ -6340,9 +6303,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6364,9 +6324,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index 590a229fd..f7672d38c 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "@mantine/hooks": "^9.5.1", "@mantine/modals": "^9.5.1", "@mantine/notifications": "^9.5.1", - "@tabler/icons-react": "^3.44.0", + "@phosphor-icons/react": "^2.1.10", "@tanstack/react-query": "^5.100.10", "@tanstack/react-router": "^1.169.2", "@tanstack/react-router-devtools": "^1.166.13", diff --git a/src/frontend/components/app-menu.tsx b/src/frontend/components/app-menu.tsx index 60d5a56d5..1c7b69d5a 100644 --- a/src/frontend/components/app-menu.tsx +++ b/src/frontend/components/app-menu.tsx @@ -1,6 +1,6 @@ import { PropsWithChildren, ReactNode } from "react"; import { FloatingPosition, Menu, ActionIcon } from "@mantine/core"; -import { IconDots } from "@tabler/icons-react"; +import { DotsThree } from "@phosphor-icons/react"; import { IconSize } from "../lib/style-constants"; interface AppContextMenuProps { @@ -82,7 +82,7 @@ export function MenuButton(props: MenuButtonProps): ReactNode { title="View options" onClick={(e) => e.stopPropagation()} > - <IconDots size={large ? IconSize.CONTROL : IconSize.MEDIUM} /> + <DotsThree size={large ? IconSize.CONTROL : IconSize.MEDIUM} /> </ActionIcon> </AppContextMenu> ); diff --git a/src/frontend/components/app-navbar.tsx b/src/frontend/components/app-navbar.tsx index 3e75186fd..ad496a82c 100644 --- a/src/frontend/components/app-navbar.tsx +++ b/src/frontend/components/app-navbar.tsx @@ -8,7 +8,7 @@ import { TextInput, Tooltip } from "@mantine/core"; -import { IconChevronDown, IconSearch, IconSettings } from "@tabler/icons-react"; +import { CaretDown, Gear, MagnifyingGlass } from "@phosphor-icons/react"; import { HEADER_CONTROL_COLOR, IconSize } from "../lib/style-constants"; import { ReactNode, RefObject, useRef } from "react"; import { useNavigate } from "@tanstack/react-router"; @@ -128,7 +128,7 @@ function LibraryMenu(): ReactNode { <Menu.Target> <Button variant="default" - rightSection={<IconChevronDown size={IconSize.SMALL} />} + rightSection={<CaretDown size={IconSize.SMALL} />} > {getLibraryName(currentLibraryId)} </Button> @@ -168,7 +168,7 @@ export function SettingsButton() { title="Settings" onClick={() => openSettingsMenu()} > - <IconSettings size={IconSize.CONTROL} /> + <Gear size={IconSize.CONTROL} /> </ActionIcon> ); } @@ -202,7 +202,7 @@ export function SearchBar() { <TextInput type="search" maw={200} // Hardcode search bar width as max so close button doesn't expand - leftSection={<IconSearch size={IconSize.SMALL} />} + leftSection={<MagnifyingGlass size={IconSize.SMALL} />} placeholder="Search library..." ref={ref} value={uiState.searchQuery ?? ""} diff --git a/src/frontend/components/app-zero-state.tsx b/src/frontend/components/app-zero-state.tsx index d23d9e92d..6582b0d04 100644 --- a/src/frontend/components/app-zero-state.tsx +++ b/src/frontend/components/app-zero-state.tsx @@ -1,11 +1,9 @@ import { Center, Loader, EmptyState } from "@mantine/core"; -import { IconX } from "@tabler/icons-react"; +import { X } from "@phosphor-icons/react"; import { HeartIconColor, IconSize } from "../lib/style-constants"; import { type JSX, ReactNode } from "react"; -const DEFAULT_ERROR_ICON = ( - <IconX size={IconSize.HUGE} color={HeartIconColor} /> -); +const DEFAULT_ERROR_ICON = <X size={IconSize.HUGE} color={HeartIconColor} />; interface ZeroStateProps { icon?: ReactNode; diff --git a/src/frontend/components/change-order.tsx b/src/frontend/components/change-order.tsx index a26fab5b3..4baea7320 100644 --- a/src/frontend/components/change-order.tsx +++ b/src/frontend/components/change-order.tsx @@ -1,10 +1,10 @@ import { Menu } from "@mantine/core"; import { - IconChevronDown, - IconChevronsDown, - IconChevronsUp, - IconChevronUp -} from "@tabler/icons-react"; + CaretDoubleDown, + CaretDoubleUp, + CaretDown, + CaretUp +} from "@phosphor-icons/react"; import { IconSize } from "../lib/style-constants"; import { type ReactNode } from "react"; @@ -32,7 +32,7 @@ export function ChangeOrderItems(props: ChangeOrderMenuProps): ReactNode { <> {operations.includes(MoveOperation.MOVE_UP) && ( <Menu.Item - leftSection={<IconChevronUp size={IconSize.SMALL} />} + leftSection={<CaretUp size={IconSize.SMALL} />} onClick={() => { onOrderChange( applyMoveOperation(id, order, MoveOperation.MOVE_UP) @@ -44,7 +44,7 @@ export function ChangeOrderItems(props: ChangeOrderMenuProps): ReactNode { )} {operations.includes(MoveOperation.MOVE_DOWN) && ( <Menu.Item - leftSection={<IconChevronDown size={IconSize.SMALL} />} + leftSection={<CaretDown size={IconSize.SMALL} />} onClick={() => { onOrderChange( applyMoveOperation( @@ -60,7 +60,7 @@ export function ChangeOrderItems(props: ChangeOrderMenuProps): ReactNode { )} {operations.includes(MoveOperation.MOVE_TO_TOP) && ( <Menu.Item - leftSection={<IconChevronsUp size={IconSize.SMALL} />} + leftSection={<CaretDoubleUp size={IconSize.SMALL} />} onClick={() => { onOrderChange( applyMoveOperation( @@ -76,7 +76,7 @@ export function ChangeOrderItems(props: ChangeOrderMenuProps): ReactNode { )} {operations.includes(MoveOperation.MOVE_TO_BOTTOM) && ( <Menu.Item - leftSection={<IconChevronsDown size={IconSize.SMALL} />} + leftSection={<CaretDoubleDown size={IconSize.SMALL} />} onClick={() => { onOrderChange( applyMoveOperation( diff --git a/src/frontend/components/open-url-button.tsx b/src/frontend/components/open-url-button.tsx index 01bdb3afd..bc31d142f 100644 --- a/src/frontend/components/open-url-button.tsx +++ b/src/frontend/components/open-url-button.tsx @@ -1,5 +1,5 @@ import { Button } from "@mantine/core"; -import { IconExternalLink } from "@tabler/icons-react"; +import { ArrowSquareOut } from "@phosphor-icons/react"; import { IconSize } from "../lib/style-constants"; import { openUrlInNewTab } from "../lib/url"; @@ -11,7 +11,7 @@ interface UrlButtonProps { export function OpenUrlButton(props: UrlButtonProps) { return ( <Button - leftSection={<IconExternalLink size={IconSize.SMALL} />} + leftSection={<ArrowSquareOut size={IconSize.SMALL} />} onClick={() => openUrlInNewTab(props.url)} variant="light" > diff --git a/src/frontend/components/root-error.tsx b/src/frontend/components/root-error.tsx index 558d15725..86e70665d 100644 --- a/src/frontend/components/root-error.tsx +++ b/src/frontend/components/root-error.tsx @@ -3,7 +3,7 @@ import { PageError } from "./app-zero-state"; import { ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; import { Button } from "@mantine/core"; -import { IconHome } from "@tabler/icons-react"; +import { House } from "@phosphor-icons/react"; import { IconSize } from "../lib/style-constants"; import { ReloadGroupsButton } from "../features/library/components/reload-groups-button"; import { DEFAULT_LIBRARY_ID } from "@backend/features/library/library-id"; @@ -50,7 +50,7 @@ export function NotFoundError(): ReactNode { const navigate = useNavigate(); const homeButton = ( <Button - leftSection={<IconHome size={IconSize.MEDIUM} />} + leftSection={<House size={IconSize.MEDIUM} />} onClick={() => { void navigate({ to: "/app/library/$libraryId", diff --git a/src/frontend/features/build-status/components/build-status.tsx b/src/frontend/features/build-status/components/build-status.tsx index b175fb501..6a963b47e 100644 --- a/src/frontend/features/build-status/components/build-status.tsx +++ b/src/frontend/features/build-status/components/build-status.tsx @@ -11,13 +11,13 @@ import { Tooltip } from "@mantine/core"; import { - IconAlertOctagon, - IconAlertTriangle, - IconCheck, - IconClock, - IconInfoCircle, - IconX -} from "@tabler/icons-react"; + Check, + Clock, + Info, + Warning, + WarningOctagon, + X +} from "@phosphor-icons/react"; import { ComponentPropsWithRef, ReactNode, @@ -120,7 +120,7 @@ export function IssueIcon({ switch (severity) { case BuildIssueSeverity.ERROR: return ( - <IconAlertOctagon + <WarningOctagon ref={ref} size={IconSize.SMALL} color={IconColor.RED} @@ -129,7 +129,7 @@ export function IssueIcon({ ); case BuildIssueSeverity.WARNING: return ( - <IconAlertTriangle + <Warning ref={ref} size={IconSize.SMALL} color={IconColor.YELLOW} @@ -138,7 +138,7 @@ export function IssueIcon({ ); case BuildIssueSeverity.INFO: return ( - <IconInfoCircle + <Info ref={ref} size={IconSize.SMALL} color={IconColor.BLUE} @@ -147,7 +147,7 @@ export function IssueIcon({ ); case null: return ( - <IconCheck + <Check ref={ref} size={IconSize.SMALL} color={IconColor.GREEN} @@ -330,7 +330,7 @@ function LastModified({ c="dimmed" style={{ whiteSpace: "nowrap", flexShrink: 0 }} > - <IconClock size={IconSize.TINY} /> + <Clock size={IconSize.TINY} /> <Text size="xs"> {!lastLoadedAt ? "Unknown" @@ -349,7 +349,7 @@ function SeverityBadges({ issues }: { issues: BuildIssue[] }): ReactNode { size="sm" variant="light" color="green" - leftSection={<IconCheck size={IconSize.TINY} />} + leftSection={<Check size={IconSize.TINY} />} > All checks pass </Badge> @@ -959,9 +959,9 @@ function ParsedRow({ function StateValue({ value }: { value: StateRowValue }): ReactNode { if (value.kind === "bool") { return value.value ? ( - <IconCheck size={IconSize.SMALL} color={IconColor.GREEN} /> + <Check size={IconSize.SMALL} color={IconColor.GREEN} /> ) : ( - <IconX size={IconSize.SMALL} color={IconColor.RED} /> + <X size={IconSize.SMALL} color={IconColor.RED} /> ); } diff --git a/src/frontend/features/favorites/components/favorite-button.tsx b/src/frontend/features/favorites/components/favorite-button.tsx index f0b426d4f..fad78ddc2 100644 --- a/src/frontend/features/favorites/components/favorite-button.tsx +++ b/src/frontend/features/favorites/components/favorite-button.tsx @@ -1,9 +1,5 @@ import { ActionIcon, Menu } from "@mantine/core"; -import { - IconHeart, - IconHeartBroken, - IconHeartFilled -} from "@tabler/icons-react"; +import { Heart, HeartBreak } from "@phosphor-icons/react"; import { HeartIconColor, IconSize } from "../../../lib/style-constants"; import { useMutation } from "@tanstack/react-query"; import { ReactNode, useState } from "react"; @@ -195,9 +191,9 @@ interface HeartIconProps { export function HeartIcon(props: HeartIconProps): ReactNode { const { full = true, size = IconSize.SMALL } = props; return full ? ( - <IconHeartFilled size={size} color={HeartIconColor} /> + <Heart size={size} color={HeartIconColor} weight="fill" /> ) : ( - <IconHeart size={size} /> + <Heart size={size} /> ); } @@ -210,5 +206,5 @@ interface HeartBrokenIconProps { export function HeartBrokenIcon(props: HeartBrokenIconProps): ReactNode { const { size = IconSize.SMALL } = props; - return <IconHeartBroken size={size} color={HeartIconColor} />; + return <HeartBreak size={size} color={HeartIconColor} />; } diff --git a/src/frontend/features/favorites/components/favorite-card.tsx b/src/frontend/features/favorites/components/favorite-card.tsx index 25108bee8..a6eacf6d3 100644 --- a/src/frontend/features/favorites/components/favorite-card.tsx +++ b/src/frontend/features/favorites/components/favorite-card.tsx @@ -6,7 +6,7 @@ import { useMutation } from "@tanstack/react-query"; import { apiPost } from "../../../lib/api-client"; import { queryClient } from "../../../lib/query-client"; import { Menu } from "@mantine/core"; -import { IconPencil } from "@tabler/icons-react"; +import { Pencil } from "@phosphor-icons/react"; import { IconSize } from "../../../lib/style-constants"; import { useRouter } from "@tanstack/react-router"; import { openInsertMenu } from "../../insert/components/insert-menu"; @@ -128,7 +128,7 @@ function FavoriteMenuItems(props: FavoriteMenuItemsProps): ReactNode { </> )} <Menu.Item - leftSection={<IconPencil size={IconSize.SMALL} />} + leftSection={<Pencil size={IconSize.SMALL} />} onClick={() => { if (!insertable.isConfigurable) { openCannotEditDefaultConfigurationAlert(); diff --git a/src/frontend/features/favorites/components/favorite-menu.tsx b/src/frontend/features/favorites/components/favorite-menu.tsx index c994599d6..766931826 100644 --- a/src/frontend/features/favorites/components/favorite-menu.tsx +++ b/src/frontend/features/favorites/components/favorite-menu.tsx @@ -1,6 +1,6 @@ import { Button, Group, Stack, Text } from "@mantine/core"; import { modals } from "@mantine/modals"; -import { IconDeviceFloppy } from "@tabler/icons-react"; +import { FloppyDisk } from "@phosphor-icons/react"; import { FontWeight, IconSize } from "../../../lib/style-constants"; import { ReactNode, useEffect, useState } from "react"; import { useRouter } from "@tanstack/react-router"; @@ -188,7 +188,7 @@ function FavoriteMenuContent(props: FavoriteMenuContentProps): ReactNode { /> <Group justify="flex-end" mt="md"> <Button - leftSection={<IconDeviceFloppy size={IconSize.SMALL} />} + leftSection={<FloppyDisk size={IconSize.SMALL} />} // Saving before the wrapper reports would store {}, wiping // the favorite's configuration. disabled={!canonicalConfiguration} diff --git a/src/frontend/features/favorites/components/favorites-list.tsx b/src/frontend/features/favorites/components/favorites-list.tsx index 77031640e..5ba02f205 100644 --- a/src/frontend/features/favorites/components/favorites-list.tsx +++ b/src/frontend/features/favorites/components/favorites-list.tsx @@ -1,5 +1,5 @@ import { useAccessData } from "../../auth/access-level"; -import { IconHeartBroken } from "@tabler/icons-react"; +import { HeartBreak } from "@phosphor-icons/react"; import { HeartIconColor, IconSize } from "../../../lib/style-constants"; import { ReactNode } from "react"; import { filterInsertables } from "../../search/filter"; @@ -41,10 +41,7 @@ export function FavoritesList(): ReactNode { <SectionError title="Failed to load favorites." icon={ - <IconHeartBroken - size={IconSize.LARGE} - color={HeartIconColor} - /> + <HeartBreak size={IconSize.LARGE} color={HeartIconColor} /> } /> ); diff --git a/src/frontend/features/insert/components/insert-menu.tsx b/src/frontend/features/insert/components/insert-menu.tsx index 1a9ce387c..859556ae2 100644 --- a/src/frontend/features/insert/components/insert-menu.tsx +++ b/src/frontend/features/insert/components/insert-menu.tsx @@ -4,7 +4,7 @@ import { getFavoriteForInsertable } from "@backend/features/favorites/dto"; import { InsertableOut } from "@backend/features/library/dto"; import { ElementType } from "@backend/lib/onshape/element-type"; import { Button, Checkbox, Group, Stack, Text } from "@mantine/core"; -import { IconInfoCircle, IconPlus } from "@tabler/icons-react"; +import { Info, Plus } from "@phosphor-icons/react"; import { FontWeight, IconSize } from "../../../lib/style-constants"; import { modals } from "@mantine/modals"; import { useIsFetching } from "@tanstack/react-query"; @@ -242,7 +242,7 @@ function InsertButtons(props: InsertButtonsProps): ReactNode { /> )} <Button - leftSection={<IconPlus size={IconSize.SMALL} />} + leftSection={<Plus size={IconSize.SMALL} />} loading={isLoadingConfiguration || insertMutation.isPending} onClick={handleClick} > @@ -260,7 +260,7 @@ function showSignInPreviewToast() { notifications.show({ id: "sign-in-preview", color: "blue", - icon: <IconInfoCircle size={IconSize.MEDIUM} />, + icon: <Info size={IconSize.MEDIUM} />, message: renderNotification( "Sign in to Onshape to see the configuration preview.", { text: "Sign in", onClick: startSignIn } @@ -284,7 +284,7 @@ function showRestoreToast( restoreButton ), color: "blue", - icon: <IconInfoCircle size={IconSize.MEDIUM} />, + icon: <Info size={IconSize.MEDIUM} />, autoClose: 3000 }); } diff --git a/src/frontend/features/library/components/add-group-menu.tsx b/src/frontend/features/library/components/add-group-menu.tsx index 61b59ae49..0f0790ae4 100644 --- a/src/frontend/features/library/components/add-group-menu.tsx +++ b/src/frontend/features/library/components/add-group-menu.tsx @@ -1,6 +1,6 @@ import { Button, Group, Menu, TextInput } from "@mantine/core"; import { modals } from "@mantine/modals"; -import { IconPlus } from "@tabler/icons-react"; +import { Plus } from "@phosphor-icons/react"; import { IconSize } from "../../../lib/style-constants"; import { ReactNode, useState } from "react"; import { useMutation } from "@tanstack/react-query"; @@ -69,7 +69,7 @@ function AddGroupMenuContent(props: AddGroupMenuContentProps): ReactNode { error={mutation.isError} /> <Button - leftSection={<IconPlus size={IconSize.SMALL} />} + leftSection={<Plus size={IconSize.SMALL} />} onClick={() => mutation.mutate()} loading={mutation.isPending} > @@ -82,7 +82,7 @@ function AddGroupMenuContent(props: AddGroupMenuContentProps): ReactNode { export function AddGroupButton(): ReactNode { return ( <Button - leftSection={<IconPlus size={IconSize.SMALL} />} + leftSection={<Plus size={IconSize.SMALL} />} onClick={() => openAddGroupMenu()} > Add group @@ -97,7 +97,7 @@ interface AddGroupItemProps { export function AddGroupItem(props: AddGroupItemProps): ReactNode { return ( <Menu.Item - leftSection={<IconPlus size={IconSize.SMALL} />} + leftSection={<Plus size={IconSize.SMALL} />} onClick={() => openAddGroupMenu(props.selectedGroupId)} > Add group diff --git a/src/frontend/features/library/components/card-components.tsx b/src/frontend/features/library/components/card-components.tsx index c5bd6cae2..53a8c1a9d 100644 --- a/src/frontend/features/library/components/card-components.tsx +++ b/src/frontend/features/library/components/card-components.tsx @@ -1,12 +1,12 @@ import { Group, Menu, Stack, Table, Text } from "@mantine/core"; import { - IconExternalLink, - IconEyeOff, - IconLink, - IconPlus, - IconRefresh, - IconSettings -} from "@tabler/icons-react"; + ArrowSquareOut, + ArrowsClockwise, + EyeSlash, + Gear, + Link, + Plus +} from "@phosphor-icons/react"; import { IconColor, IconSize } from "../../../lib/style-constants"; import { copyUrlToClipboard, makeUrl, openUrlInNewTab } from "../../../lib/url"; import { Fragment, PropsWithChildren, ReactNode, useCallback } from "react"; @@ -45,13 +45,13 @@ export function OpenDocumentItems(props: OpenDocumentItemsProps) { return ( <> <Menu.Item - leftSection={<IconExternalLink size={IconSize.SMALL} />} + leftSection={<ArrowSquareOut size={IconSize.SMALL} />} onClick={() => openUrlInNewTab(url)} > Open document </Menu.Item> <Menu.Item - leftSection={<IconLink size={IconSize.SMALL} />} + leftSection={<Link size={IconSize.SMALL} />} onClick={() => { void copyUrlToClipboard(url); }} @@ -102,14 +102,14 @@ export function QuickInsertItems(props: QuickInsertItemProps) { <> {supportsFasten && ( <Menu.Item - leftSection={<IconPlus size={IconSize.SMALL} />} + leftSection={<Plus size={IconSize.SMALL} />} onClick={() => handleClick(true)} > Quick insert and fasten </Menu.Item> )} <Menu.Item - leftSection={<IconPlus size={IconSize.SMALL} />} + leftSection={<Plus size={IconSize.SMALL} />} onClick={() => handleClick(false)} > Quick insert @@ -207,10 +207,10 @@ export function CardTitle(props: CardTitleProps) { {/* After the badge: toggling visibility would otherwise shift the badge, dragging its open hover card out from under the cursor. */} {isHidden && ( - <IconEyeOff + <EyeSlash size={IconSize.SMALL} color={IconColor.YELLOW} - title="Hidden" + alt="Hidden" /> )} </Group> @@ -286,7 +286,7 @@ export function AdminOptionsSubmenu(props: PropsWithChildren): ReactNode { <Menu.Sub.Target> <Menu.Sub.Item color="yellow" - leftSection={<IconSettings size={IconSize.SMALL} />} + leftSection={<Gear size={IconSize.SMALL} />} > Admin options </Menu.Sub.Item> @@ -311,7 +311,7 @@ export function ReloadThumbnailMenuItem( ); return ( <Menu.Item - leftSection={<IconRefresh size={IconSize.SMALL} />} + leftSection={<ArrowsClockwise size={IconSize.SMALL} />} onClick={() => { reloadThumbnailMutation.mutate(); }} diff --git a/src/frontend/features/library/components/group-card.tsx b/src/frontend/features/library/components/group-card.tsx index 6e5095017..d8a4fa742 100644 --- a/src/frontend/features/library/components/group-card.tsx +++ b/src/frontend/features/library/components/group-card.tsx @@ -1,10 +1,5 @@ import { Menu } from "@mantine/core"; -import { - IconArrowRight, - IconEye, - IconEyeOff, - IconTrash -} from "@tabler/icons-react"; +import { ArrowRight, Eye, EyeSlash, Trash } from "@phosphor-icons/react"; import { IconSize } from "../../../lib/style-constants"; import { useNavigate } from "@tanstack/react-router"; import { PropsWithChildren, ReactNode } from "react"; @@ -63,7 +58,7 @@ export function GroupCard(props: GroupCardProps): ReactNode { } /> } - rightSection={<IconArrowRight size={IconSize.SMALL} />} + rightSection={<ArrowRight size={IconSize.SMALL} />} moreButton={false} menuItems={<GroupMenuItems group={group} />} /> @@ -138,7 +133,7 @@ function ShowAllElementsMenuItem({ return ( <Menu.Item color="blue" - leftSection={<IconEye size={IconSize.SMALL} />} + leftSection={<Eye size={IconSize.SMALL} />} onClick={() => mutation.mutate()} > Show all elements @@ -155,7 +150,7 @@ function HideAllElementsMenuItem({ return ( <Menu.Item color="red" - leftSection={<IconEyeOff size={IconSize.SMALL} />} + leftSection={<EyeSlash size={IconSize.SMALL} />} onClick={() => mutation.mutate()} > Hide all elements @@ -180,7 +175,7 @@ function DeleteGroupMenuItem({ groupId }: { groupId: string }): ReactNode { return ( <Menu.Item - leftSection={<IconTrash size={IconSize.SMALL} />} + leftSection={<Trash size={IconSize.SMALL} />} color="red" onClick={() => mutation.mutate()} > diff --git a/src/frontend/features/library/components/reload-groups-button.tsx b/src/frontend/features/library/components/reload-groups-button.tsx index c7ab65966..3f0729ea6 100644 --- a/src/frontend/features/library/components/reload-groups-button.tsx +++ b/src/frontend/features/library/components/reload-groups-button.tsx @@ -1,6 +1,6 @@ import { Button } from "@mantine/core"; import { modals } from "@mantine/modals"; -import { IconRefresh } from "@tabler/icons-react"; +import { ArrowsClockwise } from "@phosphor-icons/react"; import { IconSize } from "../../../lib/style-constants"; import { ReactNode } from "react"; import { showInfoToast } from "../../../lib/notifications"; @@ -66,7 +66,7 @@ export function ReloadGroupsButton(props: ReloadGroupsButtonProps): ReactNode { <Button variant="light" color={reloadAll ? "red" : "blue"} - leftSection={<IconRefresh size={IconSize.SMALL} />} + leftSection={<ArrowsClockwise size={IconSize.SMALL} />} onClick={handleClick} loading={mutation.isPending} > diff --git a/src/frontend/features/search/components/search-errors.tsx b/src/frontend/features/search/components/search-errors.tsx index 1e98ece4c..7fd31ff7a 100644 --- a/src/frontend/features/search/components/search-errors.tsx +++ b/src/frontend/features/search/components/search-errors.tsx @@ -1,5 +1,5 @@ import { Alert, Button, Group } from "@mantine/core"; -import { IconHeartBroken, IconSearch } from "@tabler/icons-react"; +import { HeartBreak, MagnifyingGlass } from "@phosphor-icons/react"; import { HeartIconColor, IconColor, @@ -76,9 +76,9 @@ export function NoSearchResultError( const icon = objectLabel === "search result" ? ( - <IconSearch size={IconSize.LARGE} color={IconColor.YELLOW} /> + <MagnifyingGlass size={IconSize.LARGE} color={IconColor.YELLOW} /> ) : ( - <IconHeartBroken size={IconSize.LARGE} color={HeartIconColor} /> + <HeartBreak size={IconSize.LARGE} color={HeartIconColor} /> ); if (filtered.byGroup > 0) { @@ -125,7 +125,7 @@ function SearchAllButton(props: SearchAllButtonProps): ReactNode { const small = props.small ?? false; return ( <Button - leftSection={<IconSearch size={IconSize.SMALL} />} + leftSection={<MagnifyingGlass size={IconSize.SMALL} />} size={small ? "xs" : undefined} onClick={() => { void navigate({ diff --git a/src/frontend/features/settings/components/vendor-filters.tsx b/src/frontend/features/settings/components/vendor-filters.tsx index ecbfe7a00..15fe21513 100644 --- a/src/frontend/features/settings/components/vendor-filters.tsx +++ b/src/frontend/features/settings/components/vendor-filters.tsx @@ -1,5 +1,5 @@ import { ActionIcon, Button, Menu } from "@mantine/core"; -import { IconFilter, IconFilterOff } from "@tabler/icons-react"; +import { Funnel, FunnelX } from "@phosphor-icons/react"; import { HEADER_CONTROL_COLOR, IconSize } from "../../../lib/style-constants"; import { ReactNode } from "react"; import { getVendorName } from "@backend/features/library/vendors"; @@ -31,7 +31,7 @@ export function ClearFiltersButton(props: ClearFiltersButtonProps): ReactNode { disabled={areAllTagsActive} variant="default" size={small ? "xs" : undefined} - leftSection={<IconFilterOff size={IconSize.SMALL} />} + leftSection={<FunnelX size={IconSize.SMALL} />} onClick={() => { setUiState({ vendorFilters: undefined }); }} @@ -69,7 +69,7 @@ export function VendorMenu(): ReactNode { </Menu.CheckboxGroup> <Menu.Divider /> <Menu.Item - leftSection={<IconFilterOff size={IconSize.SMALL} />} + leftSection={<FunnelX size={IconSize.SMALL} />} disabled={!hasFilters} onClick={() => setUiState({ vendorFilters: undefined })} > @@ -87,7 +87,7 @@ export function VendorMenu(): ReactNode { size="input-sm" title="Filter vendors" > - <IconFilter size={IconSize.CONTROL} /> + <Funnel size={IconSize.CONTROL} /> </ActionIcon> </AppContextMenu> ); diff --git a/src/frontend/features/thumbnails/components/thumbnail.tsx b/src/frontend/features/thumbnails/components/thumbnail.tsx index 5038f0680..49501c1f9 100644 --- a/src/frontend/features/thumbnails/components/thumbnail.tsx +++ b/src/frontend/features/thumbnails/components/thumbnail.tsx @@ -4,7 +4,7 @@ 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 { IconHelp } from "@tabler/icons-react"; +import { Question } from "@phosphor-icons/react"; import { ComponentPropsWithRef, ReactNode } from "react"; import { DEFAULT_CANONICAL_CONFIGURATION } from "@backend/features/configurations/canonical"; @@ -122,7 +122,7 @@ function Thumbnail(props: ThumbnailProps): ReactNode { let content; if (url === undefined || imageQuery.isError) { - content = <IconHelp size={spinnerSize} />; + content = <Question size={spinnerSize} />; } else if (imageQuery.isPending) { content = <Loader size={spinnerSize} />; } else { diff --git a/src/frontend/lib/notifications.tsx b/src/frontend/lib/notifications.tsx index 350ec2f65..a42d41b22 100644 --- a/src/frontend/lib/notifications.tsx +++ b/src/frontend/lib/notifications.tsx @@ -1,10 +1,6 @@ import { notifications } from "@mantine/notifications"; -import { - IconInfoCircle, - IconCircleCheck, - IconCircleX, - ReactNode -} from "@tabler/icons-react"; +import type { ReactNode } from "react"; +import { CheckCircle, Info, XCircle } from "@phosphor-icons/react"; import { IconSize } from "./style-constants"; import { Group, Button } from "@mantine/core"; @@ -65,7 +61,7 @@ export function showInfoToast(message: string, id?: string): string { return showToast({ id, color: "blue", - icon: <IconInfoCircle size={IconSize.MEDIUM} />, + icon: <Info size={IconSize.MEDIUM} />, message }); } @@ -85,7 +81,7 @@ export function showSuccessToast(message: string, id?: string): string { return showToast({ id, color: "green", - icon: <IconCircleCheck size={IconSize.MEDIUM} />, + icon: <CheckCircle size={IconSize.MEDIUM} />, message }); } @@ -94,7 +90,7 @@ export function showErrorToast(message: string, id?: string): string { return showToast({ id, color: "red", - icon: <IconCircleX size={IconSize.MEDIUM} />, + icon: <XCircle size={IconSize.MEDIUM} />, message }); } diff --git a/src/frontend/lib/style-constants.ts b/src/frontend/lib/style-constants.ts index 8b8d35f59..bc39d2c08 100644 --- a/src/frontend/lib/style-constants.ts +++ b/src/frontend/lib/style-constants.ts @@ -1,5 +1,5 @@ /** - * Standard icon sizes to pass to Tabler icons. + * Standard icon sizes to pass to Phosphor icons. */ export enum IconSize { /** Icons on badges */ @@ -48,7 +48,7 @@ export const HEADER_CONTROL_COLOR = "#fff"; export const HeartIconColor = "var(--mantine-color-red-6)"; /** - * Icon color intents for use with Tabler icons. + * Icon color intents for use with Phosphor icons. * For native mantine components, just use yellow, red, blue, etc. directly. */ export enum IconColor { diff --git a/src/frontend/lib/url.tsx b/src/frontend/lib/url.tsx index 849075bbd..b03b91b72 100644 --- a/src/frontend/lib/url.tsx +++ b/src/frontend/lib/url.tsx @@ -10,7 +10,7 @@ import { } from "@backend/lib/onshape/path"; import { encodeConfigurationForQuery } from "@backend/features/configurations/utils"; import { notifications } from "@mantine/notifications"; -import { IconLink } from "@tabler/icons-react"; +import { Link } from "@phosphor-icons/react"; import { IconSize } from "./style-constants"; export function makeUrl(path: ConfigurablePath): string; @@ -64,7 +64,7 @@ export async function copyUrlToClipboard(url: string): Promise<void> { await navigator.clipboard.writeText(url); notifications.show({ message: "Link copied to clipboard.", - icon: <IconLink size={IconSize.MEDIUM} />, + icon: <Link size={IconSize.MEDIUM} />, color: "blue", autoClose: 3000 }); diff --git a/src/frontend/routeTree.gen.ts b/src/frontend/routeTree.gen.ts index dea2f3a0d..a51efd206 100644 --- a/src/frontend/routeTree.gen.ts +++ b/src/frontend/routeTree.gen.ts @@ -15,7 +15,6 @@ import { Route as PagesSafariErrorRouteImport } from './routes/_pages/safari-err import { Route as PagesLicenseRouteImport } from './routes/_pages/license' import { Route as PagesGrantDeniedRouteImport } from './routes/_pages/grant-denied' import { Route as PagesCookieErrorRouteImport } from './routes/_pages/cookie-error' -import { Route as PagesBetaCompleteRouteImport } from './routes/_pages/beta-complete' import { Route as AppLibraryLibraryIdRouteRouteImport } from './routes/app/library/$libraryId/route' import { Route as AppLibraryLibraryIdIndexRouteImport } from './routes/app/library/$libraryId/index' import { Route as AppLibraryLibraryIdGroupsGroupIdRouteImport } from './routes/app/library/$libraryId/groups/$groupId' @@ -50,11 +49,6 @@ const PagesCookieErrorRoute = PagesCookieErrorRouteImport.update({ path: '/cookie-error', getParentRoute: () => rootRouteImport, } as any) -const PagesBetaCompleteRoute = PagesBetaCompleteRouteImport.update({ - id: '/_pages/beta-complete', - path: '/beta-complete', - getParentRoute: () => rootRouteImport, -} as any) const AppLibraryLibraryIdRouteRoute = AppLibraryLibraryIdRouteRouteImport.update({ id: '/library/$libraryId', @@ -77,7 +71,6 @@ const AppLibraryLibraryIdGroupsGroupIdRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute '/app': typeof AppRouteRouteWithChildren - '/beta-complete': typeof PagesBetaCompleteRoute '/cookie-error': typeof PagesCookieErrorRoute '/grant-denied': typeof PagesGrantDeniedRoute '/license': typeof PagesLicenseRoute @@ -89,7 +82,6 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/app': typeof AppRouteRouteWithChildren - '/beta-complete': typeof PagesBetaCompleteRoute '/cookie-error': typeof PagesCookieErrorRoute '/grant-denied': typeof PagesGrantDeniedRoute '/license': typeof PagesLicenseRoute @@ -101,7 +93,6 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/app': typeof AppRouteRouteWithChildren - '/_pages/beta-complete': typeof PagesBetaCompleteRoute '/_pages/cookie-error': typeof PagesCookieErrorRoute '/_pages/grant-denied': typeof PagesGrantDeniedRoute '/_pages/license': typeof PagesLicenseRoute @@ -115,7 +106,6 @@ export interface FileRouteTypes { fullPaths: | '/' | '/app' - | '/beta-complete' | '/cookie-error' | '/grant-denied' | '/license' @@ -127,7 +117,6 @@ export interface FileRouteTypes { to: | '/' | '/app' - | '/beta-complete' | '/cookie-error' | '/grant-denied' | '/license' @@ -138,7 +127,6 @@ export interface FileRouteTypes { | '__root__' | '/' | '/app' - | '/_pages/beta-complete' | '/_pages/cookie-error' | '/_pages/grant-denied' | '/_pages/license' @@ -151,7 +139,6 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute AppRouteRoute: typeof AppRouteRouteWithChildren - PagesBetaCompleteRoute: typeof PagesBetaCompleteRoute PagesCookieErrorRoute: typeof PagesCookieErrorRoute PagesGrantDeniedRoute: typeof PagesGrantDeniedRoute PagesLicenseRoute: typeof PagesLicenseRoute @@ -202,13 +189,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PagesCookieErrorRouteImport parentRoute: typeof rootRouteImport } - '/_pages/beta-complete': { - id: '/_pages/beta-complete' - path: '/beta-complete' - fullPath: '/beta-complete' - preLoaderRoute: typeof PagesBetaCompleteRouteImport - parentRoute: typeof rootRouteImport - } '/app/library/$libraryId': { id: '/app/library/$libraryId' path: '/library/$libraryId' @@ -265,7 +245,6 @@ const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AppRouteRoute: AppRouteRouteWithChildren, - PagesBetaCompleteRoute: PagesBetaCompleteRoute, PagesCookieErrorRoute: PagesCookieErrorRoute, PagesGrantDeniedRoute: PagesGrantDeniedRoute, PagesLicenseRoute: PagesLicenseRoute, diff --git a/src/frontend/routes/_pages/beta-complete.tsx b/src/frontend/routes/_pages/beta-complete.tsx deleted file mode 100644 index f7986b9b2..000000000 --- a/src/frontend/routes/_pages/beta-complete.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { JSX } from "react"; -import { createFileRoute } from "@tanstack/react-router"; -import { OpenUrlButton } from "../../components/open-url-button"; -import { PageError } from "../../components/app-zero-state"; - -export const Route = createFileRoute("/_pages/beta-complete")({ - component: BetaComplete -}); - -const URL = - "https://cad.onshape.com/appstore/apps/Manufacturers%20Models/6004ec5e83c40b107c183347"; - -function BetaComplete(): JSX.Element { - const frcDesignAppButton = <OpenUrlButton text="FRCDesignApp" url={URL} />; - - return ( - <PageError - title="The FRCDesignApp Beta has concluded." - description="The Beta is now over, and the FRCDesignApp has replaced the existing MKCad app. If you don't have the MKCad app, you can get it from the Onshape App Store. Thank you for participating!" - action={frcDesignAppButton} - /> - ); -} diff --git a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx index ae9276d88..b5f5bb435 100644 --- a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx +++ b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx @@ -6,11 +6,7 @@ import { useParams } from "@tanstack/react-router"; import { Box, Button, Group, Text } from "@mantine/core"; -import { - IconAlertTriangle, - IconArrowBackUp, - IconArrowLeft -} from "@tabler/icons-react"; +import { ArrowLeft, ArrowUUpLeft, Warning } from "@phosphor-icons/react"; import { BORDER, FontWeight, @@ -73,7 +69,7 @@ function GroupList(): ReactNode { justifyUp action={ <Button - leftSection={<IconArrowBackUp size={IconSize.SMALL} />} + leftSection={<ArrowUUpLeft size={IconSize.SMALL} />} onClick={() => { void navigate({ to: "/app/library/$libraryId", @@ -137,7 +133,7 @@ function GroupHeaderRow({ group }: { group: GroupOut }): ReactNode { > <Group wrap="nowrap" justify="space-between"> <Group gap="sm"> - <IconArrowLeft size={IconSize.MEDIUM} /> + <ArrowLeft size={IconSize.MEDIUM} /> <Text size="md" fw={FontWeight.SEMI_BOLD} truncate> {group.name} </Text> @@ -183,10 +179,7 @@ export function GroupListContent(props: GroupListCardsProps): ReactNode { return ( <SectionError icon={ - <IconAlertTriangle - size={IconSize.LARGE} - color={IconColor.YELLOW} - /> + <Warning size={IconSize.LARGE} color={IconColor.YELLOW} /> } title="All elements are hidden by filters" action={<ClearFiltersButton />} diff --git a/src/frontend/routes/app/library/$libraryId/index.tsx b/src/frontend/routes/app/library/$libraryId/index.tsx index 7678d26fb..f72603f5e 100644 --- a/src/frontend/routes/app/library/$libraryId/index.tsx +++ b/src/frontend/routes/app/library/$libraryId/index.tsx @@ -1,6 +1,6 @@ import { createFileRoute, Outlet } from "@tanstack/react-router"; import { Accordion } from "@mantine/core"; -import { IconBook, IconSearch } from "@tabler/icons-react"; +import { Book, MagnifyingGlass } from "@phosphor-icons/react"; import { BORDER, IconSize, @@ -33,105 +33,107 @@ export const Route = createFileRoute("/app/library/$libraryId/")({ } }); +/** One accordion section: what it shows, and where its open state lives. */ +interface Section { + value: string; + icon: ReactNode; + title: string; + panel: ReactNode; + opened: boolean; + setOpened: (opened: boolean) => void; +} + function HomeList(): ReactNode { const [uiState, setUiState] = useUiState(); + // Not persisted: search results open on every visit, unlike the library. const [isSearchOpen, setIsSearchOpen] = useState(true); const libraryId = useLibraryId(); - // Favorites are per-user and hidden until signed in. const isSignedIn = useIsSignedIn(); - const isSearch = !!uiState.searchQuery; - const listKey = isSearch ? "search" : "library"; + const sections: Section[] = []; - const value: string[] = []; - if (uiState.isFavoritesOpen) { - value.push("favorites"); - } - if (isSearch ? isSearchOpen : uiState.isLibraryOpen) { - value.push(listKey); + // Favorites are per-user and hidden until signed in. + if (isSignedIn) { + sections.push({ + value: "favorites", + icon: <HeartIcon />, + title: "Favorites", + panel: <FavoritesList />, + opened: uiState.isFavoritesOpen, + setOpened: (opened) => setUiState({ isFavoritesOpen: opened }) + }); } - const handleChange = (newValue: string[]) => { - setUiState({ isFavoritesOpen: newValue.includes("favorites") }); - if (isSearch) { - setIsSearchOpen(newValue.includes(listKey)); - } else { - setUiState({ isLibraryOpen: newValue.includes(listKey) }); - } - }; - - const favoritesAccordion = ( - <Accordion.Item value="favorites"> - <Accordion.Control icon={<HeartIcon />} className="interactive"> - Favorites - </Accordion.Control> - <Accordion.Panel> - <FavoritesList /> - </Accordion.Panel> - </Accordion.Item> + // One slot below favorites, showing search results while a query is active + // and the library otherwise. The differing `value` remounts it on the swap. + sections.push( + uiState.searchQuery + ? { + value: "search", + icon: ( + <MagnifyingGlass + size={IconSize.MEDIUM} + color={PrimaryColor.FILLED} + /> + ), + title: "Search Results", + panel: ( + <SearchResults + query={uiState.searchQuery} + filters={{ vendors: uiState.vendorFilters }} + /> + ), + opened: isSearchOpen, + setOpened: setIsSearchOpen + } + : { + value: "library", + icon: ( + <Book + size={IconSize.MEDIUM} + color={PrimaryColor.FILLED} + /> + ), + title: getLibraryName(libraryId), + panel: <LibraryList />, + opened: uiState.isLibraryOpen, + setOpened: (opened) => setUiState({ isLibraryOpen: opened }) + } ); - let childAccordion: ReactNode; - if (isSearch) { - childAccordion = ( - <Accordion.Item key={listKey} value={listKey}> - <Accordion.Control - className="interactive" - icon={ - <IconSearch - size={IconSize.MEDIUM} - color={PrimaryColor.FILLED} - /> - } - > - Search Results - </Accordion.Control> - <Accordion.Panel> - <SearchResults - query={uiState.searchQuery} - filters={{ vendors: uiState.vendorFilters }} - /> - </Accordion.Panel> - </Accordion.Item> - ); - } else { - childAccordion = ( - <Accordion.Item key={listKey} value={listKey}> - <Accordion.Control - className="interactive" - icon={ - <IconBook - size={IconSize.MEDIUM} - color={PrimaryColor.FILLED} - /> - } - > - {getLibraryName(libraryId)} - </Accordion.Control> - <Accordion.Panel> - <LibraryList /> - </Accordion.Panel> - </Accordion.Item> - ); - } + const handleChange = (opened: string[]) => { + for (const section of sections) { + section.setOpened(opened.includes(section.value)); + } + }; return ( <> <Accordion multiple variant="unstyled" - value={value} + value={sections + .filter((section) => section.opened) + .map((section) => section.value)} onChange={handleChange} styles={{ - content: { - padding: 0, - borderTop: BORDER, - borderBottom: BORDER - } + // On the control, so a collapsed section still divides from + // the next one; content closes off an open one. + control: { borderBottom: BORDER }, + content: { padding: 0, borderBottom: BORDER } }} > - {isSignedIn && favoritesAccordion} - {childAccordion} + {sections.map((section) => ( + <Accordion.Item key={section.value} value={section.value}> + <Accordion.Control + icon={section.icon} + className="interactive" + > + {section.title} + </Accordion.Control> + <Accordion.Panel>{section.panel}</Accordion.Panel> + </Accordion.Item> + ))} </Accordion> <Outlet /> </> From 7aa07e7cf7343ea3877665b54f65a1dfedb0b95b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 21 Aug 2026 03:50:52 +0000 Subject: [PATCH 13/56] fix: stop flashing the sign-in button, and clipping the toast's action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useAccessData falls back to a signed-out placeholder while access-data is in flight, so the navbar rendered the sign-in button on every load and then removed it once the response said the caller was already signed in. ResolvedAccessData now reports isLoaded, and the button waits for it. Anything gated on signedIn being true was already safe — it renders nothing until the data arrives; only the signed-out branch could flash. The toast's action button sat in a wrap="nowrap" row with no flex-shrink of its own, so a long message shrank it and clipped the label — 31px of the 47px "Sign in" needed. It no longer shrinks, and the message wraps instead. Both verified in the running app: the button never appears across a delayed access-data response, and the label now measures 47/47. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- src/frontend/components/app-navbar.tsx | 8 +++++--- src/frontend/features/auth/access-level.tsx | 18 ++++++++++++++---- src/frontend/lib/notifications.tsx | 12 ++++++++++-- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/frontend/components/app-navbar.tsx b/src/frontend/components/app-navbar.tsx index ad496a82c..3bcfb8ed0 100644 --- a/src/frontend/components/app-navbar.tsx +++ b/src/frontend/components/app-navbar.tsx @@ -20,7 +20,7 @@ import { useUiState } from "../lib/ui-state"; import { getLibraryName, useLibraryId } from "../features/library/library-path"; import { RequireAccessLevel } from "../features/auth/access-level"; import { useSaveSettings } from "../features/settings/settings"; -import { useIsSignedIn } from "../features/auth/access-level"; +import { useAccessData } from "../features/auth/access-level"; import { startSignIn } from "../features/auth/sign-in"; import { useJobStatus } from "../lib/refresh"; import { LibraryId } from "@backend/features/library/library-id"; @@ -59,8 +59,10 @@ export function AppNavbar(): ReactNode { * the current location, after which access-data reports the caller signed in. */ function SignInButton(): ReactNode { - const isSignedIn = useIsSignedIn(); - if (isSignedIn) return null; + const { signedIn, isLoaded } = useAccessData(); + // Waiting rather than assuming signed out: the placeholder would flash the + // button on every load for a caller who is already signed in. + if (!isLoaded || signedIn) return null; return ( <Button diff --git a/src/frontend/features/auth/access-level.tsx b/src/frontend/features/auth/access-level.tsx index 846c2581c..74b97382b 100644 --- a/src/frontend/features/auth/access-level.tsx +++ b/src/frontend/features/auth/access-level.tsx @@ -31,6 +31,12 @@ export function getAccessDataQuery() { /** Server access plus the level the app is currently viewed as. */ export interface ResolvedAccessData extends AccessData { currentAccessLevel: AccessLevel; + /** + * False until access-data resolves, while the rest of these are still the + * placeholder. Anything that would render differently for a signed-out + * caller has to wait for this, or it flashes the wrong state. + */ + isLoaded: boolean; } /** @@ -38,8 +44,8 @@ export interface ResolvedAccessData extends AccessData { * drop below the granted max), so it survives the query refetching on navigation. */ export function useAccessData(): ResolvedAccessData { - const serverData = - useQuery(getAccessDataQuery()).data ?? DEFAULT_ACCESS_DATA; + const { data } = useQuery(getAccessDataQuery()); + const serverData = data ?? DEFAULT_ACCESS_DATA; const chosenLevel = useUiState()[0].accessLevel; return useMemo(() => { const desired = chosenLevel ?? DEFAULT_ACCESS_LEVEL; @@ -50,8 +56,12 @@ export function useAccessData(): ResolvedAccessData { ) ? desired : serverData.maxAccessLevel; - return { ...serverData, currentAccessLevel }; - }, [serverData, chosenLevel]); + return { + ...serverData, + currentAccessLevel, + isLoaded: data !== undefined + }; + }, [data, serverData, chosenLevel]); } /** Whether the caller is signed in to Onshape (from access-data). */ diff --git a/src/frontend/lib/notifications.tsx b/src/frontend/lib/notifications.tsx index a42d41b22..be6aea03d 100644 --- a/src/frontend/lib/notifications.tsx +++ b/src/frontend/lib/notifications.tsx @@ -21,8 +21,16 @@ export function renderNotification( } return ( <Group justify="space-between" wrap="nowrap" gap="sm"> - <span>{message}</span> - <Button size="compact-sm" variant="subtle" onClick={action.onClick}> + {/* minWidth lets the message wrap rather than force the row wider. */} + <span style={{ minWidth: 0 }}>{message}</span> + {/* The row does not wrap, so without this the button is the flex + item that shrinks and its label gets clipped. */} + <Button + size="compact-sm" + variant="subtle" + onClick={action.onClick} + style={{ flexShrink: 0 }} + > {action.text} </Button> </Group> From 83cd4928547b1debc71be7b20ae58b2a5594cc37 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 21 Aug 2026 12:18:28 +0000 Subject: [PATCH 14/56] fix: let a toast size to its content instead of wrapping at 440px Mantine's Notifications containerWidth defaults to 440px and never grows, so a message with an action button wrapped to two lines at every window size - measured identically at 800px and 1400px wide. Setting it to max-content sizes the toast to its content and lets Mantine clamp it to the viewport when there is genuinely no room. Measured across widths: 360 -> 328px/3 lines, 420 -> 388px/2 lines, 900 and 1400 -> 514px/1 line, none overflowing the viewport. flexShrink on the action button stays: it is what keeps the label whole in the narrow case where the row still has to wrap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- src/frontend/lib/notifications.tsx | 5 ++--- src/frontend/routes/__root.tsx | 4 ++++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/frontend/lib/notifications.tsx b/src/frontend/lib/notifications.tsx index be6aea03d..de7df9386 100644 --- a/src/frontend/lib/notifications.tsx +++ b/src/frontend/lib/notifications.tsx @@ -21,10 +21,9 @@ export function renderNotification( } return ( <Group justify="space-between" wrap="nowrap" gap="sm"> - {/* minWidth lets the message wrap rather than force the row wider. */} + {/* Only reachable on a window too narrow for the row: the message + is what gives, and the button keeps its label intact. */} <span style={{ minWidth: 0 }}>{message}</span> - {/* The row does not wrap, so without this the button is the flex - item that shrinks and its label gets clipped. */} <Button size="compact-sm" variant="subtle" diff --git a/src/frontend/routes/__root.tsx b/src/frontend/routes/__root.tsx index f94f7cfa8..106cd8c80 100644 --- a/src/frontend/routes/__root.tsx +++ b/src/frontend/routes/__root.tsx @@ -48,6 +48,10 @@ function RootComponent(): ReactNode { position="bottom-center" limit={3} autoClose={4000} + // Mantine pins this at 440px otherwise, wrapping a + // message with an action button even on a wide window. + // Mantine clamps it to the viewport on a narrow one. + containerWidth="max-content" /> <Outlet /> </ModalsProvider> From fa28b993287c842384083e29d54c5e0e844c9db3 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 21 Aug 2026 12:30:07 +0000 Subject: [PATCH 15/56] refactor: use the query's own isPending, and resolve access at save time isLoaded was re-deriving what TanStack already reports. useAccessData now passes through isPending, which also fixes a failure mode the derived version had: on an errored access-data, data stays undefined, so the old flag would have hidden the sign-in button forever instead of offering it. useSaveSettings had the same placeholder problem in the other negative gate - a snapshot reading signed-out would silently persist to localStorage for a user who has a server-side row. It now resolves access data when the mutation runs, so it cannot act on a placeholder. Verified against a delayed access-data: no button while signed in, button once a signed-out answer lands, button offered after the request fails outright. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- src/frontend/components/app-navbar.tsx | 4 ++-- src/frontend/features/auth/access-level.tsx | 18 +++++++----------- src/frontend/features/settings/settings.ts | 12 ++++++++---- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/frontend/components/app-navbar.tsx b/src/frontend/components/app-navbar.tsx index 3bcfb8ed0..2333d53ab 100644 --- a/src/frontend/components/app-navbar.tsx +++ b/src/frontend/components/app-navbar.tsx @@ -59,10 +59,10 @@ export function AppNavbar(): ReactNode { * the current location, after which access-data reports the caller signed in. */ function SignInButton(): ReactNode { - const { signedIn, isLoaded } = useAccessData(); + const { signedIn, isPending } = useAccessData(); // Waiting rather than assuming signed out: the placeholder would flash the // button on every load for a caller who is already signed in. - if (!isLoaded || signedIn) return null; + if (isPending || signedIn) return null; return ( <Button diff --git a/src/frontend/features/auth/access-level.tsx b/src/frontend/features/auth/access-level.tsx index 74b97382b..b9999fc61 100644 --- a/src/frontend/features/auth/access-level.tsx +++ b/src/frontend/features/auth/access-level.tsx @@ -32,11 +32,11 @@ export function getAccessDataQuery() { export interface ResolvedAccessData extends AccessData { currentAccessLevel: AccessLevel; /** - * False until access-data resolves, while the rest of these are still the - * placeholder. Anything that would render differently for a signed-out - * caller has to wait for this, or it flashes the wrong state. + * The query's own pending flag. While set, the rest of these are the + * placeholder, so anything that renders for a *signed-out* caller has to + * wait or it flashes; positive gates can just read `signedIn`. */ - isLoaded: boolean; + isPending: boolean; } /** @@ -44,7 +44,7 @@ export interface ResolvedAccessData extends AccessData { * drop below the granted max), so it survives the query refetching on navigation. */ export function useAccessData(): ResolvedAccessData { - const { data } = useQuery(getAccessDataQuery()); + const { data, isPending } = useQuery(getAccessDataQuery()); const serverData = data ?? DEFAULT_ACCESS_DATA; const chosenLevel = useUiState()[0].accessLevel; return useMemo(() => { @@ -56,12 +56,8 @@ export function useAccessData(): ResolvedAccessData { ) ? desired : serverData.maxAccessLevel; - return { - ...serverData, - currentAccessLevel, - isLoaded: data !== undefined - }; - }, [data, serverData, chosenLevel]); + return { ...serverData, currentAccessLevel, isPending }; + }, [serverData, chosenLevel, isPending]); } /** Whether the caller is signed in to Onshape (from access-data). */ diff --git a/src/frontend/features/settings/settings.ts b/src/frontend/features/settings/settings.ts index 7229507ae..f5e75733a 100644 --- a/src/frontend/features/settings/settings.ts +++ b/src/frontend/features/settings/settings.ts @@ -2,17 +2,21 @@ import { useMutation } from "@tanstack/react-query"; import type { SettingsUpdate } from "@backend/features/settings/settings"; import { showErrorToast } from "../../lib/notifications"; import { apiPost } from "../../lib/api-client"; -import { useIsSignedIn } from "../auth/access-level"; +import { getAccessDataQuery } from "../auth/access-level"; +import { queryClient } from "../../lib/query-client"; import { writeLocalSettings } from "./local-settings"; export function useSaveSettings() { - const isSignedIn = useIsSignedIn(); - const { mutate } = useMutation({ mutationKey: ["settings"], mutationFn: async (newSettings: SettingsUpdate) => { + // Resolved here rather than read off a render: a placeholder that + // says signed out would silently persist locally for a user who + // has a server-side row. + const { signedIn } = + await queryClient.ensureQueryData(getAccessDataQuery()); // Not signed in: no server-side user row; persist locally instead. - if (!isSignedIn) { + if (!signedIn) { writeLocalSettings(newSettings); return; } From 287815bf5fc79877046bf398fff5b48733872a8f Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 21 Aug 2026 13:41:18 +0000 Subject: [PATCH 16/56] refactor: standardize route validation, and tighten the editor gate Editing now requires a session as well as the access level. Access level alone let a signed-out caller through wherever it is granted without one (a dev ACCESS_LEVEL_OVERRIDE), and answered 403 rather than 401 for everyone else. Both cases are now covered by tests. Routes: every body and query is a zod schema, and every entity id comes from a route-params helper. That adds a favoriteRoute/getFavoriteParam pair and moves three paths onto the existing convention - /favorite/:favoriteId, /default-configuration/favorite/:favoriteId, /configuration/insertable/:insertableId, /reload-group-thumbnail/group/ :groupId - with the frontend using the matching to*Path helpers. The previously-unchecked c.req.json<T>() casts on favorites, groups, insertables and settings were assertions, not validation. cache.ts: the cache-version check was a one-key zod schema guarding a "versioned: false" option nothing ever passed. Both are gone. ThumbnailWorkflow reads the microversion from the insertable it already resolves, rather than carrying one that may be stale by the time it runs. R2 thumbnail metadata is a typed ThumbnailMetadata rather than a bare Record, so an element's own thumbnail is tagged with the empty canonical configuration it represents instead of omitting the field. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- README.md | 4 +- src/backend/features/auth/guards.test.ts | 37 ++++++++ src/backend/features/auth/guards.ts | 20 +++-- .../features/configurations/routes.test.ts | 12 +-- src/backend/features/configurations/routes.ts | 69 ++++++++------- src/backend/features/favorites/routes.test.ts | 10 +-- src/backend/features/favorites/routes.ts | 85 +++++++++---------- src/backend/features/library/groups/routes.ts | 48 ++++++----- .../features/library/insertables/routes.ts | 14 ++- src/backend/features/settings/routes.ts | 34 +++++--- .../features/thumbnails/routes.test.ts | 1 - src/backend/features/thumbnails/routes.ts | 12 ++- src/backend/features/thumbnails/store.ts | 22 ++++- src/backend/features/thumbnails/workflow.ts | 33 ++++--- src/backend/lib/cache.ts | 16 +--- src/backend/lib/route-params.ts | 10 +++ .../favorites/components/favorite-button.tsx | 8 +- .../favorites/components/favorite-menu.tsx | 11 ++- .../insert/components/configurations.tsx | 3 +- src/frontend/features/library/card-hooks.ts | 9 +- src/frontend/features/library/library-path.ts | 4 + 21 files changed, 285 insertions(+), 177 deletions(-) diff --git a/README.md b/README.md index f8acc7dee..4b2634ca7 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,9 @@ To exercise the signed-in-only UI (favorites, insert button) without a real Onshape session, set `FORCE_SIGNED_IN=true` in your `.env`. This is a testing-only escape hatch — it uses a fake user id and Onshape calls it reveals won't actually work, so leave it unset normally. Combine with -`ACCESS_LEVEL_OVERRIDE=admin` to also show editor/admin controls. +`ACCESS_LEVEL_OVERRIDE=admin` to also show editor/admin controls — editor +routes require a session as well as the access level, so the override alone +does not reach them. # Troubleshooting diff --git a/src/backend/features/auth/guards.test.ts b/src/backend/features/auth/guards.test.ts index d5a707ff3..79d20848d 100644 --- a/src/backend/features/auth/guards.test.ts +++ b/src/backend/features/auth/guards.test.ts @@ -1,5 +1,6 @@ import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it } from "vitest"; +import { AccessLevel } from "./access-level"; import { LibraryId } from "../library/library-id"; import { Theme } from "../settings/settings"; import { @@ -47,3 +48,39 @@ describe("requireSignInMiddleware", () => { expect(favorites.status).toBe(200); }); }); + +describe("requireEditorMiddleware", () => { + beforeEach(async () => { + await resetDb(db); + }); + + // Access level alone would admit a signed-out caller wherever it is + // 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, + accessLevel: AccessLevel.ADMIN + }); + + const res = await app.request( + `/api/reload-groups/library/${LibraryId.FRC_DESIGN_LIB}`, + jsonRequest("POST"), + env + ); + expect(res.status).toBe(401); + }); + + it("403s a signed-in caller without editor access", async () => { + const app = createTestApp({ + signedIn: true, + accessLevel: AccessLevel.USER + }); + + const res = await app.request( + `/api/reload-groups/library/${LibraryId.FRC_DESIGN_LIB}`, + jsonRequest("POST"), + env + ); + expect(res.status).toBe(403); + }); +}); diff --git a/src/backend/features/auth/guards.ts b/src/backend/features/auth/guards.ts index e0bdff12a..e3b75fbc2 100644 --- a/src/backend/features/auth/guards.ts +++ b/src/backend/features/auth/guards.ts @@ -2,27 +2,37 @@ import type { MiddlewareHandler } from "hono"; import { HTTPException } from "hono/http-exception"; import { HttpStatus } from "http-status-ts"; -import type { AppContextEnv } from "../../lib/context"; +import type { AppContext, AppContextEnv } from "../../lib/context"; import { hasEditorAccess } from "./access-level"; import { isSignedIn } from "./caller"; -export const requireSignInMiddleware: MiddlewareHandler<AppContextEnv> = async ( - c, - next -) => { +async function requireSignIn(c: AppContext): Promise<void> { if (!(await isSignedIn(c))) { throw new HTTPException(HttpStatus.UNAUTHORIZED, { message: "You must be signed in to Onshape to use this functionality" }); } +} + +export const requireSignInMiddleware: MiddlewareHandler<AppContextEnv> = async ( + c, + next +) => { + await requireSignIn(c); await next(); }; +/** + * Editing implies a session: access level alone would let a signed-out caller + * through wherever it is granted without one (a dev `ACCESS_LEVEL_OVERRIDE`), + * and would answer 403 rather than 401 for everyone else. + */ export const requireEditorMiddleware: MiddlewareHandler<AppContextEnv> = async ( c, next ) => { + await requireSignIn(c); if (!hasEditorAccess(await c.var.getAccessLevel())) { throw new HTTPException(HttpStatus.FORBIDDEN, { message: "You must be on the admin team to use this functionality" diff --git a/src/backend/features/configurations/routes.test.ts b/src/backend/features/configurations/routes.test.ts index fd642d972..5900d050f 100644 --- a/src/backend/features/configurations/routes.test.ts +++ b/src/backend/features/configurations/routes.test.ts @@ -23,13 +23,13 @@ describe("configuration routes", () => { afterEach(() => vi.restoreAllMocks()); - it("GET /configuration/:id returns the stored parameters", async () => { + it("GET /configuration/insertable/:insertableId returns the stored parameters", async () => { await seedPartStudio(db); await seedConfiguration(db); const app = createTestApp(); const res = await app.request( - `/api/configuration/${TEST_PART_STUDIO_ID}?v=abc123`, + `/api/configuration/insertable/${TEST_PART_STUDIO_ID}?v=abc123`, jsonRequest("GET"), env ); @@ -41,7 +41,7 @@ describe("configuration routes", () => { // The element's own part data is the record an unset configuration falls // back to, and it lives on the insertable, not in a configurations row. - it("GET /configuration/:id serves the element's own part data as a record", async () => { + it("GET /configuration/insertable/:insertableId serves the element's own part data as a record", async () => { await seedPartStudio(db, { partData: { partNumber: "WCP-0405", @@ -56,7 +56,7 @@ describe("configuration routes", () => { const app = createTestApp(); const res = await app.request( - `/api/configuration/${TEST_PART_STUDIO_ID}?v=abc123`, + `/api/configuration/insertable/${TEST_PART_STUDIO_ID}?v=abc123`, jsonRequest("GET"), env ); @@ -74,10 +74,10 @@ describe("configuration routes", () => { }); }); - it("GET /configuration/:id 404s for an unknown id", async () => { + it("GET /configuration/insertable/:insertableId 404s for an unknown id", async () => { const app = createTestApp(); const res = await app.request( - "/api/configuration/missing?v=abc123", + "/api/configuration/insertable/missing?v=abc123", jsonRequest("GET"), env ); diff --git a/src/backend/features/configurations/routes.ts b/src/backend/features/configurations/routes.ts index 840dba296..f03b7f95e 100644 --- a/src/backend/features/configurations/routes.ts +++ b/src/backend/features/configurations/routes.ts @@ -1,6 +1,9 @@ import { eq } from "drizzle-orm"; import { CachePolicy, cacheMiddleware } from "../../lib/cache"; +import { z } from "zod"; +import { zValidator } from "@hono/zod-validator"; import { getApp } from "../../lib/context"; +import { getInsertableParam, insertableRoute } from "../../lib/route-params"; import { getDb } from "../../db/client"; import { getUnitInfo } from "../../lib/onshape/endpoints/documents"; import { configurations, insertables } from "../../db/schema"; @@ -8,24 +11,24 @@ import { type ConfigurationResult, type UnitInfo } from "./models"; import { toSearchRecords } from "../search/search-index"; import { toRecords } from "./utils"; import { QuantityType, type Unit } from "./enums"; -import { isInstancePath } from "../../lib/onshape/path"; +import { INSTANCE_TYPES } from "../../lib/onshape/path"; import { HTTPException } from "hono/http-exception"; import { HttpStatus } from "http-status-ts"; export const configurationRoutes = getApp(); -/** GET /api/configuration/:insertableId?v=:microversionId — parameters and records */ +const instancePathQuery = z.object({ + documentId: z.string().min(1), + instanceId: z.string().min(1), + instanceType: z.enum(INSTANCE_TYPES) +}); + +/** GET /api/configuration/insertable/:insertableId?v=:microversionId */ configurationRoutes.get( - "/configuration/:insertableId", + "/configuration" + insertableRoute(), cacheMiddleware(CachePolicy.PUBLIC_CACHE), async (c) => { - const insertableId = c.req.param("insertableId"); - if (!insertableId) { - throw new HTTPException(HttpStatus.BAD_REQUEST, { - message: "insertableId is required" - }); - } - + const insertableId = getInsertableParam(c); const db = getDb(c.env.DB); // Left join: the element's own part data is the fallback record, and it // lives on the insertable whether or not it is configurable. @@ -57,34 +60,30 @@ configurationRoutes.get( ); /** GET /api/unit-info?documentId=X&instanceId=Y&instanceType=v */ -configurationRoutes.get("/unit-info", cacheMiddleware(), async (c) => { - const onshapeApi = await c.var.getOnshapeApi(); - const instancePath = { - documentId: c.req.query("documentId"), - instanceId: c.req.query("instanceId"), - instanceType: c.req.query("instanceType") - }; - if (!isInstancePath(instancePath)) { - throw new HTTPException(HttpStatus.BAD_REQUEST, { - message: "instancePath is required" - }); - } +configurationRoutes.get( + "/unit-info", + cacheMiddleware(), + zValidator("query", instancePathQuery), + async (c) => { + const onshapeApi = await c.var.getOnshapeApi(); + const instancePath = c.req.valid("query"); - const rawUnitInfo = await getUnitInfo(onshapeApi, instancePath); - const units: OnshapeUnit[] = rawUnitInfo.defaultUnits.units; + const rawUnitInfo = await getUnitInfo(onshapeApi, instancePath); + const units: OnshapeUnit[] = rawUnitInfo.defaultUnits.units; - const angleUnit = getDefaultUnit(units, QuantityType.ANGLE); - const lengthUnit = getDefaultUnit(units, QuantityType.LENGTH); + const angleUnit = getDefaultUnit(units, QuantityType.ANGLE); + const lengthUnit = getDefaultUnit(units, QuantityType.LENGTH); - const result: UnitInfo = { - angleUnit, - lengthUnit, - anglePrecision: rawUnitInfo.unitsDisplayPrecision[angleUnit], - lengthPrecision: rawUnitInfo.unitsDisplayPrecision[lengthUnit], - realPrecision: 3 - }; - return c.json(result); -}); + const result: UnitInfo = { + angleUnit, + lengthUnit, + anglePrecision: rawUnitInfo.unitsDisplayPrecision[angleUnit], + lengthPrecision: rawUnitInfo.unitsDisplayPrecision[lengthUnit], + realPrecision: 3 + }; + return c.json(result); + } +); interface OnshapeUnit { key: QuantityType; diff --git a/src/backend/features/favorites/routes.test.ts b/src/backend/features/favorites/routes.test.ts index cedd720f3..0e9e97e0e 100644 --- a/src/backend/features/favorites/routes.test.ts +++ b/src/backend/features/favorites/routes.test.ts @@ -126,14 +126,14 @@ describe("favorites routes", () => { }); }); - describe("DELETE /favorites/:favoriteId", () => { + describe("DELETE /favorite/:favoriteId", () => { it("deletes a favorite owned by the current user", async () => { await seedPartStudio(db); const favoriteId = await seedFavorite(db, TEST_PART_STUDIO_ID); const app = createTestApp(); const res = await app.request( - `/api/favorites/${favoriteId}`, + `/api/favorite/${favoriteId}`, jsonRequest("DELETE"), env ); @@ -153,7 +153,7 @@ describe("favorites routes", () => { const app = createTestApp(); const res = await app.request( - `/api/favorites/${favoriteId}`, + `/api/favorite/${favoriteId}`, jsonRequest("DELETE"), env ); @@ -198,7 +198,7 @@ describe("favorites routes", () => { }); }); - describe("POST /default-configuration/:favoriteId", () => { + describe("POST /default-configuration/favorite/:favoriteId", () => { it("persists the default configuration", async () => { await seedPartStudio(db); const favoriteId = await seedFavorite(db, TEST_PART_STUDIO_ID); @@ -206,7 +206,7 @@ describe("favorites routes", () => { const defaultConfiguration = { "param-id": "value" }; const res = await app.request( - `/api/default-configuration/${favoriteId}`, + `/api/default-configuration/favorite/${favoriteId}`, jsonRequest("POST", { defaultConfiguration }), env ); diff --git a/src/backend/features/favorites/routes.ts b/src/backend/features/favorites/routes.ts index bc4f5cfd5..3c44e5c2f 100644 --- a/src/backend/features/favorites/routes.ts +++ b/src/backend/features/favorites/routes.ts @@ -1,17 +1,33 @@ import { and, asc, eq } from "drizzle-orm"; import { cacheMiddleware } from "../../lib/cache"; import { getApp } from "../../lib/context"; -import { getLibraryParam, libraryRoute } from "../../lib/route-params"; +import { + favoriteRoute, + getFavoriteParam, + getLibraryParam, + libraryRoute +} from "../../lib/route-params"; import { type Db, getDb } from "../../db/client"; import { users, favorites } from "../../db/schema"; import type { Favorite, FavoritesData } from "./dto"; import type { LibraryId } from "../library/library-id"; -import { HttpStatus } from "http-status-ts"; -import { type ParameterValues } from "../configurations/models"; +import { z } from "zod"; +import { zValidator } from "@hono/zod-validator"; import { requireSignInMiddleware } from "../auth/guards"; export const favoriteRoutes = getApp(); +const addFavoriteQuery = z.object({ + insertableId: z.string().min(1), + id: z.string().min(1) +}); + +const favoriteOrderBody = z.object({ favoriteOrder: z.array(z.string()) }); + +const defaultConfigurationBody = z.object({ + defaultConfiguration: z.record(z.string(), z.string()) +}); + async function getFavorites( db: Db, userId: string, @@ -61,18 +77,11 @@ favoriteRoutes.get( favoriteRoutes.post( "/favorites" + libraryRoute(), requireSignInMiddleware, + zValidator("query", addFavoriteQuery), async (c) => { const libraryId = getLibraryParam(c); const userId = await c.var.getUserId(); - const insertableId = c.req.query("insertableId"); - const favoriteId = c.req.query("id"); - if (!insertableId) - return c.json( - { error: "insertableId required" }, - HttpStatus.BAD_REQUEST - ); - if (!favoriteId) - return c.json({ error: "id required" }, HttpStatus.BAD_REQUEST); + const { insertableId, id: favoriteId } = c.req.valid("query"); const db = getDb(c.env.DB); @@ -104,42 +113,31 @@ favoriteRoutes.post( } ); -/** DELETE /api/favorites/:favoriteId */ -favoriteRoutes.delete( - "/favorites/:favoriteId", - requireSignInMiddleware, - async (c) => { - const favoriteId = c.req.param("favoriteId"); - if (!favoriteId) { - return c.json( - { error: "favoriteId is required" }, - HttpStatus.BAD_REQUEST - ); - } - const userId = await c.var.getUserId(); - const db = getDb(c.env.DB); +/** DELETE /api/favorite/:favoriteId */ +favoriteRoutes.delete(favoriteRoute(), requireSignInMiddleware, async (c) => { + const favoriteId = getFavoriteParam(c); + const userId = await c.var.getUserId(); + const db = getDb(c.env.DB); - // security: Require the user to also match - await db - .delete(favorites) - .where( - and(eq(favorites.id, favoriteId), eq(favorites.userId, userId)) - ); + // security: Require the user to also match + await db + .delete(favorites) + .where(and(eq(favorites.id, favoriteId), eq(favorites.userId, userId))); - return c.json({ success: true }); - } -); + return c.json({ success: true }); +}); /** POST /api/favorite-order/library/:libraryId */ favoriteRoutes.post( "/favorite-order" + libraryRoute(), requireSignInMiddleware, + zValidator("json", favoriteOrderBody), async (c) => { - const body = await c.req.json<{ favoriteOrder: string[] }>(); + const { favoriteOrder } = c.req.valid("json"); const db = getDb(c.env.DB); await Promise.all( - body.favoriteOrder.map((id, i) => + favoriteOrder.map((id, i) => db .update(favorites) .set({ sortOrder: i }) @@ -151,20 +149,19 @@ favoriteRoutes.post( } ); -/** POST /api/default-configuration/:favoriteId */ +/** POST /api/default-configuration/favorite/:favoriteId */ favoriteRoutes.post( - "/default-configuration/:favoriteId", + "/default-configuration" + favoriteRoute(), requireSignInMiddleware, + zValidator("json", defaultConfigurationBody), async (c) => { - const favoriteId = c.req.param("favoriteId"); - const body = await c.req.json<{ - defaultConfiguration: ParameterValues; - }>(); + const favoriteId = getFavoriteParam(c); + const { defaultConfiguration } = c.req.valid("json"); const db = getDb(c.env.DB); await db .update(favorites) - .set({ defaultConfiguration: body.defaultConfiguration }) + .set({ defaultConfiguration }) .where(eq(favorites.id, favoriteId)); return c.json({ success: true }); diff --git a/src/backend/features/library/groups/routes.ts b/src/backend/features/library/groups/routes.ts index 3294aff7b..72dcbaf31 100644 --- a/src/backend/features/library/groups/routes.ts +++ b/src/backend/features/library/groups/routes.ts @@ -24,6 +24,25 @@ const reloadGroupsQuery = z.object({ forceReload: z.stringbool().default(false) }); +const setVisibilityBody = z.object({ + insertableIds: z.array(z.string()), + isVisible: z.boolean() +}); + +const sortGroupBody = z.object({ + groupId: z.string().min(1), + sortAlphabetically: z.boolean() +}); + +const groupOrderBody = z.object({ groupOrder: z.array(z.string()) }); + +const addGroupBody = z.object({ + newDocumentId: z.string().min(1), + selectedGroupId: z.string().optional() +}); + +const deleteGroupQuery = z.object({ groupId: z.string().min(1) }); + /** POST /api/reload-groups/library/:libraryId?forceReload=true */ groupRoutes.post( "/reload-groups" + libraryRoute(), @@ -71,12 +90,10 @@ groupRoutes.get( groupRoutes.post( "/set-insertable-visibility" + libraryRoute(), requireEditorMiddleware, + zValidator("json", setVisibilityBody), async (c) => { const libraryId = getLibraryParam(c); - const body = await c.req.json<{ - insertableIds: string[]; - isVisible: boolean; - }>(); + const body = c.req.valid("json"); const db = getDb(c.env.DB); @@ -113,12 +130,10 @@ groupRoutes.post( groupRoutes.post( "/sort-group-alphabetically" + libraryRoute(), requireEditorMiddleware, + zValidator("json", sortGroupBody), async (c) => { const libraryId = getLibraryParam(c); - const body = await c.req.json<{ - groupId: string; - sortAlphabetically: boolean; - }>(); + const body = c.req.valid("json"); const db = getDb(c.env.DB); await db @@ -137,9 +152,10 @@ groupRoutes.post( groupRoutes.post( "/group-order" + libraryRoute(), requireEditorMiddleware, + zValidator("json", groupOrderBody), async (c) => { const libraryId = getLibraryParam(c); - const body = await c.req.json<{ groupOrder: string[] }>(); + const body = c.req.valid("json"); const db = getDb(c.env.DB); await Promise.all( @@ -162,13 +178,11 @@ groupRoutes.post( groupRoutes.post( "/group" + libraryRoute(), requireEditorMiddleware, + zValidator("json", addGroupBody), async (c) => { const onshapeApi = await c.var.getOnshapeApi(); const libraryId = getLibraryParam(c); - const body = await c.req.json<{ - newDocumentId: string; - selectedGroupId?: string; - }>(); + const body = c.req.valid("json"); const sessionId = getSessionId(c); const documentPath: DocumentPath = { documentId: body.newDocumentId }; @@ -232,14 +246,10 @@ groupRoutes.post( groupRoutes.delete( "/group" + libraryRoute(), requireEditorMiddleware, + zValidator("query", deleteGroupQuery), async (c) => { const libraryId = getLibraryParam(c); - const groupId = c.req.query("groupId"); - if (!groupId) - return c.json( - { error: "groupId required" }, - HttpStatus.BAD_REQUEST - ); + const { groupId } = c.req.valid("query"); const db = getDb(c.env.DB); diff --git a/src/backend/features/library/insertables/routes.ts b/src/backend/features/library/insertables/routes.ts index f96f9de5b..6c575351e 100644 --- a/src/backend/features/library/insertables/routes.ts +++ b/src/backend/features/library/insertables/routes.ts @@ -44,14 +44,19 @@ import { checkIndexedPartNumber } from "../../build-checker/checks"; export const insertableRoutes = getApp(); /** POST /api/toggle-insert-and-fasten/insertable/:insertableId */ +const setFastenBody = z.object({ supportsFasten: z.boolean() }); + +const indexConfigurationsBody = z.object({ indexConfigurations: z.boolean() }); + insertableRoutes.post( "/toggle-insert-and-fasten" + insertableRoute(), requireEditorMiddleware, + zValidator("json", setFastenBody), async (c) => { const db = getDb(c.env.DB); const insertableId = getInsertableParam(c); - const body = await c.req.json<{ supportsFasten: boolean }>(); + const { supportsFasten } = c.req.valid("json"); const insertableRow = await db .select({ libraryId: insertables.libraryId }) @@ -64,7 +69,7 @@ insertableRoutes.post( }); let fastenInfo = null; - if (body.supportsFasten) { + if (supportsFasten) { const onshapeApi = await c.var.getOnshapeApi(); const elementPath = await getInsertableElementPath( db, @@ -93,7 +98,7 @@ insertableRoutes.post( await db .update(insertables) - .set({ supportsFasten: body.supportsFasten, fastenInfo }) + .set({ supportsFasten, fastenInfo }) .where(eq(insertables.id, insertableId)); await bumpLibraryVersion(db, insertableRow.libraryId); @@ -105,10 +110,11 @@ insertableRoutes.post( insertableRoutes.post( "/index-configurations" + insertableRoute(), requireEditorMiddleware, + zValidator("json", indexConfigurationsBody), async (c) => { const db = getDb(c.env.DB); const insertableId = getInsertableParam(c); - const body = await c.req.json<{ indexConfigurations: boolean }>(); + const body = c.req.valid("json"); const row = await db .select({ diff --git a/src/backend/features/settings/routes.ts b/src/backend/features/settings/routes.ts index 2934789e4..a63ef334f 100644 --- a/src/backend/features/settings/routes.ts +++ b/src/backend/features/settings/routes.ts @@ -2,24 +2,36 @@ import { eq } from "drizzle-orm"; import { getApp } from "../../lib/context"; import { getDb } from "../../db/client"; import { users } from "../../db/schema"; +import { z } from "zod"; +import { zValidator } from "@hono/zod-validator"; import { requireSignInMiddleware } from "../auth/guards"; -import type { SettingsUpdate } from "./settings"; +import { LibraryId } from "../library/library-id"; +import { Theme } from "./settings"; export const settingsRoutes = getApp(); +const settingsBody = z.object({ + theme: z.enum(Theme).optional(), + libraryId: z.enum(LibraryId).optional() +}); + /** POST /api/settings — update the caller's stored settings */ -settingsRoutes.post("/settings", requireSignInMiddleware, async (c) => { - const userId = await c.var.getUserId(); +settingsRoutes.post( + "/settings", + requireSignInMiddleware, + zValidator("json", settingsBody), + async (c) => { + const userId = await c.var.getUserId(); + const body = c.req.valid("json"); - const body = await c.req.json<SettingsUpdate>(); + const db = getDb(c.env.DB); - const db = getDb(c.env.DB); + await db.insert(users).values({ id: userId }).onConflictDoNothing(); - await db.insert(users).values({ id: userId }).onConflictDoNothing(); + if (Object.keys(body).length > 0) { + await db.update(users).set(body).where(eq(users.id, userId)); + } - if (Object.keys(body).length > 0) { - await db.update(users).set(body).where(eq(users.id, userId)); + return c.json({ success: true }); } - - return c.json({ success: true }); -}); +); diff --git a/src/backend/features/thumbnails/routes.test.ts b/src/backend/features/thumbnails/routes.test.ts index 54c3b354c..8d4c58cea 100644 --- a/src/backend/features/thumbnails/routes.test.ts +++ b/src/backend/features/thumbnails/routes.test.ts @@ -240,7 +240,6 @@ describe("warming a configuration's thumbnail", () => { expect.objectContaining({ params: { insertableId: INSERTABLE_ID, - microversionId: MICROVERSION, canonicalConfiguration: CANONICAL_CONFIGURATION, // The render runs later, so it needs a session to authenticate. sessionId: SESSION_ID diff --git a/src/backend/features/thumbnails/routes.ts b/src/backend/features/thumbnails/routes.ts index a22aead76..817a89ef1 100644 --- a/src/backend/features/thumbnails/routes.ts +++ b/src/backend/features/thumbnails/routes.ts @@ -3,7 +3,12 @@ import { z } from "zod"; import { zValidator } from "@hono/zod-validator"; import { CachePolicy, cacheMiddleware, setCacheTtl } from "../../lib/cache"; import { getApp } from "../../lib/context"; -import { getInsertableParam, insertableRoute } from "../../lib/route-params"; +import { + getGroupParam, + getInsertableParam, + groupRoute, + insertableRoute +} from "../../lib/route-params"; import { getInsertableElementPath } from "../library/insertables/routes"; import { getDb } from "../../db/client"; import { requireEditorMiddleware } from "../auth/guards"; @@ -87,7 +92,6 @@ thumbnailRoutes.get( if (warm && insertableId) { await warmConfigurationThumbnail(c, { insertableId, - microversionId, canonicalConfiguration }); } @@ -184,11 +188,11 @@ thumbnailRoutes.post( /** POST /api/reload-group-thumbnail/group/:groupId */ thumbnailRoutes.post( - "/reload-group-thumbnail/group/:groupId", + "/reload-group-thumbnail" + groupRoute(), requireEditorMiddleware, async (c) => { const onshapeApi = await c.var.getOnshapeApi(); - const groupId = c.req.param("groupId"); + const groupId = getGroupParam(c); const db = getDb(c.env.DB); const row = await db diff --git a/src/backend/features/thumbnails/store.ts b/src/backend/features/thumbnails/store.ts index ec464639a..3afc53a63 100644 --- a/src/backend/features/thumbnails/store.ts +++ b/src/backend/features/thumbnails/store.ts @@ -24,12 +24,22 @@ import { } from "../configurations/canonical"; import { OnshapeApi } from "../../lib/onshape/client"; +/** + * What produced a stored thumbnail, tagged onto the R2 object. The key already + * addresses it; this is for reading an object back and telling what it is. + */ +export interface ThumbnailMetadata extends Record<string, string> { + microversionId: string; + /** Empty for an element's own thumbnail, as everywhere else. */ + canonicalConfiguration: string; +} + /** Stores one rendered thumbnail, tagging it with what produced it. */ async function putThumbnail( bucket: R2Bucket, key: string, thumbnail: ArrayBuffer, - metadata: Record<string, string> + metadata: ThumbnailMetadata ): Promise<void> { await bucket.put(key, thumbnail, { httpMetadata: { @@ -61,13 +71,19 @@ export async function uploadThumbnails( bucket, thumbnailKey(elementId, microversionId, ThumbnailSize.SMALL), small, - { microversionId } + { + microversionId, + canonicalConfiguration: DEFAULT_CANONICAL_CONFIGURATION + } ), putThumbnail( bucket, thumbnailKey(elementId, microversionId, ThumbnailSize.LARGE), large, - { microversionId } + { + microversionId, + canonicalConfiguration: DEFAULT_CANONICAL_CONFIGURATION + } ) ]); diff --git a/src/backend/features/thumbnails/workflow.ts b/src/backend/features/thumbnails/workflow.ts index 7347a805a..5cc322d45 100644 --- a/src/backend/features/thumbnails/workflow.ts +++ b/src/backend/features/thumbnails/workflow.ts @@ -14,8 +14,6 @@ import { uploadConfigurationThumbnails } from "./store"; /** The render to run, plus the session whose Onshape tokens it runs under. */ export interface ThumbnailWorkflowParams { insertableId: string; - /** Part of the key, so a render lands where the request looked for it. */ - microversionId: string; /** Never the default, which loads eagerly with the element. */ canonicalConfiguration: string; sessionId: string; @@ -33,19 +31,18 @@ export class ThumbnailWorkflow extends WorkflowEntrypoint< event: WorkflowEvent<ThumbnailWorkflowParams>, step: WorkflowStep ): Promise<void> { - const { - insertableId, - microversionId, - canonicalConfiguration, - sessionId - } = event.payload; + const { insertableId, canonicalConfiguration, sessionId } = + event.payload; - const elementPath = await step.do("resolve-element", async () => { + // Read rather than passed in: the stored row is what the key has to + // agree with, and a request can carry a microversion it has moved past. + const element = await step.do("resolve-element", async () => { const row = await getDb(this.env.DB) .select({ documentId: insertables.documentId, versionId: insertables.versionId, - elementId: insertables.elementId + elementId: insertables.elementId, + microversionId: insertables.microversionId }) .from(insertables) .where(eq(insertables.id, insertableId)) @@ -53,12 +50,7 @@ export class ThumbnailWorkflow extends WorkflowEntrypoint< if (!row) { throw new Error(`No insertable ${insertableId}`); } - return { - documentId: row.documentId, - instanceId: row.versionId, - instanceType: "v" as const, - elementId: row.elementId - }; + return row; }); await step.do( @@ -73,8 +65,13 @@ export class ThumbnailWorkflow extends WorkflowEntrypoint< step, limit: createLimiter(1) }), - elementPath, - microversionId, + { + documentId: element.documentId, + instanceId: element.versionId, + instanceType: "v" as const, + elementId: element.elementId + }, + element.microversionId, canonicalConfiguration ) ); diff --git a/src/backend/lib/cache.ts b/src/backend/lib/cache.ts index 9790e333f..51911fef5 100644 --- a/src/backend/lib/cache.ts +++ b/src/backend/lib/cache.ts @@ -1,7 +1,6 @@ import { type MiddlewareHandler } from "hono"; import { HTTPException } from "hono/http-exception"; import { HttpStatus } from "http-status-ts"; -import z from "zod"; import { type AppContext, type AppContextEnv } from "./context"; /** A year — a versioned url's content never changes, only its version does. */ @@ -24,13 +23,6 @@ export function immutableCacheControl( return `${policy}, max-age=${IMMUTABLE_CACHE_TTL}, immutable`; } -const cacheVersionSchema = z.object({ v: z.string().min(1) }); - -interface CacheOptions { - /** Pass false only when the url is immutable without a `?v=`. */ - versioned?: boolean; -} - /** Overrides the route's immutable default for a body its url does not pin. */ export function setCacheTtl(c: AppContext, maxAge: number): void { c.set("cacheTtl", maxAge); @@ -38,8 +30,7 @@ export function setCacheTtl(c: AppContext, maxAge: number): void { /** Declares how a route's response may be cached, and enforces what that takes. */ export function cacheMiddleware( - policy: CachePolicy = CachePolicy.NO_CACHE, - options: CacheOptions = {} + policy: CachePolicy = CachePolicy.NO_CACHE ): MiddlewareHandler<AppContextEnv> { if (policy === CachePolicy.NO_CACHE) { return async (c, next) => { @@ -49,10 +40,11 @@ export function cacheMiddleware( } const cacheControl = immutableCacheControl(policy); - const versioned = options.versioned ?? true; return async (c, next) => { - if (versioned && !cacheVersionSchema.safeParse(c.req.query()).success) { + // An immutable response has to be pinned by something, or the next + // version of it is unreachable behind the cache. + if (!c.req.query("v")) { throw new HTTPException(HttpStatus.BAD_REQUEST, { message: "Missing cache version" }); diff --git a/src/backend/lib/route-params.ts b/src/backend/lib/route-params.ts index cdf872bf3..da10c28e1 100644 --- a/src/backend/lib/route-params.ts +++ b/src/backend/lib/route-params.ts @@ -30,6 +30,16 @@ export function getInsertableParam(c: AppContext): string { return id; } +export function favoriteRoute(): string { + return "/favorite/:favoriteId"; +} + +export function getFavoriteParam(c: AppContext): string { + const id = c.req.param("favoriteId"); + if (!id) throw new Error("Missing favoriteId route param"); + return id; +} + export function groupRoute(): string { return "/group/:groupId"; } diff --git a/src/frontend/features/favorites/components/favorite-button.tsx b/src/frontend/features/favorites/components/favorite-button.tsx index fad78ddc2..79c694293 100644 --- a/src/frontend/features/favorites/components/favorite-button.tsx +++ b/src/frontend/features/favorites/components/favorite-button.tsx @@ -11,7 +11,11 @@ import { queryClient } from "../../../lib/query-client"; import { useRouter } from "@tanstack/react-router"; import { handleAppError, HandledError } from "../../../lib/errors"; import { getQueryUpdater } from "../../../lib/utils"; -import { toLibraryPath, useLibraryId } from "../../library/library-path"; +import { + toFavoritePath, + toLibraryPath, + useLibraryId +} from "../../library/library-path"; import { favoritesQueryKey } from "../../../lib/query-keys"; import { useRefreshFavorites } from "../../../lib/refresh"; @@ -68,7 +72,7 @@ function useUpdateFavoritesMutation() { } }); } else { - return apiDelete("/favorites/" + args.favoriteId); + return apiDelete(toFavoritePath(args.favoriteId)); } }, onMutate: async (args) => { diff --git a/src/frontend/features/favorites/components/favorite-menu.tsx b/src/frontend/features/favorites/components/favorite-menu.tsx index 766931826..4b40225cb 100644 --- a/src/frontend/features/favorites/components/favorite-menu.tsx +++ b/src/frontend/features/favorites/components/favorite-menu.tsx @@ -21,7 +21,7 @@ import { useFavoritesQuery } from "../queries"; import { useLibraryQuery } from "../../library/queries"; import { favoritesQueryKey } from "../../../lib/query-keys"; import { getQueryUpdater } from "../../../lib/utils"; -import { useLibraryId } from "../../library/library-path"; +import { toFavoritePath, useLibraryId } from "../../library/library-path"; import { useRefreshFavorites } from "../../../lib/refresh"; import { PageError } from "../../../components/app-zero-state"; @@ -126,9 +126,12 @@ function FavoriteMenuContent(props: FavoriteMenuContentProps): ReactNode { mutationFn: async () => { // Canonical, so it addresses the thumbnail the favorites row asks // for; Onshape applies defaults for what it omits. - return apiPost("/default-configuration/" + favoriteId, { - body: { defaultConfiguration: canonicalConfiguration } - }); + return apiPost( + "/default-configuration" + toFavoritePath(favoriteId), + { + body: { defaultConfiguration: canonicalConfiguration } + } + ); }, onMutate: async () => { diff --git a/src/frontend/features/insert/components/configurations.tsx b/src/frontend/features/insert/components/configurations.tsx index 2de2d5819..d005387e5 100644 --- a/src/frontend/features/insert/components/configurations.tsx +++ b/src/frontend/features/insert/components/configurations.tsx @@ -51,6 +51,7 @@ import { configurationQueryKey } from "../../../lib/query-keys"; import { showErrorToast } from "../../../lib/notifications"; import { SectionError } from "../../../components/app-zero-state"; import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; +import { toInsertablePath } from "../../library/library-path"; interface ConfigurationWrapperProps { insertableId: string; @@ -81,7 +82,7 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { const query = useQuery<ConfigurationResult>({ queryKey: configurationQueryKey(insertableId, microversionId), queryFn: async () => { - return apiGet("/configuration/" + insertableId, { + return apiGet("/configuration" + toInsertablePath(insertableId), { cacheId: microversionId }); }, diff --git a/src/frontend/features/library/card-hooks.ts b/src/frontend/features/library/card-hooks.ts index f0fefce2f..9a74a863b 100644 --- a/src/frontend/features/library/card-hooks.ts +++ b/src/frontend/features/library/card-hooks.ts @@ -11,7 +11,12 @@ import { showLoadingToast, showSuccessToast } from "../../lib/notifications"; -import { toInsertablePath, toLibraryPath, useLibraryId } from "./library-path"; +import { + toGroupPath, + toInsertablePath, + toLibraryPath, + useLibraryId +} from "./library-path"; import { getAppErrorHandler } from "../../lib/errors"; import { useCacheVersion } from "./queries"; import { buildStatusQueryKey } from "../../lib/query-keys"; @@ -109,7 +114,7 @@ export function useReloadThumbnailMutation(id: string, isGroup: boolean) { const refreshLibrary = useRefreshLibrary(); const endpoint = isGroup - ? `/reload-group-thumbnail/group/${id}` + ? "/reload-group-thumbnail" + toGroupPath(id) : "/reload-insertable-thumbnail" + toInsertablePath(id); return useMutation({ diff --git a/src/frontend/features/library/library-path.ts b/src/frontend/features/library/library-path.ts index 998b85e93..1002f5d76 100644 --- a/src/frontend/features/library/library-path.ts +++ b/src/frontend/features/library/library-path.ts @@ -27,6 +27,10 @@ export function toGroupPath(groupId: string): string { return `/group/${groupId}`; } +export function toFavoritePath(favoriteId: string): string { + return `/favorite/${favoriteId}`; +} + export function getLibraryName(libraryId: string): string { switch (libraryId) { case LibraryId.FRC_DESIGN_LIB: From 2ec617636e12f74d899ef9b9e47713c8fd0563bf Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 21 Aug 2026 14:14:55 +0000 Subject: [PATCH 17/56] refactor: clearer contracts, names, and favorite ownership dto.ts is contract.ts - the backend owns the contract, and DTO was an acronym that named nothing. JobStatus moves out of the library's contract into the load feature that produces it. libraryId is stored per user but was missing from Settings, so DEFAULT_LIBRARY_ID lived off in library-id.ts as a second default. It is now part of Settings and DEFAULT_SETTINGS, and SettingsUpdate is just Partial<Settings>. Favorite writes are all scoped to their owner. Reordering and setting a default configuration were keyed on the favorite id alone, so one user could rewrite another's. Scoping the WHERE rather than reading the row first means no extra query. PartData is PartMetadata: absent values are undefined rather than null, and ConfigurationRecord documents that it is the same fields plus the configuration that produced them. Each probe now records whether it resolved to an open composite, and toResult derives instability by comparing against the element's own probe, so no record carries a flag about a comparison it cannot see. The two configuration-count issues now name their limits: CONFIGURATION_LIMIT_EXCEEDED and MANUAL_INDEXING_REQUIRED, matching IndexingBand.EXCEEDED and .MANUAL. addBuildIssue always returns a new array, with a test for it. checkIndexedPartNumber takes probes, not probed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- drizzle/0006_split_part_data.sql | 2 +- src/__test_utils__/insertable-fixtures.ts | 2 +- src/backend/db/schema.ts | 12 ++-- .../features/build-checker/checks.test.ts | 18 ++---- src/backend/features/build-checker/checks.ts | 18 +++--- .../build-checker/{dto.ts => contract.ts} | 0 .../features/build-checker/issues.test.ts | 12 +++- src/backend/features/build-checker/issues.ts | 20 +++--- .../features/build-checker/routes.test.ts | 2 +- src/backend/features/build-checker/routes.ts | 2 +- .../features/configurations/combinations.ts | 2 +- src/backend/features/configurations/models.ts | 36 ++++++----- .../features/configurations/routes.test.ts | 10 +-- src/backend/features/configurations/routes.ts | 4 +- .../features/configurations/utils.test.ts | 2 +- src/backend/features/configurations/utils.ts | 8 +-- src/backend/features/entry/routes.ts | 3 +- .../favorites/{dto.ts => contract.ts} | 0 src/backend/features/favorites/routes.ts | 16 +++-- .../features/library/{dto.ts => contract.ts} | 8 --- src/backend/features/library/db.test.ts | 10 +-- src/backend/features/library/db.ts | 6 +- .../features/library/groups/routes.test.ts | 2 +- .../library/insertables/routes.test.ts | 12 ++-- .../features/library/insertables/routes.ts | 4 +- src/backend/features/library/library-id.ts | 3 - src/backend/features/library/routes.test.ts | 2 +- src/backend/features/load/contract.ts | 7 ++ src/backend/features/load/job-tracker.ts | 2 +- .../features/load/load-insertable.test.ts | 27 ++++---- src/backend/features/load/load-insertable.ts | 10 +-- .../load/parse-configuration-records.test.ts | 48 +++++++------- .../load/parse-configuration-records.ts | 64 +++++++++---------- src/backend/features/search/search-index.ts | 4 +- src/backend/features/settings/settings.ts | 10 +-- src/frontend/components/root-error.tsx | 4 +- .../build-status/components/build-status.tsx | 2 +- src/frontend/features/build-status/queries.ts | 2 +- .../favorites/components/favorite-button.tsx | 7 +- .../favorites/components/favorite-card.tsx | 4 +- .../favorites/components/favorite-menu.tsx | 2 +- .../favorites/components/favorites-list.tsx | 4 +- src/frontend/features/favorites/queries.ts | 2 +- .../insert/components/insert-menu.tsx | 4 +- src/frontend/features/insert/insert-hooks.ts | 2 +- src/frontend/features/library/card-hooks.ts | 4 +- .../library/components/add-group-menu.tsx | 2 +- .../library/components/card-components.tsx | 2 +- .../library/components/group-card.tsx | 2 +- .../library/components/insertable-card.tsx | 4 +- .../components/reload-groups-button.tsx | 2 +- src/frontend/features/library/library-path.ts | 8 +-- src/frontend/features/library/queries.ts | 3 +- src/frontend/features/search/filter.ts | 2 +- src/frontend/features/search/search.test.ts | 11 ++-- src/frontend/features/search/search.ts | 2 +- .../features/settings/local-settings.ts | 7 +- src/frontend/routes/__root.tsx | 3 +- .../library/$libraryId/groups/$groupId.tsx | 2 +- .../routes/app/library/$libraryId/route.tsx | 8 +-- 60 files changed, 239 insertions(+), 244 deletions(-) rename src/backend/features/build-checker/{dto.ts => contract.ts} (100%) rename src/backend/features/favorites/{dto.ts => contract.ts} (100%) rename src/backend/features/library/{dto.ts => contract.ts} (80%) create mode 100644 src/backend/features/load/contract.ts diff --git a/drizzle/0006_split_part_data.sql b/drizzle/0006_split_part_data.sql index f6785bcf0..4b2bfb8c8 100644 --- a/drizzle/0006_split_part_data.sql +++ b/drizzle/0006_split_part_data.sql @@ -9,4 +9,4 @@ null and repopulates on the next load, which is also when a configurations row that holds no parameters is dropped. */ -ALTER TABLE `insertables` ADD `part_data` text; +ALTER TABLE `insertables` ADD `part_metadata` text; diff --git a/src/__test_utils__/insertable-fixtures.ts b/src/__test_utils__/insertable-fixtures.ts index 762b20b0d..7fd2ef05a 100644 --- a/src/__test_utils__/insertable-fixtures.ts +++ b/src/__test_utils__/insertable-fixtures.ts @@ -39,7 +39,7 @@ export function parsedInsertable( fastenInfo: null, isOpenComposite: false, buildIssues: [], - partData: null, + partMetadata: null, configuration: { parameters: [], records: [] }, ...overrides }; diff --git a/src/backend/db/schema.ts b/src/backend/db/schema.ts index 02e52a905..66009bde6 100644 --- a/src/backend/db/schema.ts +++ b/src/backend/db/schema.ts @@ -1,14 +1,14 @@ import { sqliteTable, text, integer, unique } from "drizzle-orm/sqlite-core"; import { ElementType } from "../lib/onshape/element-type"; import { FastenInfo } from "../features/library/insertables/fasten"; -import { DEFAULT_LIBRARY_ID, LibraryId } from "../features/library/library-id"; +import { LibraryId } from "../features/library/library-id"; import { DEFAULT_SETTINGS, Theme } from "../features/settings/settings"; import { Vendor } from "../features/library/vendors"; import { ParameterValues, ConfigurationParameter, ConfigurationRecord, - PartData + PartMetadata } from "../features/configurations/models"; import { BuildIssue } from "../features/build-checker/issues"; @@ -96,7 +96,9 @@ export const insertables = sqliteTable("insertables", { }).$type<FastenInfo | null>(), // The element's own part identity, probed from its defaults. Null until a // probe succeeds; a configurable insertable left unindexed never gets one. - partData: text("part_data", { mode: "json" }).$type<PartData | null>(), + partMetadata: text("part_metadata", { + mode: "json" + }).$type<PartMetadata | null>(), // Build-time issues flagged by the build checker, recomputed on reload. buildIssues: text("build_issues", { mode: "json" }) .$type<BuildIssue[]>() @@ -117,7 +119,7 @@ export const configurations = sqliteTable("configurations", { .notNull() .default([]), // One record per indexed configuration. Empty unless the insertable is - // indexed; the element's own part data lives on `insertables.partData`. + // indexed; the element's own metadata lives on `insertables.partMetadata`. records: text("records", { mode: "json" }) .$type<ConfigurationRecord[]>() .notNull() @@ -137,7 +139,7 @@ export const users = sqliteTable("users", { libraryId: text("library_id") .$type<LibraryId>() .notNull() - .default(DEFAULT_LIBRARY_ID) + .default(DEFAULT_SETTINGS.libraryId) }); export const favorites = sqliteTable( diff --git a/src/backend/features/build-checker/checks.test.ts b/src/backend/features/build-checker/checks.test.ts index 1a2fc1989..a6f3753c8 100644 --- a/src/backend/features/build-checker/checks.test.ts +++ b/src/backend/features/build-checker/checks.test.ts @@ -57,16 +57,12 @@ describe("checkGroup", () => { }); /** An indexed record; only its part number matters to these checks. */ -function record(partNumber: string | null): ConfigurationRecord { +function record(partNumber?: string): ConfigurationRecord { return { configuration: {}, partNumber, - name: null, - description: null, - material: null, - vendor: null, hasMultipleParts: false, - isUnstableComposite: false + isOpenComposite: false }; } @@ -74,7 +70,7 @@ describe("checkInsertable", () => { const HEALTHY_INSERTABLE = { vendors: [Vendor.REV], thumbnailUrls: THUMBNAILS, - probed: [record("217-2600")] + probes: [record("217-2600")] }; it("returns no issues when vendors are parsed and thumbnails generated", () => { @@ -100,7 +96,7 @@ describe("checkInsertable", () => { it("warns when a vendor part indexed without a part number", () => { const issues = checkInsertable({ ...HEALTHY_INSERTABLE, - probed: [record(null), record(null)] + probes: [record(), record()] }); expect(issues).toEqual([{ type: BuildIssueType.NO_PART_NUMBER }]); }); @@ -108,7 +104,7 @@ describe("checkInsertable", () => { it("does not warn when only some configurations lack one", () => { const issues = checkInsertable({ ...HEALTHY_INSERTABLE, - probed: [record(null), record("217-2600")] + probes: [record(), record("217-2600")] }); expect(issues).toEqual([]); }); @@ -118,14 +114,14 @@ describe("checkInsertable", () => { const issues = checkInsertable({ ...HEALTHY_INSERTABLE, vendors: [Vendor.CUSTOM], - probed: [record(null)] + probes: [record()] }); expect(issues).toEqual([]); }); // Nothing was probed, so there is nothing to conclude. it("does not warn when the insertable is not indexed", () => { - const issues = checkInsertable({ ...HEALTHY_INSERTABLE, probed: [] }); + const issues = checkInsertable({ ...HEALTHY_INSERTABLE, probes: [] }); expect(issues).toEqual([]); }); }); diff --git a/src/backend/features/build-checker/checks.ts b/src/backend/features/build-checker/checks.ts index 538ce0876..1643e83c3 100644 --- a/src/backend/features/build-checker/checks.ts +++ b/src/backend/features/build-checker/checks.ts @@ -1,7 +1,7 @@ import { ThumbnailUrls } from "../thumbnails/types"; import { Vendor, isCustomPart } from "../library/vendors"; import { addBuildIssue, BuildIssue, BuildIssueType } from "./issues"; -import type { PartData } from "../configurations/models"; +import type { PartMetadata } from "../configurations/models"; interface GroupCheckInput { /** Whether the Onshape document has a designated thumbnail tab/element. */ @@ -42,8 +42,8 @@ interface InsertableCheckInput { vendors: Vendor[]; /** The uploaded thumbnail URLs, or `null` when generation failed. */ thumbnailUrls: ThumbnailUrls | null; - /** The element's own part data, plus one per indexed configuration. */ - probed: (PartData | null)[]; + /** Every probe of the element: its own, plus one per indexed configuration. */ + probes: (PartMetadata | null)[]; } /** @@ -65,7 +65,7 @@ export function checkInsertable(input: InsertableCheckInput): BuildIssue[] { issues = addBuildIssue( issues, - ...checkIndexedPartNumber(input.vendors, input.probed) + ...checkIndexedPartNumber(input.vendors, input.probes) ); return issues; @@ -75,16 +75,16 @@ export function checkInsertable(input: InsertableCheckInput): BuildIssue[] { * A custom part is expected to have no part number; anything a vendor sells * should have one in at least one configuration. */ -/** `probed` is the element's own part data plus any indexed configuration's. */ +/** Every probe of an element: its own, plus one per indexed configuration. */ export function checkIndexedPartNumber( vendors: Vendor[], - probed: (PartData | null)[] + probes: (PartMetadata | null)[] ): BuildIssue[] { - const found = probed.filter((data) => data !== null); - if (isCustomPart(vendors) || found.length === 0) { + const read = probes.filter((probe) => probe !== null); + if (isCustomPart(vendors) || read.length === 0) { return []; } - return found.some((data) => data.partNumber) + return read.some((probe) => probe.partNumber) ? [] : [{ type: BuildIssueType.NO_PART_NUMBER }]; } diff --git a/src/backend/features/build-checker/dto.ts b/src/backend/features/build-checker/contract.ts similarity index 100% rename from src/backend/features/build-checker/dto.ts rename to src/backend/features/build-checker/contract.ts diff --git a/src/backend/features/build-checker/issues.test.ts b/src/backend/features/build-checker/issues.test.ts index c7a8f404a..f948d3b78 100644 --- a/src/backend/features/build-checker/issues.test.ts +++ b/src/backend/features/build-checker/issues.test.ts @@ -63,7 +63,17 @@ describe("addBuildIssue", () => { const result = addBuildIssue(existing, { type: BuildIssueType.NO_VENDORS }); - expect(result).toBe(existing); + expect(result).toEqual(existing); + }); + + // Callers hold onto the array they passed in, so it must never be the one + // that comes back, even when there was nothing to add. + it("returns a new array even when nothing is added", () => { + const existing: BuildIssue[] = [{ type: BuildIssueType.NO_VENDORS }]; + expect( + addBuildIssue(existing, { type: BuildIssueType.NO_VENDORS }) + ).not.toBe(existing); + expect(addBuildIssue(existing)).not.toBe(existing); }); }); diff --git a/src/backend/features/build-checker/issues.ts b/src/backend/features/build-checker/issues.ts index 08ba614a0..c768054e7 100644 --- a/src/backend/features/build-checker/issues.ts +++ b/src/backend/features/build-checker/issues.ts @@ -24,8 +24,8 @@ export enum BuildIssueType { NO_PART_NUMBER = "no-part-number", NO_PARTS = "no-parts", NO_UNHIDDEN_INSERTABLES = "no-unhidden-insertables", - TOO_MANY_CONFIGURATIONS = "too-many-configurations", - MANY_CONFIGURATIONS = "many-configurations", + CONFIGURATION_LIMIT_EXCEEDED = "configuration-limit-exceeded", + MANUAL_INDEXING_REQUIRED = "manual-indexing-required", MULTIPLE_PARTS = "multiple-parts", UNSTABLE_COMPOSITE = "unstable-composite", INSERTABLES_FAILED = "insertables-failed", @@ -46,8 +46,8 @@ export type BuildIssue = | BuildIssueOf<BuildIssueType.NO_PART_NUMBER> | BuildIssueOf<BuildIssueType.NO_PARTS> | BuildIssueOf<BuildIssueType.NO_UNHIDDEN_INSERTABLES> - | BuildIssueOf<BuildIssueType.TOO_MANY_CONFIGURATIONS> - | BuildIssueOf<BuildIssueType.MANY_CONFIGURATIONS> + | BuildIssueOf<BuildIssueType.CONFIGURATION_LIMIT_EXCEEDED> + | BuildIssueOf<BuildIssueType.MANUAL_INDEXING_REQUIRED> | BuildIssueOf<BuildIssueType.MULTIPLE_PARTS> | BuildIssueOf<BuildIssueType.UNSTABLE_COMPOSITE> | BuildIssueOf<BuildIssueType.INSERTABLES_FAILED> @@ -68,9 +68,9 @@ export function getIssueDescription(issue: BuildIssue): string { return "This part studio has no parts"; case BuildIssueType.NO_UNHIDDEN_INSERTABLES: return "No unhidden insertables"; - case BuildIssueType.TOO_MANY_CONFIGURATIONS: + case BuildIssueType.CONFIGURATION_LIMIT_EXCEEDED: return `Over the ${MAX_PART_NUMBER_CONFIGURATIONS} configuration limit, so its configurations cannot be indexed`; - case BuildIssueType.MANY_CONFIGURATIONS: + case BuildIssueType.MANUAL_INDEXING_REQUIRED: return `Over ${AUTO_INDEX_THRESHOLD} configurations, so indexing must be enabled manually`; case BuildIssueType.MULTIPLE_PARTS: return "This part studio has more than one part"; @@ -95,8 +95,8 @@ export function getIssueSeverity(issue: BuildIssue): BuildIssueSeverity { case BuildIssueType.LOAD_FAILED: return BuildIssueSeverity.ERROR; case BuildIssueType.NO_THUMBNAIL_TAB: - case BuildIssueType.TOO_MANY_CONFIGURATIONS: - case BuildIssueType.MANY_CONFIGURATIONS: + case BuildIssueType.CONFIGURATION_LIMIT_EXCEEDED: + case BuildIssueType.MANUAL_INDEXING_REQUIRED: case BuildIssueType.NO_PART_NUMBER: return BuildIssueSeverity.WARNING; case BuildIssueType.NO_VENDORS: @@ -112,10 +112,10 @@ export function addBuildIssue( issues: BuildIssue[], ...newIssues: BuildIssue[] ): BuildIssue[] { - let result = issues; + const result = [...issues]; for (const issue of newIssues) { if (!result.some((existing) => existing.type === issue.type)) { - result = [...result, issue]; + result.push(issue); } } return result; diff --git a/src/backend/features/build-checker/routes.test.ts b/src/backend/features/build-checker/routes.test.ts index 6e729df24..646699528 100644 --- a/src/backend/features/build-checker/routes.test.ts +++ b/src/backend/features/build-checker/routes.test.ts @@ -11,7 +11,7 @@ import { seedPartStudio } from "../../../__test_utils__"; import { getDb } from "../../db/client"; -import type { LibraryBuildStatus } from "./dto"; +import type { LibraryBuildStatus } from "./contract"; const db = getDb(env.DB); diff --git a/src/backend/features/build-checker/routes.ts b/src/backend/features/build-checker/routes.ts index a379cd246..b2b9dace7 100644 --- a/src/backend/features/build-checker/routes.ts +++ b/src/backend/features/build-checker/routes.ts @@ -9,7 +9,7 @@ import type { LibraryBuildStatus, GroupBuildStatus, InsertableBuildStatus -} from "./dto"; +} from "./contract"; export const buildStatusRoutes = getApp(); diff --git a/src/backend/features/configurations/combinations.ts b/src/backend/features/configurations/combinations.ts index 2993a4c77..f9cde0be0 100644 --- a/src/backend/features/configurations/combinations.ts +++ b/src/backend/features/configurations/combinations.ts @@ -19,7 +19,7 @@ export const MAX_PART_NUMBER_CONFIGURATIONS = 512; /** * At or above this, indexing waits for an admin, who can trim the count back - * with "exclude from properties"; see the `MANY_CONFIGURATIONS` build issue. + * with "exclude from properties"; see the `MANUAL_INDEXING_REQUIRED` build issue. */ export const AUTO_INDEX_THRESHOLD = 128; diff --git a/src/backend/features/configurations/models.ts b/src/backend/features/configurations/models.ts index 148c9b24b..55321af2b 100644 --- a/src/backend/features/configurations/models.ts +++ b/src/backend/features/configurations/models.ts @@ -81,8 +81,8 @@ export interface ConfigurationResult { * the index and the `/configuration` route can share it. */ export interface SearchRecord { - partNumber: string | null; - name: string | null; + partNumber?: string; + name?: string; /** The (enumerated) parameter values that produce it; empty for the default. */ configuration: ParameterValues; } @@ -140,25 +140,29 @@ export type ParameterValues = Record<string, string>; * UI can read it back without re-querying Onshape. */ /** - * What a probe reads off an element. Probed from the element's own defaults it - * describes the element itself; probed from a configuration it describes that - * configuration (see {@link ConfigurationRecord}). + * The part an element resolves to, as one probe of Onshape read it. Probed from + * the element's own defaults this describes the element; probed from a specific + * configuration it is a {@link ConfigurationRecord}. A field is absent when the + * probe found no value for it. */ -export interface PartData { - partNumber: string | null; - name: string | null; - description: string | null; +export interface PartMetadata { + partNumber?: string; + name?: string; + description?: string; /** Material display name, e.g. "6061 Aluminum". */ - material: string | null; - vendor: string | null; - /** True when a part studio resolved to more than one part. */ + material?: string; + vendor?: string; + /** True when the part studio resolved to more than one part. */ hasMultipleParts: boolean; - /** True when an open composite lost its composite here. */ - isUnstableComposite: boolean; + /** Whether this probe resolved to an open composite. */ + isOpenComposite: boolean; } -/** One configuration's part data, stored only for an indexed insertable. */ -export interface ConfigurationRecord extends PartData { +/** + * {@link PartMetadata} for one specific configuration — the same fields, plus + * the parameter values that produced them. Stored only for an indexed insertable. + */ +export interface ConfigurationRecord extends PartMetadata { /** The parameter values that produce it. */ configuration: ParameterValues; } diff --git a/src/backend/features/configurations/routes.test.ts b/src/backend/features/configurations/routes.test.ts index 5900d050f..afcfcfd27 100644 --- a/src/backend/features/configurations/routes.test.ts +++ b/src/backend/features/configurations/routes.test.ts @@ -43,14 +43,14 @@ describe("configuration routes", () => { // back to, and it lives on the insertable, not in a configurations row. it("GET /configuration/insertable/:insertableId serves the element's own part data as a record", async () => { await seedPartStudio(db, { - partData: { + partMetadata: { partNumber: "WCP-0405", name: "2x1 Tube", - description: null, - material: null, - vendor: null, + description: undefined, + material: undefined, + vendor: undefined, hasMultipleParts: false, - isUnstableComposite: false + isOpenComposite: false } }); const app = createTestApp(); diff --git a/src/backend/features/configurations/routes.ts b/src/backend/features/configurations/routes.ts index f03b7f95e..799e051d0 100644 --- a/src/backend/features/configurations/routes.ts +++ b/src/backend/features/configurations/routes.ts @@ -34,7 +34,7 @@ configurationRoutes.get( // lives on the insertable whether or not it is configurable. const config = await db .select({ - partData: insertables.partData, + partMetadata: insertables.partMetadata, parameters: configurations.parameters, records: configurations.records }) @@ -52,7 +52,7 @@ configurationRoutes.get( const result: ConfigurationResult = { parameters: config.parameters ?? [], records: toSearchRecords( - toRecords(config.partData, config.records ?? []) + toRecords(config.partMetadata, config.records ?? []) ) }; return c.json(result); diff --git a/src/backend/features/configurations/utils.test.ts b/src/backend/features/configurations/utils.test.ts index 6430ae18f..d9cdde5ce 100644 --- a/src/backend/features/configurations/utils.test.ts +++ b/src/backend/features/configurations/utils.test.ts @@ -6,7 +6,7 @@ function rec( configuration: Record<string, string>, partNumber = "PN" ): SearchRecord { - return { partNumber, name: null, configuration }; + return { partNumber, configuration }; } describe("findRecordForConfiguration", () => { diff --git a/src/backend/features/configurations/utils.ts b/src/backend/features/configurations/utils.ts index a222ee0c1..c603d0ca0 100644 --- a/src/backend/features/configurations/utils.ts +++ b/src/backend/features/configurations/utils.ts @@ -1,6 +1,6 @@ import { type ConfigurationRecord, - type PartData, + type PartMetadata, ParameterValues, EnumOption, EnumParameter, @@ -195,9 +195,9 @@ export function getEvaluateOptions( * unset configuration falls back to — then one per indexed configuration. */ export function toRecords( - partData: PartData | null, + partMetadata: PartMetadata | null, records: ConfigurationRecord[] ): ConfigurationRecord[] { - if (!partData) return records; - return [{ ...partData, configuration: {} }, ...records]; + if (!partMetadata) return records; + return [{ ...partMetadata, configuration: {} }, ...records]; } diff --git a/src/backend/features/entry/routes.ts b/src/backend/features/entry/routes.ts index 1cb3ef210..00f08b835 100644 --- a/src/backend/features/entry/routes.ts +++ b/src/backend/features/entry/routes.ts @@ -8,7 +8,6 @@ import { users } from "../../db/schema"; import { cacheMiddleware } from "../../lib/cache"; import { getApp, type AppContext } from "../../lib/context"; import { getSessionCompanyId } from "../auth/session"; -import { DEFAULT_LIBRARY_ID } from "../library/library-id"; import { DEFAULT_SETTINGS } from "../settings/settings"; /** Cloudflare strips the port in local dev, so redirect back relatively. */ @@ -33,7 +32,7 @@ async function getEntryUrl(c: AppContext): Promise<string> { } search.set("theme", user?.theme ?? DEFAULT_SETTINGS.theme); - const libraryId = user?.libraryId ?? DEFAULT_LIBRARY_ID; + const libraryId = user?.libraryId ?? DEFAULT_SETTINGS.libraryId; return `/app/library/${libraryId}?${search.toString()}`; } diff --git a/src/backend/features/favorites/dto.ts b/src/backend/features/favorites/contract.ts similarity index 100% rename from src/backend/features/favorites/dto.ts rename to src/backend/features/favorites/contract.ts diff --git a/src/backend/features/favorites/routes.ts b/src/backend/features/favorites/routes.ts index 3c44e5c2f..0a4bd859e 100644 --- a/src/backend/features/favorites/routes.ts +++ b/src/backend/features/favorites/routes.ts @@ -9,7 +9,7 @@ import { } from "../../lib/route-params"; import { type Db, getDb } from "../../db/client"; import { users, favorites } from "../../db/schema"; -import type { Favorite, FavoritesData } from "./dto"; +import type { Favorite, FavoritesData } from "./contract"; import type { LibraryId } from "../library/library-id"; import { z } from "zod"; import { zValidator } from "@hono/zod-validator"; @@ -119,7 +119,7 @@ favoriteRoutes.delete(favoriteRoute(), requireSignInMiddleware, async (c) => { const userId = await c.var.getUserId(); const db = getDb(c.env.DB); - // security: Require the user to also match + // Scoped to the owner, so another user's favorite matches nothing. await db .delete(favorites) .where(and(eq(favorites.id, favoriteId), eq(favorites.userId, userId))); @@ -134,14 +134,19 @@ favoriteRoutes.post( zValidator("json", favoriteOrderBody), async (c) => { const { favoriteOrder } = c.req.valid("json"); + const userId = await c.var.getUserId(); const db = getDb(c.env.DB); + // Scoped to the owner rather than checked first: a favorite that is not + // theirs matches nothing, which costs no extra read. await Promise.all( favoriteOrder.map((id, i) => db .update(favorites) .set({ sortOrder: i }) - .where(eq(favorites.id, id)) + .where( + and(eq(favorites.id, id), eq(favorites.userId, userId)) + ) ) ); @@ -157,12 +162,15 @@ favoriteRoutes.post( async (c) => { const favoriteId = getFavoriteParam(c); const { defaultConfiguration } = c.req.valid("json"); + const userId = await c.var.getUserId(); const db = getDb(c.env.DB); await db .update(favorites) .set({ defaultConfiguration }) - .where(eq(favorites.id, favoriteId)); + .where( + and(eq(favorites.id, favoriteId), eq(favorites.userId, userId)) + ); return c.json({ success: true }); } diff --git a/src/backend/features/library/dto.ts b/src/backend/features/library/contract.ts similarity index 80% rename from src/backend/features/library/dto.ts rename to src/backend/features/library/contract.ts index 4e3fefbc1..9115e31dd 100644 --- a/src/backend/features/library/dto.ts +++ b/src/backend/features/library/contract.ts @@ -39,11 +39,3 @@ export interface LibraryOut { groups: Groups; insertables: Insertables; } - -/** - * Whether a library-load job is running, and how long it has been going. - * Milliseconds since the oldest running job started paces the client's polling. - */ -export type JobStatus = - | { running: false } - | { running: true; runningForMs: number }; diff --git a/src/backend/features/library/db.test.ts b/src/backend/features/library/db.test.ts index 7acbbff67..ae74cd6c9 100644 --- a/src/backend/features/library/db.test.ts +++ b/src/backend/features/library/db.test.ts @@ -80,14 +80,14 @@ describe("rebuildSearchDb", () => { it("indexes an unconfigurable insertable's part number", async () => { await seedGroup(db); await seedInsertable(db, { - partData: { + partMetadata: { partNumber: "WCP-0405", name: "2x1 Tube", - description: null, - material: null, - vendor: null, + description: undefined, + material: undefined, + vendor: undefined, hasMultipleParts: false, - isUnstableComposite: false + isOpenComposite: false } }); diff --git a/src/backend/features/library/db.ts b/src/backend/features/library/db.ts index 217a7ba0c..858806850 100644 --- a/src/backend/features/library/db.ts +++ b/src/backend/features/library/db.ts @@ -2,7 +2,7 @@ import { asc, eq, sql } from "drizzle-orm"; import { type Db } from "../../db/client"; import { libraries, group, insertables, configurations } from "../../db/schema"; import { LibraryId } from "./library-id"; -import { InsertableOut, LibraryOut, Insertables, Groups } from "./dto"; +import { InsertableOut, LibraryOut, Insertables, Groups } from "./contract"; import { ConfigurationRecord } from "../configurations/models"; import { toRecords } from "../configurations/utils"; import { buildSearchDb } from "../search/search-index"; @@ -196,7 +196,7 @@ async function getRecordsMap( const rows = await db .select({ id: insertables.id, - partData: insertables.partData, + partMetadata: insertables.partMetadata, records: configurations.records }) .from(insertables) @@ -206,7 +206,7 @@ async function getRecordsMap( const recordsMap: Record<string, ConfigurationRecord[]> = {}; for (const row of rows) { - const records = toRecords(row.partData, row.records ?? []); + const records = toRecords(row.partMetadata, row.records ?? []); if (records.length > 0) { recordsMap[row.id] = records; } diff --git a/src/backend/features/library/groups/routes.test.ts b/src/backend/features/library/groups/routes.test.ts index f88f54c67..92045855b 100644 --- a/src/backend/features/library/groups/routes.test.ts +++ b/src/backend/features/library/groups/routes.test.ts @@ -14,7 +14,7 @@ import { } from "../../../../__test_utils__"; import MiniSearch from "minisearch"; import { getDb } from "../../../db/client"; -import type { JobStatus } from "../dto"; +import type { JobStatus } from "../../load/contract"; import { searchIndexKey } from "../db"; import { SEARCH_OPTIONS, type SearchDocument } from "../../search/search-index"; import * as DocumentsEndpoint from "../../../lib/onshape/endpoints/documents"; diff --git a/src/backend/features/library/insertables/routes.test.ts b/src/backend/features/library/insertables/routes.test.ts index 966b5a9ef..13f2eaa47 100644 --- a/src/backend/features/library/insertables/routes.test.ts +++ b/src/backend/features/library/insertables/routes.test.ts @@ -184,14 +184,10 @@ describe("insertable routes", () => { // Nothing to configure, so the part data lands on the insertable and // no configurations row is manufactured to hold it. - expect(row?.partData).toEqual({ + expect(row?.partMetadata).toEqual({ partNumber: "PN-123", - name: null, - description: null, - material: null, - vendor: null, hasMultipleParts: false, - isUnstableComposite: false + isOpenComposite: false }); expect(await readConfig(TEST_PART_STUDIO_ID)).toBeUndefined(); }); @@ -280,7 +276,7 @@ describe("insertable routes", () => { expect(res.status).toBe(200); const row = await readInsertable(TEST_PART_STUDIO_ID); - expect(row?.partData).not.toBeNull(); + expect(row?.partMetadata).not.toBeNull(); // Nobody sells it, so a missing part number is not worth flagging. expect(row?.buildIssues).toEqual([]); }); @@ -294,7 +290,7 @@ describe("insertable routes", () => { .set({ buildIssues: [ { type: BuildIssueType.NO_VENDORS }, - { type: BuildIssueType.TOO_MANY_CONFIGURATIONS } + { type: BuildIssueType.CONFIGURATION_LIMIT_EXCEEDED } ] }) .where(eq(insertables.id, TEST_PART_STUDIO_ID)); diff --git a/src/backend/features/library/insertables/routes.ts b/src/backend/features/library/insertables/routes.ts index 6c575351e..4ebb00778 100644 --- a/src/backend/features/library/insertables/routes.ts +++ b/src/backend/features/library/insertables/routes.ts @@ -167,7 +167,7 @@ insertableRoutes.post( ...indexing.buildIssues, // Vendors are read, not re-derived: the load path wrote them. ...checkIndexedPartNumber(row.vendors, [ - indexed.partData, + indexed.partMetadata, ...indexed.records ]) ); @@ -195,7 +195,7 @@ insertableRoutes.post( .update(insertables) .set({ indexConfigurations: body.indexConfigurations, - partData: indexed.partData, + partMetadata: indexed.partMetadata, buildIssues }) .where(eq(insertables.id, insertableId)), diff --git a/src/backend/features/library/library-id.ts b/src/backend/features/library/library-id.ts index 192d4071f..66a48607c 100644 --- a/src/backend/features/library/library-id.ts +++ b/src/backend/features/library/library-id.ts @@ -3,6 +3,3 @@ export enum LibraryId { FTC_DESIGN_LIB = "ftc-design-lib", MKCAD = "mkcad" } - -/** The library a user lands in before they have picked one. */ -export const DEFAULT_LIBRARY_ID = LibraryId.FRC_DESIGN_LIB; diff --git a/src/backend/features/library/routes.test.ts b/src/backend/features/library/routes.test.ts index 6ea1c6cc1..6758599e8 100644 --- a/src/backend/features/library/routes.test.ts +++ b/src/backend/features/library/routes.test.ts @@ -14,7 +14,7 @@ import { } from "../../../__test_utils__"; import { getDb } from "../../db/client"; import { rebuildSearchDb, searchIndexKey } from "./db"; -import { LibraryOut } from "./dto"; +import { LibraryOut } from "./contract"; import { LibraryId } from "./library-id"; const db = getDb(env.DB); diff --git a/src/backend/features/load/contract.ts b/src/backend/features/load/contract.ts new file mode 100644 index 000000000..b752b231d --- /dev/null +++ b/src/backend/features/load/contract.ts @@ -0,0 +1,7 @@ +/** + * Whether a library-load job is running, and how long it has been going. + * Milliseconds since the oldest running job started paces the client's polling. + */ +export type JobStatus = + | { running: false } + | { running: true; runningForMs: number }; diff --git a/src/backend/features/load/job-tracker.ts b/src/backend/features/load/job-tracker.ts index ff8c981d0..a40b7786e 100644 --- a/src/backend/features/load/job-tracker.ts +++ b/src/backend/features/load/job-tracker.ts @@ -1,6 +1,6 @@ import type { AppBindings } from "../../lib/context"; import type { LibraryId } from "../library/library-id"; -import type { JobStatus } from "../library/dto"; +import type { JobStatus } from "./contract"; /** * Backstop for a job that crashes before untracking itself; must outlast the diff --git a/src/backend/features/load/load-insertable.test.ts b/src/backend/features/load/load-insertable.test.ts index 7748e7fe4..30e19a303 100644 --- a/src/backend/features/load/load-insertable.test.ts +++ b/src/backend/features/load/load-insertable.test.ts @@ -3,7 +3,10 @@ import { eq } from "drizzle-orm"; import { beforeEach, describe, expect, it } from "vitest"; import { getDb } from "../../db/client"; import { configurations, insertables } from "../../db/schema"; -import type { ConfigurationRecord, PartData } from "../configurations/models"; +import type { + ConfigurationRecord, + PartMetadata +} from "../configurations/models"; import { TEST_PARAMETERS, TEST_PART_STUDIO_ID, @@ -26,25 +29,17 @@ function readInsertable() { .get(); } -/** Builds an element's own part data with the given part number and defaults. */ -function partData(partNumber: string | null): PartData { - return { - partNumber, - name: null, - description: null, - material: null, - vendor: null, - hasMultipleParts: false, - isUnstableComposite: false - }; +/** The element's own metadata, with the given part number and defaults. */ +function partMetadata(partNumber?: string): PartMetadata { + return { partNumber, hasMultipleParts: false, isOpenComposite: false }; } /** Builds a configuration record with the given part number and defaults. */ function record( - partNumber: string | null, + partNumber?: string, configuration: Record<string, string> = {} ): ConfigurationRecord { - return { ...partData(partNumber), configuration }; + return { ...partMetadata(partNumber), configuration }; } describe("saveInsertable", () => { @@ -140,7 +135,7 @@ describe("saveInsertable", () => { db, insertableTarget(), parsedInsertable({ - partData: partData("PN-default"), + partMetadata: partMetadata("PN-default"), configuration: { parameters: [], records: [] } }) ); @@ -150,7 +145,7 @@ describe("saveInsertable", () => { .from(insertables) .where(eq(insertables.id, TEST_PART_STUDIO_ID)) .get(); - expect(insertable?.partData).toEqual(partData("PN-default")); + expect(insertable?.partMetadata).toEqual(partMetadata("PN-default")); expect(await db.select().from(configurations).all()).toHaveLength(0); }); diff --git a/src/backend/features/load/load-insertable.ts b/src/backend/features/load/load-insertable.ts index 7662232fb..6f8da25ad 100644 --- a/src/backend/features/load/load-insertable.ts +++ b/src/backend/features/load/load-insertable.ts @@ -2,7 +2,7 @@ import { eq } from "drizzle-orm"; import { type Db, getDb } from "../../db/client"; import type { Configuration, - PartData, + PartMetadata, ConfigurationParameter } from "../configurations/models"; import { @@ -47,7 +47,7 @@ export interface ParsedInsertable { isOpenComposite: boolean; buildIssues: BuildIssue[]; /** The element's own part data; null when nothing was probed. */ - partData: PartData | null; + partMetadata: PartMetadata | null; configuration: Configuration; } @@ -111,7 +111,7 @@ export async function loadInsertable( ? checkInsertable({ vendors, thumbnailUrls, - probed: [recordsResult.partData, ...recordsResult.records] + probes: [recordsResult.partMetadata, ...recordsResult.records] }) : [{ type: BuildIssueType.NO_PARTS }], ...recordsResult.buildIssues, @@ -124,7 +124,7 @@ export async function loadInsertable( fastenInfo, isOpenComposite, buildIssues, - partData: recordsResult.partData, + partMetadata: recordsResult.partMetadata, configuration: { parameters, records: recordsResult.records } }; @@ -230,7 +230,7 @@ export async function saveInsertable( largeThumbnailUrl: parsed.thumbnailUrls?.large ?? null, fastenInfo: parsed.fastenInfo, isOpenComposite: parsed.isOpenComposite, - partData: parsed.partData, + partMetadata: parsed.partMetadata, buildIssues: parsed.buildIssues, lastLoadedAt: Date.now() }; diff --git a/src/backend/features/load/parse-configuration-records.test.ts b/src/backend/features/load/parse-configuration-records.test.ts index 02ce82687..28132f56f 100644 --- a/src/backend/features/load/parse-configuration-records.test.ts +++ b/src/backend/features/load/parse-configuration-records.test.ts @@ -44,8 +44,8 @@ function paramsWithConfigs(count: number): ConfigurationParameter[] { ]; } -const MANY = [{ type: BuildIssueType.MANY_CONFIGURATIONS }]; -const TOO_MANY = [{ type: BuildIssueType.TOO_MANY_CONFIGURATIONS }]; +const MANY = [{ type: BuildIssueType.MANUAL_INDEXING_REQUIRED }]; +const TOO_MANY = [{ type: BuildIssueType.CONFIGURATION_LIMIT_EXCEEDED }]; describe("decideIndexing", () => { // Vendors no longer enter into it: the configuration count is the only gate. @@ -97,7 +97,7 @@ describe("parsePartStudioRecord", () => { material: "6061 Aluminum", vendor: "AM", hasMultipleParts: false, - isUnstableComposite: false + isOpenComposite: false }); }); @@ -125,7 +125,7 @@ describe("parsePartStudioRecord", () => { ); expect(record.partNumber).toBe("COMP-1"); expect(record.hasMultipleParts).toBe(false); - expect(record.isUnstableComposite).toBe(false); + expect(record.isOpenComposite).toBe(true); }); it("flags an unstable composite when a configuration loses its composite", () => { @@ -137,26 +137,22 @@ describe("parsePartStudioRecord", () => { ) ).toEqual({ configuration: { size: "S" }, - partNumber: null, - name: null, - description: null, - material: null, - vendor: null, hasMultipleParts: false, - isUnstableComposite: true + // The composite it was expected to resolve to is gone. + isOpenComposite: false }); }); it("returns an all-null record for an empty response", () => { expect(parsePartStudioRecord([], { A: "a1" }, false)).toEqual({ configuration: { A: "a1" }, - partNumber: null, - name: null, - description: null, - material: null, - vendor: null, + partNumber: undefined, + name: undefined, + description: undefined, + material: undefined, + vendor: undefined, hasMultipleParts: false, - isUnstableComposite: false + isOpenComposite: false }); }); }); @@ -182,7 +178,7 @@ describe("parseAssemblyRecord", () => { material: "Steel", vendor: "AM", hasMultipleParts: false, - isUnstableComposite: false + isOpenComposite: false }); }); }); @@ -216,7 +212,7 @@ describe("parseConfigurationRecords", () => { expect(result.buildIssues).toEqual([]); // "a1" is A's default, so that combination is the element's own probe // under another name and is not probed again. - expect(result.partData?.partNumber).toBe("PN-default"); + expect(result.partMetadata?.partNumber).toBe("PN-default"); expect(result.records.map((r) => r.partNumber)).toEqual(["PN-a2"]); }); @@ -236,7 +232,7 @@ describe("parseConfigurationRecords", () => { false ); - expect(result.partData?.partNumber).toBe("PN-default"); + expect(result.partMetadata?.partNumber).toBe("PN-default"); expect(result.records.map((r) => r.partNumber)).toEqual(["PN-a1"]); }); @@ -310,7 +306,7 @@ describe("parseConfigurationRecords", () => { expect(result.buildIssues).toEqual([]); expect(result.records).toHaveLength(0); - expect(result.partData?.partNumber).toBe("PN-default"); + expect(result.partMetadata?.partNumber).toBe("PN-default"); expect(spy).toHaveBeenCalledTimes(1); }); @@ -331,14 +327,14 @@ describe("parseConfigurationRecords", () => { ); expect(result.records).toEqual([]); - expect(result.partData).toEqual({ + expect(result.partMetadata).toEqual({ partNumber: "AM-1", - name: null, - description: null, - material: null, - vendor: null, + name: undefined, + description: undefined, + material: undefined, + vendor: undefined, hasMultipleParts: false, - isUnstableComposite: false + isOpenComposite: false }); expect(spy).toHaveBeenCalledWith(CLIENT, PATH, {}); }); diff --git a/src/backend/features/load/parse-configuration-records.ts b/src/backend/features/load/parse-configuration-records.ts index 9e3af4d72..847f644c8 100644 --- a/src/backend/features/load/parse-configuration-records.ts +++ b/src/backend/features/load/parse-configuration-records.ts @@ -8,7 +8,7 @@ import { ElementType } from "../../lib/onshape/element-type"; import { ParameterValues, ConfigurationParameter, - PartData, + PartMetadata, ConfigurationRecord } from "../configurations/models"; import { @@ -37,15 +37,15 @@ const BATCH_SIZE = 20; /** An insertable's configuration records, and the issues indexing them raised. */ export interface ConfigurationRecordsResult { /** The element's own part data; null when nothing was probed. */ - partData: PartData | null; - /** One per indexed configuration; the element's own is `partData`. */ + partMetadata: PartMetadata | null; + /** One per indexed configuration; the element's own is `partMetadata`. */ records: ConfigurationRecord[]; buildIssues: BuildIssue[]; } /** The result for an insertable that isn't indexed. */ export const NO_RECORDS: ConfigurationRecordsResult = { - partData: null, + partMetadata: null, records: [], buildIssues: [] }; @@ -55,8 +55,8 @@ export const NO_RECORDS: ConfigurationRecordsResult = { * issues clears these first, so a resolved issue doesn't stick around. */ export const INDEXING_ISSUE_TYPES = [ - BuildIssueType.TOO_MANY_CONFIGURATIONS, - BuildIssueType.MANY_CONFIGURATIONS, + BuildIssueType.CONFIGURATION_LIMIT_EXCEEDED, + BuildIssueType.MANUAL_INDEXING_REQUIRED, BuildIssueType.MULTIPLE_PARTS, BuildIssueType.UNSTABLE_COMPOSITE, BuildIssueType.NO_PART_NUMBER @@ -83,14 +83,16 @@ export function decideIndexing( if (band === IndexingBand.EXCEEDED) { return { shouldIndex, - buildIssues: [{ type: BuildIssueType.TOO_MANY_CONFIGURATIONS }], + buildIssues: [ + { type: BuildIssueType.CONFIGURATION_LIMIT_EXCEEDED } + ], configurations }; } if (band === IndexingBand.MANUAL && !indexConfigurations) { return { shouldIndex, - buildIssues: [{ type: BuildIssueType.MANY_CONFIGURATIONS }], + buildIssues: [{ type: BuildIssueType.MANUAL_INDEXING_REQUIRED }], configurations }; } @@ -98,9 +100,9 @@ export function decideIndexing( } /** Trims a raw metadata value; a missing or blank one becomes `null`. */ -function normalizeText(value: string | undefined | null): string | null { +function normalizeText(value: string | undefined | null): string | undefined { const trimmed = value?.trim(); - return trimmed ? trimmed : null; + return trimmed ? trimmed : undefined; } /** What a part studio's parts resolve to, before build issues are decided. */ @@ -148,16 +150,13 @@ export function parsePartStudioRecord( isOpenComposite: boolean ): ConfigurationRecord { const evaluation = evaluateParts(parts); + // An element that is an open composite everywhere else has no part to read + // in a configuration that loses it; toResult raises the build issue. if (isOpenComposite && !evaluation.isOpenComposite) { return { configuration, - partNumber: null, - name: null, - description: null, - material: null, - vendor: null, hasMultipleParts: false, - isUnstableComposite: true + isOpenComposite: false }; } const part = evaluation.partToUse; @@ -169,7 +168,7 @@ export function parsePartStudioRecord( material: normalizeText(part?.material?.displayName), vendor: normalizeText(part?.vendor), hasMultipleParts: evaluation.hasMultipleParts, - isUnstableComposite: false + isOpenComposite: evaluation.isOpenComposite }; } @@ -183,14 +182,14 @@ const METADATA_FIELDS = { } as const; /** Reads a metadata property value as text; materials arrive as `{displayName}`. */ -function readMetadataValue(value: unknown): string | null { +function readMetadataValue(value: unknown): string | undefined { if (typeof value === "string") { return normalizeText(value); } if (value && typeof value === "object" && "displayName" in value) { return normalizeText((value as { displayName?: string }).displayName); } - return null; + return undefined; } /** Builds a record from an assembly's element metadata for one configuration. */ @@ -198,15 +197,11 @@ export function parseAssemblyRecord( metadata: OnshapeMetadataObject, configuration: ParameterValues ): ConfigurationRecord { + // An assembly is never a composite, so it reads nothing about one. const record: ConfigurationRecord = { configuration, - partNumber: null, - name: null, - description: null, - material: null, - vendor: null, hasMultipleParts: false, - isUnstableComposite: false + isOpenComposite: false }; for (const property of metadata.properties) { const field = @@ -376,14 +371,14 @@ function toResult( ): ConfigurationRecordsResult { // The element's own probe describes the element, not a configuration of it, // so it sheds the (empty) configuration that produced it. - const partData: PartData = { + const partMetadata: PartMetadata = { partNumber: defaultRecord.partNumber, name: defaultRecord.name, description: defaultRecord.description, material: defaultRecord.material, vendor: defaultRecord.vendor, hasMultipleParts: defaultRecord.hasMultipleParts, - isUnstableComposite: defaultRecord.isUnstableComposite + isOpenComposite: defaultRecord.isOpenComposite }; // Canonical, so a record addresses the same thumbnail the insert menu does @@ -397,19 +392,24 @@ function toResult( })); // A capped insertable never reaches here: decideIndexing turns indexing off - // past the cap, and raises TOO_MANY_CONFIGURATIONS itself. - const probed: PartData[] = [partData, ...records]; + // past the cap, and raises CONFIGURATION_LIMIT_EXCEEDED itself. + const probes: PartMetadata[] = [partMetadata, ...records]; let buildIssues: BuildIssue[] = []; - if (probed.some((record) => record.hasMultipleParts)) { + if (probes.some((probe) => probe.hasMultipleParts)) { buildIssues = addBuildIssue(buildIssues, { type: BuildIssueType.MULTIPLE_PARTS }); } - if (probed.some((record) => record.isUnstableComposite)) { + // The element's own probe sets the expectation; losing the composite in any + // configuration is what makes it unstable. + if ( + partMetadata.isOpenComposite && + probes.some((probe) => !probe.isOpenComposite) + ) { buildIssues = addBuildIssue(buildIssues, { type: BuildIssueType.UNSTABLE_COMPOSITE }); } - return { partData, records, buildIssues }; + return { partMetadata, records, buildIssues }; } diff --git a/src/backend/features/search/search-index.ts b/src/backend/features/search/search-index.ts index 03b8833cd..9124f6584 100644 --- a/src/backend/features/search/search-index.ts +++ b/src/backend/features/search/search-index.ts @@ -3,7 +3,7 @@ * deserializes it with the same options. */ import MiniSearch, { Options } from "minisearch"; -import { LibraryOut } from "../library/dto"; +import { LibraryOut } from "../library/contract"; import { Vendor } from "../library/vendors"; import { ConfigurationRecord, SearchRecord } from "../configurations/models"; @@ -118,7 +118,7 @@ export const SEARCH_OPTIONS: Options<SearchDocument> = { }; /** Joins the distinct non-null values with spaces (a searchable field's form). */ -function uniqueJoin(values: (string | null)[]): string { +function uniqueJoin(values: (string | undefined)[]): string { return Array.from( new Set(values.filter((value): value is string => !!value)) ).join(" "); diff --git a/src/backend/features/settings/settings.ts b/src/backend/features/settings/settings.ts index 7c07e0b90..c30a65ab3 100644 --- a/src/backend/features/settings/settings.ts +++ b/src/backend/features/settings/settings.ts @@ -9,13 +9,13 @@ export enum Theme { /** User settings, which the entry redirect reads and seeds the app with. */ export interface Settings { theme: Theme; + /** The library the caller last opened, and lands in next time. */ + libraryId: LibraryId; } -export interface SettingsUpdate { - theme?: Theme; - libraryId?: LibraryId; -} +export type SettingsUpdate = Partial<Settings>; export const DEFAULT_SETTINGS: Settings = { - theme: Theme.SYSTEM + theme: Theme.SYSTEM, + libraryId: LibraryId.FRC_DESIGN_LIB }; diff --git a/src/frontend/components/root-error.tsx b/src/frontend/components/root-error.tsx index 86e70665d..1f4f48a0e 100644 --- a/src/frontend/components/root-error.tsx +++ b/src/frontend/components/root-error.tsx @@ -6,7 +6,7 @@ import { Button } from "@mantine/core"; import { House } from "@phosphor-icons/react"; import { IconSize } from "../lib/style-constants"; import { ReloadGroupsButton } from "../features/library/components/reload-groups-button"; -import { DEFAULT_LIBRARY_ID } from "@backend/features/library/library-id"; +import { DEFAULT_SETTINGS } from "@backend/features/settings/settings"; /** * Catch-all error state for when a route below the root fails to load. @@ -54,7 +54,7 @@ export function NotFoundError(): ReactNode { onClick={() => { void navigate({ to: "/app/library/$libraryId", - params: { libraryId: DEFAULT_LIBRARY_ID } + params: { libraryId: DEFAULT_SETTINGS.libraryId } }); }} > diff --git a/src/frontend/features/build-status/components/build-status.tsx b/src/frontend/features/build-status/components/build-status.tsx index 6a963b47e..a7df46a83 100644 --- a/src/frontend/features/build-status/components/build-status.tsx +++ b/src/frontend/features/build-status/components/build-status.tsx @@ -40,7 +40,7 @@ import { import { GroupBuildStatus, InsertableBuildStatus -} from "@backend/features/build-checker/dto"; +} from "@backend/features/build-checker/contract"; import { getVendorName, Vendor } from "@backend/features/library/vendors"; import { ConfigurationParameter, diff --git a/src/frontend/features/build-status/queries.ts b/src/frontend/features/build-status/queries.ts index 2e0dca329..b49b85e6e 100644 --- a/src/frontend/features/build-status/queries.ts +++ b/src/frontend/features/build-status/queries.ts @@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { apiGet } from "../../lib/api-client"; -import { type LibraryBuildStatus } from "@backend/features/build-checker/dto"; +import { type LibraryBuildStatus } from "@backend/features/build-checker/contract"; import { LibraryId } from "@backend/features/library/library-id"; import { useLibraryId } from "../library/library-path"; import { useCacheVersion } from "../library/queries"; diff --git a/src/frontend/features/favorites/components/favorite-button.tsx b/src/frontend/features/favorites/components/favorite-button.tsx index 79c694293..9ba1cc675 100644 --- a/src/frontend/features/favorites/components/favorite-button.tsx +++ b/src/frontend/features/favorites/components/favorite-button.tsx @@ -4,8 +4,11 @@ import { HeartIconColor, IconSize } from "../../../lib/style-constants"; import { useMutation } from "@tanstack/react-query"; import { ReactNode, useState } from "react"; import { apiDelete, apiPost } from "../../../lib/api-client"; -import type { Favorite, FavoritesData } from "@backend/features/favorites/dto"; -import type { InsertableOut } from "@backend/features/library/dto"; +import type { + Favorite, + FavoritesData +} from "@backend/features/favorites/contract"; +import type { InsertableOut } from "@backend/features/library/contract"; import { LibraryId } from "@backend/features/library/library-id"; import { queryClient } from "../../../lib/query-client"; import { useRouter } from "@tanstack/react-router"; diff --git a/src/frontend/features/favorites/components/favorite-card.tsx b/src/frontend/features/favorites/components/favorite-card.tsx index a6eacf6d3..61f5fa9b3 100644 --- a/src/frontend/features/favorites/components/favorite-card.tsx +++ b/src/frontend/features/favorites/components/favorite-card.tsx @@ -1,7 +1,7 @@ import { encodeCanonicalConfiguration } from "@backend/features/configurations/canonical"; import { ReactNode } from "react"; -import { Favorite } from "@backend/features/favorites/dto"; -import { InsertableOut } from "@backend/features/library/dto"; +import { Favorite } from "@backend/features/favorites/contract"; +import { InsertableOut } from "@backend/features/library/contract"; import { useMutation } from "@tanstack/react-query"; import { apiPost } from "../../../lib/api-client"; import { queryClient } from "../../../lib/query-client"; diff --git a/src/frontend/features/favorites/components/favorite-menu.tsx b/src/frontend/features/favorites/components/favorite-menu.tsx index 4b40225cb..5fffc475c 100644 --- a/src/frontend/features/favorites/components/favorite-menu.tsx +++ b/src/frontend/features/favorites/components/favorite-menu.tsx @@ -9,7 +9,7 @@ import { apiPost } from "../../../lib/api-client"; import { showErrorToast, showSuccessToast } from "../../../lib/notifications"; import { PreviewImageCard } from "../../thumbnails/components/thumbnail"; import { ConfigurationWrapper } from "../../insert/components/configurations"; -import type { FavoritesData } from "@backend/features/favorites/dto"; +import type { FavoritesData } from "@backend/features/favorites/contract"; import { HeartIcon } from "./favorite-button"; import { queryClient } from "../../../lib/query-client"; import { diff --git a/src/frontend/features/favorites/components/favorites-list.tsx b/src/frontend/features/favorites/components/favorites-list.tsx index 5ba02f205..c36fb4136 100644 --- a/src/frontend/features/favorites/components/favorites-list.tsx +++ b/src/frontend/features/favorites/components/favorites-list.tsx @@ -3,8 +3,8 @@ import { HeartBreak } from "@phosphor-icons/react"; import { HeartIconColor, IconSize } from "../../../lib/style-constants"; import { ReactNode } from "react"; import { filterInsertables } from "../../search/filter"; -import { getFavoriteForInsertable } from "@backend/features/favorites/dto"; -import { InsertableOut } from "@backend/features/library/dto"; +import { getFavoriteForInsertable } from "@backend/features/favorites/contract"; +import { InsertableOut } from "@backend/features/library/contract"; import { useUiState } from "../../../lib/ui-state"; import { SectionError, diff --git a/src/frontend/features/favorites/queries.ts b/src/frontend/features/favorites/queries.ts index 447d3455c..18a7fede0 100644 --- a/src/frontend/features/favorites/queries.ts +++ b/src/frontend/features/favorites/queries.ts @@ -1,6 +1,6 @@ import { queryOptions, useQuery } from "@tanstack/react-query"; import { apiGet } from "../../lib/api-client"; -import { type FavoritesData } from "@backend/features/favorites/dto"; +import { type FavoritesData } from "@backend/features/favorites/contract"; import { LibraryId } from "@backend/features/library/library-id"; import { useAccessData } from "../auth/access-level"; import { useLibraryId } from "../library/library-path"; diff --git a/src/frontend/features/insert/components/insert-menu.tsx b/src/frontend/features/insert/components/insert-menu.tsx index 859556ae2..470518693 100644 --- a/src/frontend/features/insert/components/insert-menu.tsx +++ b/src/frontend/features/insert/components/insert-menu.tsx @@ -1,7 +1,7 @@ import { useSearch } from "@tanstack/react-router"; import { ReactNode, useCallback, useEffect, useState } from "react"; -import { getFavoriteForInsertable } from "@backend/features/favorites/dto"; -import { InsertableOut } from "@backend/features/library/dto"; +import { getFavoriteForInsertable } from "@backend/features/favorites/contract"; +import { InsertableOut } from "@backend/features/library/contract"; import { ElementType } from "@backend/lib/onshape/element-type"; import { Button, Checkbox, Group, Stack, Text } from "@mantine/core"; import { Info, Plus } from "@phosphor-icons/react"; diff --git a/src/frontend/features/insert/insert-hooks.ts b/src/frontend/features/insert/insert-hooks.ts index aeec61af2..b30df8d40 100644 --- a/src/frontend/features/insert/insert-hooks.ts +++ b/src/frontend/features/insert/insert-hooks.ts @@ -1,7 +1,7 @@ import { useMutation } from "@tanstack/react-query"; import { useSearch } from "@tanstack/react-router"; import { apiPost } from "../../lib/api-client"; -import { InsertableOut } from "@backend/features/library/dto"; +import { InsertableOut } from "@backend/features/library/contract"; import { ElementType } from "@backend/lib/onshape/element-type"; import { type ElementPath } from "@backend/lib/onshape/path"; import { showLoadingToast, showSuccessToast } from "../../lib/notifications"; diff --git a/src/frontend/features/library/card-hooks.ts b/src/frontend/features/library/card-hooks.ts index 9a74a863b..eb136f009 100644 --- a/src/frontend/features/library/card-hooks.ts +++ b/src/frontend/features/library/card-hooks.ts @@ -2,8 +2,8 @@ import { useAccessData } from "../auth/access-level"; import { useMutation } from "@tanstack/react-query"; import { modals } from "@mantine/modals"; import { apiPost } from "../../lib/api-client"; -import { LibraryBuildStatus } from "@backend/features/build-checker/dto"; -import { InsertableOut } from "@backend/features/library/dto"; +import { LibraryBuildStatus } from "@backend/features/build-checker/contract"; +import { InsertableOut } from "@backend/features/library/contract"; import { hasUserAccess } from "@backend/features/auth/access-level"; import { useCallback, useMemo } from "react"; import { diff --git a/src/frontend/features/library/components/add-group-menu.tsx b/src/frontend/features/library/components/add-group-menu.tsx index 0f0790ae4..bea008a4d 100644 --- a/src/frontend/features/library/components/add-group-menu.tsx +++ b/src/frontend/features/library/components/add-group-menu.tsx @@ -11,7 +11,7 @@ import { showInfoToast, showLoadingToast } from "../../../lib/notifications"; import { queryClient } from "../../../lib/query-client"; import { toLibraryPath, useLibraryId } from "../library-path"; import { jobStatusQueryKey } from "../../../lib/query-keys"; -import type { JobStatus } from "@backend/features/library/dto"; +import type { JobStatus } from "@backend/features/load/contract"; function openAddGroupMenu(selectedGroupId?: string) { modals.open({ diff --git a/src/frontend/features/library/components/card-components.tsx b/src/frontend/features/library/components/card-components.tsx index 53a8c1a9d..89003e78d 100644 --- a/src/frontend/features/library/components/card-components.tsx +++ b/src/frontend/features/library/components/card-components.tsx @@ -26,7 +26,7 @@ import { useInsertMutation, useIsAssemblyInPartStudio } from "../../insert/insert-hooks"; -import { InsertableOut } from "@backend/features/library/dto"; +import { InsertableOut } from "@backend/features/library/contract"; import { ElementType } from "@backend/lib/onshape/element-type"; import { ParameterValues } from "@backend/features/configurations/models"; diff --git a/src/frontend/features/library/components/group-card.tsx b/src/frontend/features/library/components/group-card.tsx index d8a4fa742..78dba30fc 100644 --- a/src/frontend/features/library/components/group-card.tsx +++ b/src/frontend/features/library/components/group-card.tsx @@ -3,7 +3,7 @@ import { ArrowRight, Eye, EyeSlash, Trash } from "@phosphor-icons/react"; import { IconSize } from "../../../lib/style-constants"; import { useNavigate } from "@tanstack/react-router"; import { PropsWithChildren, ReactNode } from "react"; -import { GroupOut, LibraryOut } from "@backend/features/library/dto"; +import { GroupOut, LibraryOut } from "@backend/features/library/contract"; import { useMutation } from "@tanstack/react-query"; import { apiPost, apiDelete } from "../../../lib/api-client"; import { showErrorToast } from "../../../lib/notifications"; diff --git a/src/frontend/features/library/components/insertable-card.tsx b/src/frontend/features/library/components/insertable-card.tsx index 53616df98..6344f040b 100644 --- a/src/frontend/features/library/components/insertable-card.tsx +++ b/src/frontend/features/library/components/insertable-card.tsx @@ -4,8 +4,8 @@ import { PropsWithChildren, ReactNode } from "react"; import { Favorite, getFavoriteForInsertable -} from "@backend/features/favorites/dto"; -import { InsertableOut } from "@backend/features/library/dto"; +} from "@backend/features/favorites/contract"; +import { InsertableOut } from "@backend/features/library/contract"; import { ParameterValues } from "@backend/features/configurations/models"; import { SearchHit } from "../../search/search"; import { diff --git a/src/frontend/features/library/components/reload-groups-button.tsx b/src/frontend/features/library/components/reload-groups-button.tsx index 3f0729ea6..096e9ea63 100644 --- a/src/frontend/features/library/components/reload-groups-button.tsx +++ b/src/frontend/features/library/components/reload-groups-button.tsx @@ -10,7 +10,7 @@ import { queryClient } from "../../../lib/query-client"; import { getAppErrorHandler } from "../../../lib/errors"; import { toLibraryPath, useLibraryId } from "../library-path"; import { jobStatusQueryKey } from "../../../lib/query-keys"; -import type { JobStatus } from "@backend/features/library/dto"; +import type { JobStatus } from "@backend/features/load/contract"; interface ReloadGroupsButtonProps { reloadAll?: boolean; diff --git a/src/frontend/features/library/library-path.ts b/src/frontend/features/library/library-path.ts index 1002f5d76..fe1213b46 100644 --- a/src/frontend/features/library/library-path.ts +++ b/src/frontend/features/library/library-path.ts @@ -1,8 +1,6 @@ import { useParams } from "@tanstack/react-router"; -import { - DEFAULT_LIBRARY_ID, - LibraryId -} from "@backend/features/library/library-id"; +import { LibraryId } from "@backend/features/library/library-id"; +import { DEFAULT_SETTINGS } from "@backend/features/settings/settings"; /** Returns the library being displayed, which the url is the source of truth for. */ export function useLibraryId(): LibraryId { @@ -12,7 +10,7 @@ export function useLibraryId(): LibraryId { from: "/app/library/$libraryId", shouldThrow: false }); - return params?.libraryId ?? DEFAULT_LIBRARY_ID; + return params?.libraryId ?? DEFAULT_SETTINGS.libraryId; } export function toLibraryPath(libraryId: LibraryId): string { diff --git a/src/frontend/features/library/queries.ts b/src/frontend/features/library/queries.ts index 6678ba754..53d6e83bf 100644 --- a/src/frontend/features/library/queries.ts +++ b/src/frontend/features/library/queries.ts @@ -1,7 +1,8 @@ /** Queries for the library snapshot, its cache version, and its load jobs. */ import { queryOptions, useQuery } from "@tanstack/react-query"; import { apiGet } from "../../lib/api-client"; -import { type JobStatus, type LibraryOut } from "@backend/features/library/dto"; +import { type LibraryOut } from "@backend/features/library/contract"; +import { type JobStatus } from "@backend/features/load/contract"; import { hasEditorAccess } from "@backend/features/auth/access-level"; import { LibraryId } from "@backend/features/library/library-id"; import { useAccessData } from "../auth/access-level"; diff --git a/src/frontend/features/search/filter.ts b/src/frontend/features/search/filter.ts index 01fef08a3..6086966b6 100644 --- a/src/frontend/features/search/filter.ts +++ b/src/frontend/features/search/filter.ts @@ -1,4 +1,4 @@ -import { InsertableOut } from "@backend/features/library/dto"; +import { InsertableOut } from "@backend/features/library/contract"; import { Vendor } from "@backend/features/library/vendors"; import { FilterResult } from "./search"; diff --git a/src/frontend/features/search/search.test.ts b/src/frontend/features/search/search.test.ts index 2a53e8f54..46a7b9557 100644 --- a/src/frontend/features/search/search.test.ts +++ b/src/frontend/features/search/search.test.ts @@ -5,7 +5,7 @@ import { tokenize } from "@backend/features/search/search-index"; import { doSearch, type Position } from "./search"; -import { LibraryOut } from "@backend/features/library/dto"; +import { LibraryOut } from "@backend/features/library/contract"; import { ElementType } from "@backend/lib/onshape/element-type"; import { ConfigurationRecord, @@ -14,19 +14,16 @@ import { /** Builds a configuration record carrying a part number, name, + configuration. */ function record( - partNumber: string | null, + partNumber: string | undefined, configuration: ParameterValues, - name: string | null = null + name?: string ): ConfigurationRecord { return { configuration, partNumber, name, - description: null, - material: null, - vendor: null, hasMultipleParts: false, - isUnstableComposite: false + isOpenComposite: false }; } diff --git a/src/frontend/features/search/search.ts b/src/frontend/features/search/search.ts index 266908508..62a92d4fc 100644 --- a/src/frontend/features/search/search.ts +++ b/src/frontend/features/search/search.ts @@ -186,7 +186,7 @@ function matchedRecord( function findBestRecord( query: string, records: SearchRecord[], - selector: (record: SearchRecord) => string | null + selector: (record: SearchRecord) => string | undefined ): SearchRecord | undefined { const normalizedQuery = normalizeForMatch(query.trim()); if (records.length === 0 || normalizedQuery === "") { diff --git a/src/frontend/features/settings/local-settings.ts b/src/frontend/features/settings/local-settings.ts index 6f4d171a1..2968c7bf5 100644 --- a/src/frontend/features/settings/local-settings.ts +++ b/src/frontend/features/settings/local-settings.ts @@ -1,7 +1,4 @@ -import { - DEFAULT_LIBRARY_ID, - type LibraryId -} from "@backend/features/library/library-id"; +import { type LibraryId } from "@backend/features/library/library-id"; import { DEFAULT_SETTINGS, type SettingsUpdate, @@ -24,7 +21,7 @@ export function readLocalSettings(): { theme: Theme; libraryId: LibraryId } { const stored = readStored(); return { theme: stored.theme ?? DEFAULT_SETTINGS.theme, - libraryId: stored.libraryId ?? DEFAULT_LIBRARY_ID + libraryId: stored.libraryId ?? DEFAULT_SETTINGS.libraryId }; } diff --git a/src/frontend/routes/__root.tsx b/src/frontend/routes/__root.tsx index 106cd8c80..10d05727d 100644 --- a/src/frontend/routes/__root.tsx +++ b/src/frontend/routes/__root.tsx @@ -12,7 +12,6 @@ import { ReactNode, useMemo } from "react"; import { queryClient } from "../lib/query-client"; import { createAppTheme } from "../theme"; import { getColorTheme } from "../lib/onshape-params"; -import { DEFAULT_LIBRARY_ID } from "@backend/features/library/library-id"; import { DEFAULT_SETTINGS } from "@backend/features/settings/settings"; import { NotFoundError, RootCrash } from "../components/root-error"; @@ -31,7 +30,7 @@ function RootComponent(): ReactNode { // rewrites them — so the first paint is already the right colors. const params = useParams({ strict: false }); - const libraryId = params.libraryId ?? DEFAULT_LIBRARY_ID; + const libraryId = params.libraryId ?? DEFAULT_SETTINGS.libraryId; const theme = useMemo(() => createAppTheme(libraryId), [libraryId]); const colorTheme = getColorTheme( search.theme ?? DEFAULT_SETTINGS.theme, diff --git a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx index b5f5bb435..e508694be 100644 --- a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx +++ b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx @@ -15,7 +15,7 @@ import { } from "../../../../../lib/style-constants"; import { ReactNode } from "react"; import { SearchResults } from "../../../../../features/search/components/search-results"; -import { GroupOut, Insertables } from "@backend/features/library/dto"; +import { GroupOut, Insertables } from "@backend/features/library/contract"; import { hasEditorAccess } from "@backend/features/auth/access-level"; import { filterInsertables } from "../../../../../features/search/filter"; import { GroupMenuItems } from "../../../../../features/library/components/group-card"; diff --git a/src/frontend/routes/app/library/$libraryId/route.tsx b/src/frontend/routes/app/library/$libraryId/route.tsx index 7aaa203e2..e5773aeb3 100644 --- a/src/frontend/routes/app/library/$libraryId/route.tsx +++ b/src/frontend/routes/app/library/$libraryId/route.tsx @@ -7,10 +7,8 @@ import { getLibraryVersionQuery } from "../../../../features/library/queries"; import { getSearchDbQuery } from "../../../../features/search/queries"; -import { - DEFAULT_LIBRARY_ID, - LibraryId -} from "@backend/features/library/library-id"; +import { LibraryId } from "@backend/features/library/library-id"; +import { DEFAULT_SETTINGS } from "@backend/features/settings/settings"; import { getUiState } from "../../../../lib/ui-state"; /** Restoring the last group is an entry behavior, so it happens once per load. */ @@ -30,7 +28,7 @@ export const Route = createFileRoute("/app/library/$libraryId")({ if (!isLibraryId(params.libraryId)) { throw redirect({ to: "/app/library/$libraryId", - params: { libraryId: DEFAULT_LIBRARY_ID } + params: { libraryId: DEFAULT_SETTINGS.libraryId } }); } // Client state, so the entry redirect can't restore it. From 3f104ab4f2757e131544379b85a3d87035916989 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 21 Aug 2026 14:22:47 +0000 Subject: [PATCH 18/56] refactor: one error shape for every failed api response Every failure now answers with { kind, message }, where kind says what the client should do with the message rather than leaving it to guess: - handled the message is written for the user; show it - notice the same, for an outcome that is not a failure - internal the message is for the logs; the caller shows its own wording The frontend switches on kind alone, so it handles an error it has never heard of. Callers keep supplying the wording for their own context, which is what internal errors fall back to. That replaces three inconsistent paths: a hand-rolled body two group routes returned, HTTPException messages that reached the user or did not depending on the route, and zValidator's own 400 body - which never reached the error handler at all, since it answers rather than throws. Routes now use a validate() wrapper that throws instead. Onshape failures get a mapping of their own, so what we are willing to repeat is one function rather than a decision per call site: a 401/403 says to try signing in again, a rate limit keeps its retry-after, and anything else stays generic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- docs/REFERENCE.md | 2 +- src/backend/features/auth/guards.ts | 17 ++--- src/backend/features/auth/onshape-oauth.ts | 9 +-- src/backend/features/auth/routes.ts | 9 +-- src/backend/features/auth/session.ts | 16 +++-- src/backend/features/configurations/routes.ts | 13 ++-- src/backend/features/favorites/routes.ts | 8 +-- src/backend/features/library/groups/routes.ts | 31 ++++----- .../features/library/insertables/routes.ts | 46 ++++++------- src/backend/features/settings/routes.ts | 4 +- src/backend/features/thumbnails/routes.ts | 16 ++--- src/backend/lib/api-error.ts | 59 +++++++++++++++++ src/backend/lib/cache.ts | 9 +-- src/backend/lib/errors.test.ts | 64 +++++++++++++++++++ src/backend/lib/errors.ts | 63 ++++++++++++++---- src/backend/lib/route-params.ts | 6 +- src/backend/lib/validate.ts | 24 +++++++ .../favorites/components/favorite-button.tsx | 4 +- .../library/components/add-group-menu.tsx | 4 +- src/frontend/lib/api-client.ts | 9 +-- src/frontend/lib/errors.ts | 57 +++++++++++++---- src/frontend/lib/query-client.ts | 10 ++- 22 files changed, 341 insertions(+), 139 deletions(-) create mode 100644 src/backend/lib/api-error.ts create mode 100644 src/backend/lib/errors.test.ts create mode 100644 src/backend/lib/validate.ts diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index e93f61ea7..eaf250b7f 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -147,7 +147,7 @@ owns, `lib/` for cross-cutting plumbing, and a small set of files at the root. - `index.ts` — Worker entry point; exports the default app and the three Workflow classes - `app.ts` — composition root, and nothing else: binds the caller onto each request, mounts every feature's routes, and installs the error handler - `db/` — `client.ts` (the Drizzle client) and `schema.ts` (table definitions) -- `lib/` — request plumbing shared by every feature: `context.ts` (bindings, typed context, and the caller binding), `cache.ts` (cache-control middleware), `errors.ts`, `route-params.ts`, `query-params.ts` +- `lib/` — request plumbing shared by every feature: `context.ts` (bindings, typed context, and the caller binding), `cache.ts` (cache-control middleware), `api-error.ts` and `errors.ts` (the one shape every failed response takes), `validate.ts`, `route-params.ts`, `query-params.ts` - `lib/onshape/` — everything that talks to Onshape's REST API: `client.ts` (the client class), `api-path.ts`, `path.ts` (`ElementPath`/`InstancePath` and their serializers), `endpoints/` (per-category wrappers), `objects/` (feature and query builders) - `features/` — one directory per feature, each holding its own `routes.ts` plus whatever it owns: - `auth/` — split by role: `session.ts` stores the session cookie and its KV records, `onshape-oauth.ts` runs the handshake, `caller.ts` resolves who is calling (and exports `productionCaller`, the wiring `createApp` binds), `guards.ts` holds both gates, and `routes.ts` serves the OAuth redirects plus `/access-data` diff --git a/src/backend/features/auth/guards.ts b/src/backend/features/auth/guards.ts index e3b75fbc2..bf7c25a92 100644 --- a/src/backend/features/auth/guards.ts +++ b/src/backend/features/auth/guards.ts @@ -1,6 +1,6 @@ /** The two gates routes mount: signed in to Onshape at all, and on the admin team. */ import type { MiddlewareHandler } from "hono"; -import { HTTPException } from "hono/http-exception"; +import { handledError } from "../../lib/api-error"; import { HttpStatus } from "http-status-ts"; import type { AppContext, AppContextEnv } from "../../lib/context"; import { hasEditorAccess } from "./access-level"; @@ -8,10 +8,10 @@ import { isSignedIn } from "./caller"; async function requireSignIn(c: AppContext): Promise<void> { if (!(await isSignedIn(c))) { - throw new HTTPException(HttpStatus.UNAUTHORIZED, { - message: - "You must be signed in to Onshape to use this functionality" - }); + throw handledError( + "You must be signed in to Onshape to use this functionality", + HttpStatus.UNAUTHORIZED + ); } } @@ -34,9 +34,10 @@ export const requireEditorMiddleware: MiddlewareHandler<AppContextEnv> = async ( ) => { await requireSignIn(c); if (!hasEditorAccess(await c.var.getAccessLevel())) { - throw new HTTPException(HttpStatus.FORBIDDEN, { - message: "You must be on the admin team to use this functionality" - }); + throw handledError( + "You must be on the admin team to use this functionality", + HttpStatus.FORBIDDEN + ); } await next(); }; diff --git a/src/backend/features/auth/onshape-oauth.ts b/src/backend/features/auth/onshape-oauth.ts index 9a4bccfdc..891cf2258 100644 --- a/src/backend/features/auth/onshape-oauth.ts +++ b/src/backend/features/auth/onshape-oauth.ts @@ -1,7 +1,7 @@ /** The Onshape OAuth handshake: where we send the user, and what comes back. */ import { HttpStatus } from "http-status-ts"; import { generateState, OAuth2Client, OAuth2Tokens } from "arctic"; -import { HTTPException } from "hono/http-exception"; +import { internalError } from "../../lib/api-error"; import { env } from "cloudflare:workers"; import { type AppContext } from "../../lib/context"; import { @@ -75,9 +75,10 @@ export async function doCallback(c: AppContext): Promise<Response> { } if (!search.code || session.state !== search.state) { - throw new HTTPException(HttpStatus.UNAUTHORIZED, { - message: "Invalid response from Onshape" - }); + throw internalError( + "Invalid response from Onshape", + HttpStatus.UNAUTHORIZED + ); } const oauthClient = getOauthClient(); diff --git a/src/backend/features/auth/routes.ts b/src/backend/features/auth/routes.ts index aab7870e1..f9e1f5bf9 100644 --- a/src/backend/features/auth/routes.ts +++ b/src/backend/features/auth/routes.ts @@ -1,5 +1,5 @@ import { HttpStatus } from "http-status-ts"; -import { HTTPException } from "hono/http-exception"; +import { internalError } from "../../lib/api-error"; import { getApp } from "../../lib/context"; import { cacheMiddleware } from "../../lib/cache"; import { type AccessData } from "./access-level"; @@ -31,9 +31,10 @@ authRoutes.get("/sign-in", async (c) => { } if (!redirectUrl) { - throw new HTTPException(HttpStatus.BAD_REQUEST, { - message: "Failed to find valid redirectUrl" - }); + throw internalError( + "Failed to find valid redirectUrl", + HttpStatus.BAD_REQUEST + ); } // Standalone sign-in omits sessionCompanyId; leave companyId undefined so the diff --git a/src/backend/features/auth/session.ts b/src/backend/features/auth/session.ts index 4c05839ab..b6a2f3e3a 100644 --- a/src/backend/features/auth/session.ts +++ b/src/backend/features/auth/session.ts @@ -1,6 +1,6 @@ /** Session cookie plus the KV records it keys: OAuth tokens and login state. */ import { HttpStatus } from "http-status-ts"; -import { HTTPException } from "hono/http-exception"; +import { internalError } from "../../lib/api-error"; import { getCookie, setCookie } from "hono/cookie"; import { type AppContext } from "../../lib/context"; @@ -11,9 +11,10 @@ export const SESSION_TTL = 30 * 24 * 3600; // 30 days export function getSessionId(c: AppContext): string { const sessionId = getCookie(c, SESSION_COOKIE); if (!sessionId) { - throw new HTTPException(HttpStatus.UNAUTHORIZED, { - message: "Failed to find a valid session" - }); + throw internalError( + "Failed to find a valid session", + HttpStatus.UNAUTHORIZED + ); } return sessionId; } @@ -44,9 +45,10 @@ export async function getTokens( ): Promise<AuthTokens> { const raw = await kv.get(`tokens:${sessionId}`); if (!raw) { - throw new HTTPException(HttpStatus.UNAUTHORIZED, { - message: "Failed to find valid auth tokens to use" - }); + throw internalError( + "Failed to find valid auth tokens to use", + HttpStatus.UNAUTHORIZED + ); } return JSON.parse(raw) as AuthTokens; } diff --git a/src/backend/features/configurations/routes.ts b/src/backend/features/configurations/routes.ts index 799e051d0..5139309cc 100644 --- a/src/backend/features/configurations/routes.ts +++ b/src/backend/features/configurations/routes.ts @@ -1,7 +1,7 @@ import { eq } from "drizzle-orm"; import { CachePolicy, cacheMiddleware } from "../../lib/cache"; import { z } from "zod"; -import { zValidator } from "@hono/zod-validator"; +import { validate } from "../../lib/validate"; import { getApp } from "../../lib/context"; import { getInsertableParam, insertableRoute } from "../../lib/route-params"; import { getDb } from "../../db/client"; @@ -12,7 +12,7 @@ import { toSearchRecords } from "../search/search-index"; import { toRecords } from "./utils"; import { QuantityType, type Unit } from "./enums"; import { INSTANCE_TYPES } from "../../lib/onshape/path"; -import { HTTPException } from "hono/http-exception"; +import { internalError } from "../../lib/api-error"; import { HttpStatus } from "http-status-ts"; export const configurationRoutes = getApp(); @@ -44,9 +44,10 @@ configurationRoutes.get( .get(); if (!config) { - throw new HTTPException(HttpStatus.NOT_FOUND, { - message: "Failed to find configuration" - }); + throw internalError( + "Failed to find configuration", + HttpStatus.NOT_FOUND + ); } const result: ConfigurationResult = { @@ -63,7 +64,7 @@ configurationRoutes.get( configurationRoutes.get( "/unit-info", cacheMiddleware(), - zValidator("query", instancePathQuery), + validate("query", instancePathQuery), async (c) => { const onshapeApi = await c.var.getOnshapeApi(); const instancePath = c.req.valid("query"); diff --git a/src/backend/features/favorites/routes.ts b/src/backend/features/favorites/routes.ts index 0a4bd859e..a03e07256 100644 --- a/src/backend/features/favorites/routes.ts +++ b/src/backend/features/favorites/routes.ts @@ -12,7 +12,7 @@ import { users, favorites } from "../../db/schema"; import type { Favorite, FavoritesData } from "./contract"; import type { LibraryId } from "../library/library-id"; import { z } from "zod"; -import { zValidator } from "@hono/zod-validator"; +import { validate } from "../../lib/validate"; import { requireSignInMiddleware } from "../auth/guards"; export const favoriteRoutes = getApp(); @@ -77,7 +77,7 @@ favoriteRoutes.get( favoriteRoutes.post( "/favorites" + libraryRoute(), requireSignInMiddleware, - zValidator("query", addFavoriteQuery), + validate("query", addFavoriteQuery), async (c) => { const libraryId = getLibraryParam(c); const userId = await c.var.getUserId(); @@ -131,7 +131,7 @@ favoriteRoutes.delete(favoriteRoute(), requireSignInMiddleware, async (c) => { favoriteRoutes.post( "/favorite-order" + libraryRoute(), requireSignInMiddleware, - zValidator("json", favoriteOrderBody), + validate("json", favoriteOrderBody), async (c) => { const { favoriteOrder } = c.req.valid("json"); const userId = await c.var.getUserId(); @@ -158,7 +158,7 @@ favoriteRoutes.post( favoriteRoutes.post( "/default-configuration" + favoriteRoute(), requireSignInMiddleware, - zValidator("json", defaultConfigurationBody), + validate("json", defaultConfigurationBody), async (c) => { const favoriteId = getFavoriteParam(c); const { defaultConfiguration } = c.req.valid("json"); diff --git a/src/backend/features/library/groups/routes.ts b/src/backend/features/library/groups/routes.ts index 72dcbaf31..6c44cb158 100644 --- a/src/backend/features/library/groups/routes.ts +++ b/src/backend/features/library/groups/routes.ts @@ -10,13 +10,14 @@ import { type DocumentPath } from "../../../lib/onshape/path"; import { group, insertables, libraries, favorites } from "../../../db/schema"; import { bumpLibraryVersion, rebuildSearchDb } from "../db"; import { HttpStatus } from "http-status-ts"; +import { handledError } from "../../../lib/api-error"; import { getJobStatus, isReloadRunning, trackJob } from "../../load/job-tracker"; import { z } from "zod"; -import { zValidator } from "@hono/zod-validator"; +import { validate } from "../../../lib/validate"; export const groupRoutes = getApp(); @@ -47,7 +48,7 @@ const deleteGroupQuery = z.object({ groupId: z.string().min(1) }); groupRoutes.post( "/reload-groups" + libraryRoute(), requireEditorMiddleware, - zValidator("query", reloadGroupsQuery), + validate("query", reloadGroupsQuery), async (c) => { const libraryId = getLibraryParam(c); const { forceReload } = c.req.valid("query"); @@ -90,7 +91,7 @@ groupRoutes.get( groupRoutes.post( "/set-insertable-visibility" + libraryRoute(), requireEditorMiddleware, - zValidator("json", setVisibilityBody), + validate("json", setVisibilityBody), async (c) => { const libraryId = getLibraryParam(c); const body = c.req.valid("json"); @@ -130,7 +131,7 @@ groupRoutes.post( groupRoutes.post( "/sort-group-alphabetically" + libraryRoute(), requireEditorMiddleware, - zValidator("json", sortGroupBody), + validate("json", sortGroupBody), async (c) => { const libraryId = getLibraryParam(c); const body = c.req.valid("json"); @@ -152,7 +153,7 @@ groupRoutes.post( groupRoutes.post( "/group-order" + libraryRoute(), requireEditorMiddleware, - zValidator("json", groupOrderBody), + validate("json", groupOrderBody), async (c) => { const libraryId = getLibraryParam(c); const body = c.req.valid("json"); @@ -178,7 +179,7 @@ groupRoutes.post( groupRoutes.post( "/group" + libraryRoute(), requireEditorMiddleware, - zValidator("json", addGroupBody), + validate("json", addGroupBody), async (c) => { const onshapeApi = await c.var.getOnshapeApi(); const libraryId = getLibraryParam(c); @@ -191,12 +192,8 @@ groupRoutes.post( try { documentName = (await getDocument(onshapeApi, documentPath)).name; } catch { - return c.json( - { - type: "handled", - message: "Failed to find the specified document.", - isError: true - }, + throw handledError( + "Failed to find the specified document.", HttpStatus.UNPROCESSABLE_ENTITY ); } @@ -215,12 +212,8 @@ groupRoutes.post( .get(); if (existingGroup) { - return c.json( - { - type: "handled", - message: "Document has already been added to library.", - isError: true - }, + throw handledError( + "Document has already been added to library.", HttpStatus.UNPROCESSABLE_ENTITY ); } @@ -246,7 +239,7 @@ groupRoutes.post( groupRoutes.delete( "/group" + libraryRoute(), requireEditorMiddleware, - zValidator("query", deleteGroupQuery), + validate("query", deleteGroupQuery), async (c) => { const libraryId = getLibraryParam(c); const { groupId } = c.req.valid("query"); diff --git a/src/backend/features/library/insertables/routes.ts b/src/backend/features/library/insertables/routes.ts index 4ebb00778..03ccb1e8d 100644 --- a/src/backend/features/library/insertables/routes.ts +++ b/src/backend/features/library/insertables/routes.ts @@ -1,6 +1,6 @@ import { eq } from "drizzle-orm"; -import { HTTPException } from "hono/http-exception"; -import { zValidator } from "@hono/zod-validator"; +import { internalError } from "../../../lib/api-error"; +import { validate } from "../../../lib/validate"; import { HttpStatus } from "http-status-ts"; import z from "zod"; import { getApp } from "../../../lib/context"; @@ -51,7 +51,7 @@ const indexConfigurationsBody = z.object({ indexConfigurations: z.boolean() }); insertableRoutes.post( "/toggle-insert-and-fasten" + insertableRoute(), requireEditorMiddleware, - zValidator("json", setFastenBody), + validate("json", setFastenBody), async (c) => { const db = getDb(c.env.DB); @@ -64,9 +64,7 @@ insertableRoutes.post( .where(eq(insertables.id, insertableId)) .get(); if (!insertableRow) - throw new HTTPException(HttpStatus.NOT_FOUND, { - message: "Insertable not found" - }); + throw internalError("Insertable not found", HttpStatus.NOT_FOUND); let fastenInfo = null; if (supportsFasten) { @@ -84,9 +82,10 @@ insertableRoutes.post( .get(); if (!insertable) { - throw new HTTPException(HttpStatus.NOT_FOUND, { - message: "Insertable not found" - }); + throw internalError( + "Insertable not found", + HttpStatus.NOT_FOUND + ); } fastenInfo = await parseFastenInfo( @@ -110,7 +109,7 @@ insertableRoutes.post( insertableRoutes.post( "/index-configurations" + insertableRoute(), requireEditorMiddleware, - zValidator("json", indexConfigurationsBody), + validate("json", indexConfigurationsBody), async (c) => { const db = getDb(c.env.DB); const insertableId = getInsertableParam(c); @@ -131,9 +130,7 @@ insertableRoutes.post( .where(eq(insertables.id, insertableId)) .get(); if (!row) - throw new HTTPException(HttpStatus.NOT_FOUND, { - message: "Insertable not found" - }); + throw internalError("Insertable not found", HttpStatus.NOT_FOUND); const parameters = ( @@ -274,7 +271,7 @@ const addToAssemblyBody = insertBodySchema.extend({ insertableRoutes.post( "/add-to-part-studio" + insertableRoute(), requireSignInMiddleware, - zValidator("json", addToPartStudioBody), + validate("json", addToPartStudioBody), async (c) => { const onshapeApi = await c.var.getOnshapeApi(); const insertableId = getInsertableParam(c); @@ -294,9 +291,7 @@ insertableRoutes.post( .get(); if (!insertable) { - throw new HTTPException(HttpStatus.NOT_FOUND, { - message: "Insertable not found" - }); + throw internalError("Insertable not found", HttpStatus.NOT_FOUND); } // Look up parsed configuration parameters from D1 if configuration is provided @@ -332,7 +327,7 @@ insertableRoutes.post( insertableRoutes.post( "/add-to-assembly" + insertableRoute(), requireSignInMiddleware, - zValidator("json", addToAssemblyBody), + validate("json", addToAssemblyBody), async (c) => { const onshapeApi = await c.var.getOnshapeApi(); const insertableId = getInsertableParam(c); @@ -357,9 +352,7 @@ insertableRoutes.post( .get(); if (!row) { - throw new HTTPException(HttpStatus.NOT_FOUND, { - message: "Insertable not found" - }); + throw internalError("Insertable not found", HttpStatus.NOT_FOUND); } const sourcePath: ElementPath = { @@ -409,9 +402,10 @@ insertableRoutes.post( const fastenInfo = row.fastenInfo; if (!fastenInfo) { - throw new HTTPException(HttpStatus.BAD_REQUEST, { - message: `${row.name} does not support insert and fasten.` - }); + throw internalError( + `${row.name} does not support insert and fasten.`, + HttpStatus.BAD_REQUEST + ); } const instancePath: string[] = @@ -447,9 +441,7 @@ export async function getInsertableElementPath( .get(); if (!row) { - throw new HTTPException(HttpStatus.NOT_FOUND, { - message: "Insertable not found" - }); + throw internalError("Insertable not found", HttpStatus.NOT_FOUND); } return { diff --git a/src/backend/features/settings/routes.ts b/src/backend/features/settings/routes.ts index a63ef334f..c003eab4f 100644 --- a/src/backend/features/settings/routes.ts +++ b/src/backend/features/settings/routes.ts @@ -3,7 +3,7 @@ import { getApp } from "../../lib/context"; import { getDb } from "../../db/client"; import { users } from "../../db/schema"; import { z } from "zod"; -import { zValidator } from "@hono/zod-validator"; +import { validate } from "../../lib/validate"; import { requireSignInMiddleware } from "../auth/guards"; import { LibraryId } from "../library/library-id"; import { Theme } from "./settings"; @@ -19,7 +19,7 @@ const settingsBody = z.object({ settingsRoutes.post( "/settings", requireSignInMiddleware, - zValidator("json", settingsBody), + validate("json", settingsBody), async (c) => { const userId = await c.var.getUserId(); const body = c.req.valid("json"); diff --git a/src/backend/features/thumbnails/routes.ts b/src/backend/features/thumbnails/routes.ts index 817a89ef1..e8e9cd3dd 100644 --- a/src/backend/features/thumbnails/routes.ts +++ b/src/backend/features/thumbnails/routes.ts @@ -1,6 +1,6 @@ import { eq } from "drizzle-orm"; import { z } from "zod"; -import { zValidator } from "@hono/zod-validator"; +import { validate } from "../../lib/validate"; import { CachePolicy, cacheMiddleware, setCacheTtl } from "../../lib/cache"; import { getApp } from "../../lib/context"; import { @@ -16,7 +16,7 @@ import { bumpLibraryVersion } from "../library/db"; import { type InstancePath } from "../../lib/onshape/path"; import { group, insertables } from "../../db/schema"; -import { HTTPException } from "hono/http-exception"; +import { internalError } from "../../lib/api-error"; import { HttpStatus } from "http-status-ts"; import { ThumbnailSize } from "./types"; import { @@ -64,8 +64,8 @@ const storedThumbnailQuery = z.object({ thumbnailRoutes.get( "/thumbnail/:size/:elementId", cacheMiddleware(CachePolicy.PUBLIC_CACHE), - zValidator("param", storedThumbnailParams), - zValidator("query", storedThumbnailQuery), + validate("param", storedThumbnailParams), + validate("query", storedThumbnailQuery), async (c) => { const { size, elementId } = c.req.valid("param"); const { @@ -157,9 +157,7 @@ thumbnailRoutes.post( .get(); if (!row) { - throw new HTTPException(HttpStatus.NOT_FOUND, { - message: "Insertable not found" - }); + throw internalError("Insertable not found", HttpStatus.NOT_FOUND); } const thumbnails = await uploadThumbnails( @@ -207,9 +205,7 @@ thumbnailRoutes.post( .get(); if (!row) { - throw new HTTPException(HttpStatus.NOT_FOUND, { - message: "Group not found" - }); + throw internalError("Group not found", HttpStatus.NOT_FOUND); } const instancePath: InstancePath = { diff --git a/src/backend/lib/api-error.ts b/src/backend/lib/api-error.ts new file mode 100644 index 000000000..4c1c758d2 --- /dev/null +++ b/src/backend/lib/api-error.ts @@ -0,0 +1,59 @@ +/** + * The one shape every failed /api response takes. `kind` tells the client what + * to do with `message`, so it can handle an error it has never heard of. + * + * A leaf module: the frontend imports the kind and the body to switch on them. + */ + +export enum ApiErrorKind { + /** `message` is written for the user; show it as the failure. */ + HANDLED = "handled", + /** `message` is written for the user, but nothing went wrong. */ + NOTICE = "notice", + /** `message` is for us. The client shows its own wording instead. */ + INTERNAL = "internal" +} + +export interface ApiErrorBody { + kind: ApiErrorKind; + message: string; + /** Only on an Onshape rate limit: how long it asked us to wait. */ + retryAfterSeconds?: number; +} + +/** Thrown by a route; the app's error handler turns it into the response. */ +export class ApiError extends Error { + constructor( + readonly kind: ApiErrorKind, + message: string, + readonly status: number, + readonly retryAfterSeconds?: number + ) { + super(message); + this.name = "ApiError"; + Object.setPrototypeOf(this, new.target.prototype); + } + + get body(): ApiErrorBody { + return { + kind: this.kind, + message: this.message, + retryAfterSeconds: this.retryAfterSeconds + }; + } +} + +/** The wording reaches the user, so write it for them. */ +export function handledError(message: string, status: number): ApiError { + return new ApiError(ApiErrorKind.HANDLED, message, status); +} + +/** Same, for an outcome that is worth saying but is not a failure. */ +export function noticeError(message: string, status: number): ApiError { + return new ApiError(ApiErrorKind.NOTICE, message, status); +} + +/** The client will show its own wording; this text is only for the logs. */ +export function internalError(message: string, status: number): ApiError { + return new ApiError(ApiErrorKind.INTERNAL, message, status); +} diff --git a/src/backend/lib/cache.ts b/src/backend/lib/cache.ts index 51911fef5..bc7c025e9 100644 --- a/src/backend/lib/cache.ts +++ b/src/backend/lib/cache.ts @@ -1,5 +1,5 @@ import { type MiddlewareHandler } from "hono"; -import { HTTPException } from "hono/http-exception"; +import { internalError } from "./api-error"; import { HttpStatus } from "http-status-ts"; import { type AppContext, type AppContextEnv } from "./context"; @@ -45,9 +45,10 @@ export function cacheMiddleware( // An immutable response has to be pinned by something, or the next // version of it is unreachable behind the cache. if (!c.req.query("v")) { - throw new HTTPException(HttpStatus.BAD_REQUEST, { - message: "Missing cache version" - }); + throw internalError( + "Missing cache version", + HttpStatus.BAD_REQUEST + ); } await next(); // A miss must stay retryable, so only store what succeeded. diff --git a/src/backend/lib/errors.test.ts b/src/backend/lib/errors.test.ts new file mode 100644 index 000000000..7565f0b01 --- /dev/null +++ b/src/backend/lib/errors.test.ts @@ -0,0 +1,64 @@ +import { env } from "cloudflare:workers"; +import { beforeEach, describe, expect, it } from "vitest"; +import { AccessLevel } from "../features/auth/access-level"; +import { LibraryId } from "../features/library/library-id"; +import { ApiErrorKind } from "./api-error"; +import { createTestApp, jsonRequest, resetDb } from "../../__test_utils__"; +import { getDb } from "../db/client"; + +const db = getDb(env.DB); + +describe("api error responses", () => { + beforeEach(async () => { + await resetDb(db); + }); + + // Written for the user, so the client shows it verbatim. + it("marks a gate's refusal as handled", async () => { + const app = createTestApp({ signedIn: false }); + + const res = await app.request( + `/api/reload-groups/library/${LibraryId.FRC_DESIGN_LIB}`, + jsonRequest("POST"), + env + ); + + expect(res.status).toBe(401); + expect(await res.json()).toMatchObject({ + kind: ApiErrorKind.HANDLED, + message: expect.stringContaining("signed in") + }); + }); + + // A malformed request is our bug, so the client falls back to its own + // wording rather than showing a validator's message. + it("marks a rejected request as internal", async () => { + const app = createTestApp({ accessLevel: AccessLevel.ADMIN }); + + const res = await app.request( + `/api/group-order/library/${LibraryId.FRC_DESIGN_LIB}`, + jsonRequest("POST", { groupOrder: "not-an-array" }), + env + ); + + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + kind: ApiErrorKind.INTERNAL + }); + }); + + it("marks an unknown library as internal rather than explaining it", async () => { + const app = createTestApp(); + + const res = await app.request( + "/api/library-data/library/not-a-library?v=1", + jsonRequest("GET"), + env + ); + + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + kind: ApiErrorKind.INTERNAL + }); + }); +}); diff --git a/src/backend/lib/errors.ts b/src/backend/lib/errors.ts index 94d57f719..82b537822 100644 --- a/src/backend/lib/errors.ts +++ b/src/backend/lib/errors.ts @@ -1,27 +1,62 @@ import type { ErrorHandler } from "hono"; import { HTTPException } from "hono/http-exception"; import { HttpStatus } from "http-status-ts"; -import { OnshapeRateLimitError } from "./onshape/client"; +import { OnshapeApiError, OnshapeRateLimitError } from "./onshape/client"; +import { ApiError, ApiErrorKind, handledError } from "./api-error"; import type { AppContextEnv } from "./context"; -export const errorHandler: ErrorHandler<AppContextEnv> = (err, c) => { - // Surface an Onshape rate limit as a 429 the client can retry, rather - // than blocking the request thread waiting it out. - if (err instanceof OnshapeRateLimitError) { - return c.json( - { - error: "Onshape rate limit reached. Please try again shortly.", - retryAfterSeconds: err.retryAfterSeconds - }, - HttpStatus.TOO_MANY_REQUESTS +/** + * What we are willing to say about an Onshape failure. Anything not named here + * is ours to explain, not Onshape's, so it stays generic. + */ +function fromOnshapeError(error: OnshapeApiError): ApiError { + if (error instanceof OnshapeRateLimitError) { + return new ApiError( + ApiErrorKind.HANDLED, + "Onshape rate limit reached. Please try again shortly.", + HttpStatus.TOO_MANY_REQUESTS, + error.retryAfterSeconds + ); + } + if ( + error.status === HttpStatus.UNAUTHORIZED || + error.status === HttpStatus.FORBIDDEN + ) { + return handledError( + "Onshape refused the request. Try signing in again.", + error.status ); } + return new ApiError( + ApiErrorKind.INTERNAL, + `Onshape request failed: ${error.message}`, + HttpStatus.BAD_GATEWAY + ); +} + +export const errorHandler: ErrorHandler<AppContextEnv> = (err, c) => { + if (err instanceof ApiError) { + return c.json(err.body, err.status as never); + } + if (err instanceof OnshapeApiError) { + const apiError = fromOnshapeError(err); + if (apiError.kind === ApiErrorKind.INTERNAL) { + console.error(err); + } + return c.json(apiError.body, apiError.status as never); + } + // A raw HTTPException is a validator rejecting a malformed request, which + // is our bug rather than something the user can act on. if (err instanceof HTTPException) { - return err.getResponse(); + console.error(err); + return c.json( + { kind: ApiErrorKind.INTERNAL, message: err.message }, + err.status as never + ); } console.error(err); return c.json( - { error: "Internal Server Error" }, - HttpStatus.INTERNAL_SERVER_ERROR + { kind: ApiErrorKind.INTERNAL, message: "Internal Server Error" }, + HttpStatus.INTERNAL_SERVER_ERROR as never ); }; diff --git a/src/backend/lib/route-params.ts b/src/backend/lib/route-params.ts index da10c28e1..3e2ab1ed5 100644 --- a/src/backend/lib/route-params.ts +++ b/src/backend/lib/route-params.ts @@ -1,5 +1,5 @@ /** Route patterns and their param readers, so mounts and lookups stay in sync. */ -import { HTTPException } from "hono/http-exception"; +import { internalError } from "./api-error"; import { HttpStatus } from "http-status-ts"; import z from "zod"; import { LibraryId } from "../features/library/library-id"; @@ -13,9 +13,7 @@ export function getLibraryParam(c: AppContext): LibraryId { const libraryId = c.req.param("libraryId"); const parsed = z.enum(LibraryId).safeParse(libraryId); if (!parsed.success) { - throw new HTTPException(HttpStatus.BAD_REQUEST, { - message: "Invalid libraryId" - }); + throw internalError("Invalid libraryId", HttpStatus.BAD_REQUEST); } return parsed.data; } diff --git a/src/backend/lib/validate.ts b/src/backend/lib/validate.ts new file mode 100644 index 000000000..fc20f3c54 --- /dev/null +++ b/src/backend/lib/validate.ts @@ -0,0 +1,24 @@ +import { zValidator } from "@hono/zod-validator"; +import type { ValidationTargets } from "hono"; +import { HttpStatus } from "http-status-ts"; +import type { ZodType } from "zod"; +import { internalError } from "./api-error"; + +/** + * `zValidator` with our error shape. On its own it answers with a body of its + * own design, which is the one response that would not look like every other + * failure. A malformed request is our bug, so the detail is for the logs. + */ +export function validate< + T extends ZodType, + Target extends keyof ValidationTargets +>(target: Target, schema: T) { + return zValidator(target, schema, (result) => { + if (!result.success) { + throw internalError( + `Invalid ${target}: ${result.error.message}`, + HttpStatus.BAD_REQUEST + ); + } + }); +} diff --git a/src/frontend/features/favorites/components/favorite-button.tsx b/src/frontend/features/favorites/components/favorite-button.tsx index 9ba1cc675..169cc886e 100644 --- a/src/frontend/features/favorites/components/favorite-button.tsx +++ b/src/frontend/features/favorites/components/favorite-button.tsx @@ -12,7 +12,7 @@ import type { InsertableOut } from "@backend/features/library/contract"; import { LibraryId } from "@backend/features/library/library-id"; import { queryClient } from "../../../lib/query-client"; import { useRouter } from "@tanstack/react-router"; -import { handleAppError, HandledError } from "../../../lib/errors"; +import { appError, handleAppError } from "../../../lib/errors"; import { getQueryUpdater } from "../../../lib/utils"; import { toFavoritePath, @@ -64,7 +64,7 @@ function useUpdateFavoritesMutation() { mutationFn: async (args) => { if (args.operation === Operation.ADD) { if (!args.insertable.isVisible) { - throw new HandledError( + throw appError( `Cannot favorite hidden element ${args.insertable.name}.` ); } diff --git a/src/frontend/features/library/components/add-group-menu.tsx b/src/frontend/features/library/components/add-group-menu.tsx index bea008a4d..d16d87ba0 100644 --- a/src/frontend/features/library/components/add-group-menu.tsx +++ b/src/frontend/features/library/components/add-group-menu.tsx @@ -6,7 +6,7 @@ import { ReactNode, useState } from "react"; import { useMutation } from "@tanstack/react-query"; import { apiPost } from "../../../lib/api-client"; import { parseUrl } from "../../../lib/url"; -import { getAppErrorHandler, HandledError } from "../../../lib/errors"; +import { appError, getAppErrorHandler } from "../../../lib/errors"; import { showInfoToast, showLoadingToast } from "../../../lib/notifications"; import { queryClient } from "../../../lib/query-client"; import { toLibraryPath, useLibraryId } from "../library-path"; @@ -35,7 +35,7 @@ function AddGroupMenuContent(props: AddGroupMenuContentProps): ReactNode { mutationFn: async () => { const newDocumentId = parseUrl(url)?.documentId; if (!newDocumentId) { - throw new HandledError("Failed to parse url."); + throw appError("Failed to parse url."); } showLoadingToast("Adding document...", "add-group"); modals.closeAll(); diff --git a/src/frontend/lib/api-client.ts b/src/frontend/lib/api-client.ts index 7e618cb05..9a793094e 100644 --- a/src/frontend/lib/api-client.ts +++ b/src/frontend/lib/api-client.ts @@ -4,7 +4,7 @@ import { type QueryOptions, type PostOptions } from "./utils"; -import { HandledError } from "./errors"; +import { fromApiErrorBody } from "./errors"; import { THUMBNAIL_FALLBACK_HEADER } from "@backend/features/thumbnails/keys"; import { HttpStatus } from "http-status-ts"; @@ -119,12 +119,9 @@ export async function apiDelete( } async function handleResponse(response: Response) { - const json = await response.json(); + const json = await response.json().catch(() => undefined); if (!response.ok) { - if (json.type === "handled") { - throw new HandledError(json.message, json.isError); - } - throw new Error("Network response failed."); + throw fromApiErrorBody(json); } return json; } diff --git a/src/frontend/lib/errors.ts b/src/frontend/lib/errors.ts index e4c3fc60e..361e49c24 100644 --- a/src/frontend/lib/errors.ts +++ b/src/frontend/lib/errors.ts @@ -1,16 +1,38 @@ +import { ApiErrorKind, type ApiErrorBody } from "@backend/lib/api-error"; import { showErrorToast, showInfoToast } from "./notifications"; /** - * Errors which are generated and thrown on the client. - * Unlike other errors, the message is displayed directly to the user. + * A failure worth telling the user about, from the backend or raised here. + * `kind` decides how it is shown, so a caller only supplies the wording for + * the case it cannot know about. */ -export class HandledError extends Error { - public isError: boolean; - - constructor(message: string, isError = true) { +export class AppError extends Error { + constructor( + readonly kind: ApiErrorKind, + message: string, + readonly retryAfterSeconds?: number + ) { super(message); + this.name = "AppError"; Object.setPrototypeOf(this, new.target.prototype); - this.isError = isError; + } +} + +/** Raised on the client, with wording already written for the user. */ +export function appError(message: string): AppError { + return new AppError(ApiErrorKind.HANDLED, message); +} + +/** Builds an {@link AppError} from a failed /api response body. */ +export function fromApiErrorBody(body: unknown): AppError { + const { kind, message, retryAfterSeconds } = (body ?? + {}) as Partial<ApiErrorBody>; + switch (kind) { + case ApiErrorKind.HANDLED: + case ApiErrorKind.NOTICE: + return new AppError(kind, message ?? "", retryAfterSeconds); + default: + return new AppError(ApiErrorKind.INTERNAL, message ?? ""); } } @@ -18,18 +40,27 @@ export function getAppErrorHandler(defaultMessage: string, toastId?: string) { return (error: Error) => handleAppError(error, defaultMessage, toastId); } +/** + * Shows an error. Only an error carrying wording meant for the user shows its + * own message; anything else — including every rejected request — gets + * `defaultMessage`, which the caller writes for its own context. + */ export function handleAppError( error: Error, defaultMessage: string, toastKey?: string ) { - if (error instanceof HandledError) { - if (!error.isError) { - showInfoToast(error.message, toastKey); - } else { - showErrorToast(error.message, toastKey); + if (error instanceof AppError) { + switch (error.kind) { + case ApiErrorKind.HANDLED: + showErrorToast(error.message, toastKey); + return; + case ApiErrorKind.NOTICE: + showInfoToast(error.message, toastKey); + return; + case ApiErrorKind.INTERNAL: + break; } - return; } showErrorToast(defaultMessage, toastKey); } diff --git a/src/frontend/lib/query-client.ts b/src/frontend/lib/query-client.ts index 80f1faff0..872c24c22 100644 --- a/src/frontend/lib/query-client.ts +++ b/src/frontend/lib/query-client.ts @@ -1,5 +1,6 @@ import { QueryClient } from "@tanstack/react-query"; -import { HandledError } from "./errors"; +import { AppError } from "./errors"; +import { ApiErrorKind } from "@backend/lib/api-error"; export const queryClient = new QueryClient({ defaultOptions: { @@ -8,7 +9,12 @@ export const queryClient = new QueryClient({ // Only retry once if (count >= 2) { return false; - } else if (error instanceof HandledError) { + } + // Retrying will not change an answer the backend meant. + if ( + error instanceof AppError && + error.kind !== ApiErrorKind.INTERNAL + ) { return false; } return true; From 63b0b79d01b2e57c6f24e72dbf310cb6fb61d0a5 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 21 Aug 2026 16:05:10 +0000 Subject: [PATCH 19/56] refactor: address review - errors, theming, toasts, icon colors An unknown library id now 404s instead of quietly redirecting to FRCDesignLib, which hid the bad url. That surfaced a latent crash: the root renders around the not-found, so it saw an unvalidated libraryId and handed MantineProvider an undefined primaryColor. isLibraryId moves next to the other library path helpers and both callers use it. Mantine has no relative-time helper (only @mantine/charts and @mantine/dates components, neither of which we depend on), but the platform does. formatRelativeTime is now Intl.RelativeTimeFormat, which produces the same strings, localized, and adds "yesterday". ApiErrorBody is a discriminated union keyed on kind, following the same BuildIssueOf pattern already used for build issues, so a kind carries exactly its own fields. retryAfterSeconds now belongs to a rate-limited error rather than sitting optional on every error. The notice kind is gone: nothing ever produced one, and it was not distinguishable from handled. showToast updates a live toast rather than hiding and re-showing it, so a loading toast becoming a success one stays in place. Standalone, getColorTheme defaulted the system scheme to light, ignoring the OS. It now takes the scheme explicitly and the root supplies Mantine's useColorScheme when Onshape has not put one on the url. IconColor and HeartIconColor are gone - the latter was a duplicate of IconColor.RED. Phosphor icons render currentColor, so passing them through Box lets Mantine colors work directly. PrimaryColor and HEADER_CONTROL_COLOR stay: one is the dynamic per-library primary, the other is deliberately a hex. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- src/backend/lib/api-error.ts | 60 +++++++++---------- src/backend/lib/errors.ts | 16 +++-- src/frontend/components/app-zero-state.tsx | 6 +- .../build-status/components/build-status.tsx | 31 ++++++---- .../favorites/components/favorite-button.tsx | 8 +-- .../favorites/components/favorites-list.tsx | 5 +- .../library/components/card-components.tsx | 9 +-- src/frontend/features/library/library-path.ts | 4 ++ .../search/components/search-errors.tsx | 12 ++-- src/frontend/lib/errors.ts | 50 +++++++++------- src/frontend/lib/format-time.ts | 35 ++++++++--- src/frontend/lib/notifications.tsx | 26 +++++--- src/frontend/lib/onshape-params.ts | 12 ++-- src/frontend/lib/query-client.ts | 2 +- src/frontend/lib/style-constants.ts | 14 ----- src/frontend/routes/__root.tsx | 14 ++++- .../library/$libraryId/groups/$groupId.tsx | 3 +- .../routes/app/library/$libraryId/route.tsx | 17 ++---- 18 files changed, 181 insertions(+), 143 deletions(-) diff --git a/src/backend/lib/api-error.ts b/src/backend/lib/api-error.ts index 4c1c758d2..9bd740195 100644 --- a/src/backend/lib/api-error.ts +++ b/src/backend/lib/api-error.ts @@ -1,59 +1,59 @@ /** * The one shape every failed /api response takes. `kind` tells the client what - * to do with `message`, so it can handle an error it has never heard of. + * to do, and each kind carries exactly the data that kind needs — a new kind + * brings its own fields rather than adding an optional one to every error. * - * A leaf module: the frontend imports the kind and the body to switch on them. + * A leaf module: the frontend imports these to switch on them. */ export enum ApiErrorKind { - /** `message` is written for the user; show it as the failure. */ + /** `message` is written for the user; show it. */ HANDLED = "handled", - /** `message` is written for the user, but nothing went wrong. */ - NOTICE = "notice", - /** `message` is for us. The client shows its own wording instead. */ + /** Onshape is rate limiting us, and said how long to wait. */ + RATE_LIMITED = "rate-limited", + /** `message` is for the logs. The client shows its own wording instead. */ INTERNAL = "internal" } -export interface ApiErrorBody { - kind: ApiErrorKind; +interface ApiErrorOf<K extends ApiErrorKind> { + kind: K; message: string; - /** Only on an Onshape rate limit: how long it asked us to wait. */ - retryAfterSeconds?: number; } +export type ApiErrorBody = + | ApiErrorOf<ApiErrorKind.HANDLED> + | ApiErrorOf<ApiErrorKind.INTERNAL> + | (ApiErrorOf<ApiErrorKind.RATE_LIMITED> & { retryAfterSeconds: number }); + /** Thrown by a route; the app's error handler turns it into the response. */ export class ApiError extends Error { constructor( - readonly kind: ApiErrorKind, - message: string, - readonly status: number, - readonly retryAfterSeconds?: number + readonly body: ApiErrorBody, + readonly status: number ) { - super(message); + super(body.message); this.name = "ApiError"; Object.setPrototypeOf(this, new.target.prototype); } - - get body(): ApiErrorBody { - return { - kind: this.kind, - message: this.message, - retryAfterSeconds: this.retryAfterSeconds - }; - } } /** The wording reaches the user, so write it for them. */ export function handledError(message: string, status: number): ApiError { - return new ApiError(ApiErrorKind.HANDLED, message, status); -} - -/** Same, for an outcome that is worth saying but is not a failure. */ -export function noticeError(message: string, status: number): ApiError { - return new ApiError(ApiErrorKind.NOTICE, message, status); + return new ApiError({ kind: ApiErrorKind.HANDLED, message }, status); } /** The client will show its own wording; this text is only for the logs. */ export function internalError(message: string, status: number): ApiError { - return new ApiError(ApiErrorKind.INTERNAL, message, status); + return new ApiError({ kind: ApiErrorKind.INTERNAL, message }, status); +} + +export function rateLimitedError( + message: string, + status: number, + retryAfterSeconds: number +): ApiError { + return new ApiError( + { kind: ApiErrorKind.RATE_LIMITED, message, retryAfterSeconds }, + status + ); } diff --git a/src/backend/lib/errors.ts b/src/backend/lib/errors.ts index 82b537822..cb1f6c2c6 100644 --- a/src/backend/lib/errors.ts +++ b/src/backend/lib/errors.ts @@ -2,7 +2,13 @@ import type { ErrorHandler } from "hono"; import { HTTPException } from "hono/http-exception"; import { HttpStatus } from "http-status-ts"; import { OnshapeApiError, OnshapeRateLimitError } from "./onshape/client"; -import { ApiError, ApiErrorKind, handledError } from "./api-error"; +import { + ApiError, + ApiErrorKind, + handledError, + internalError, + rateLimitedError +} from "./api-error"; import type { AppContextEnv } from "./context"; /** @@ -11,8 +17,7 @@ import type { AppContextEnv } from "./context"; */ function fromOnshapeError(error: OnshapeApiError): ApiError { if (error instanceof OnshapeRateLimitError) { - return new ApiError( - ApiErrorKind.HANDLED, + return rateLimitedError( "Onshape rate limit reached. Please try again shortly.", HttpStatus.TOO_MANY_REQUESTS, error.retryAfterSeconds @@ -27,8 +32,7 @@ function fromOnshapeError(error: OnshapeApiError): ApiError { error.status ); } - return new ApiError( - ApiErrorKind.INTERNAL, + return internalError( `Onshape request failed: ${error.message}`, HttpStatus.BAD_GATEWAY ); @@ -40,7 +44,7 @@ export const errorHandler: ErrorHandler<AppContextEnv> = (err, c) => { } if (err instanceof OnshapeApiError) { const apiError = fromOnshapeError(err); - if (apiError.kind === ApiErrorKind.INTERNAL) { + if (apiError.body.kind === ApiErrorKind.INTERNAL) { console.error(err); } return c.json(apiError.body, apiError.status as never); diff --git a/src/frontend/components/app-zero-state.tsx b/src/frontend/components/app-zero-state.tsx index 6582b0d04..c71a51b70 100644 --- a/src/frontend/components/app-zero-state.tsx +++ b/src/frontend/components/app-zero-state.tsx @@ -1,9 +1,9 @@ -import { Center, Loader, EmptyState } from "@mantine/core"; +import { Box, Center, EmptyState, Loader } from "@mantine/core"; import { X } from "@phosphor-icons/react"; -import { HeartIconColor, IconSize } from "../lib/style-constants"; +import { IconSize } from "../lib/style-constants"; import { type JSX, ReactNode } from "react"; -const DEFAULT_ERROR_ICON = <X size={IconSize.HUGE} color={HeartIconColor} />; +const DEFAULT_ERROR_ICON = <Box component={X} size={IconSize.HUGE} c="red" />; interface ZeroStateProps { icon?: ReactNode; diff --git a/src/frontend/features/build-status/components/build-status.tsx b/src/frontend/features/build-status/components/build-status.tsx index a7df46a83..9ce4c72bf 100644 --- a/src/frontend/features/build-status/components/build-status.tsx +++ b/src/frontend/features/build-status/components/build-status.tsx @@ -1,5 +1,6 @@ import { Badge, + Box, Divider, Group, HoverCard, @@ -54,7 +55,7 @@ import { isIndexedParameter, MAX_PART_NUMBER_CONFIGURATIONS } from "@backend/features/configurations/combinations"; -import { FontWeight, IconColor, IconSize } from "../../../lib/style-constants"; +import { FontWeight, IconSize } from "../../../lib/style-constants"; import { RequireAccessLevel } from "../../auth/access-level"; import { useBuildStatusQuery } from "../queries"; import { useJobStatusQuery } from "../../library/queries"; @@ -104,7 +105,9 @@ function useGroupBuildIssues( }, [groupStatus, insertableStatuses]); } -interface IssueIconProps extends ComponentPropsWithRef<"svg"> { +interface IssueIconProps + // Rendered through Box, which owns these two as style props. + extends Omit<ComponentPropsWithRef<"svg">, "color" | "display"> { /** The severity to render, or null if all checks pass. */ severity: BuildIssueSeverity | null; /** @default IconSize.SMALL */ @@ -120,37 +123,41 @@ export function IssueIcon({ switch (severity) { case BuildIssueSeverity.ERROR: return ( - <WarningOctagon + <Box + component={WarningOctagon} ref={ref} size={IconSize.SMALL} - color={IconColor.RED} + c="red" {...others} /> ); case BuildIssueSeverity.WARNING: return ( - <Warning + <Box + component={Warning} ref={ref} size={IconSize.SMALL} - color={IconColor.YELLOW} + c="yellow" {...others} /> ); case BuildIssueSeverity.INFO: return ( - <Info + <Box + component={Info} ref={ref} size={IconSize.SMALL} - color={IconColor.BLUE} + c="blue" {...others} /> ); case null: return ( - <Check + <Box + component={Check} ref={ref} size={IconSize.SMALL} - color={IconColor.GREEN} + c="green" {...others} /> ); @@ -959,9 +966,9 @@ function ParsedRow({ function StateValue({ value }: { value: StateRowValue }): ReactNode { if (value.kind === "bool") { return value.value ? ( - <Check size={IconSize.SMALL} color={IconColor.GREEN} /> + <Box component={Check} size={IconSize.SMALL} c="green" /> ) : ( - <X size={IconSize.SMALL} color={IconColor.RED} /> + <Box component={X} size={IconSize.SMALL} c="red" /> ); } diff --git a/src/frontend/features/favorites/components/favorite-button.tsx b/src/frontend/features/favorites/components/favorite-button.tsx index 169cc886e..dab9310e2 100644 --- a/src/frontend/features/favorites/components/favorite-button.tsx +++ b/src/frontend/features/favorites/components/favorite-button.tsx @@ -1,6 +1,6 @@ -import { ActionIcon, Menu } from "@mantine/core"; +import { ActionIcon, Box, Menu } from "@mantine/core"; import { Heart, HeartBreak } from "@phosphor-icons/react"; -import { HeartIconColor, IconSize } from "../../../lib/style-constants"; +import { IconSize } from "../../../lib/style-constants"; import { useMutation } from "@tanstack/react-query"; import { ReactNode, useState } from "react"; import { apiDelete, apiPost } from "../../../lib/api-client"; @@ -198,7 +198,7 @@ interface HeartIconProps { export function HeartIcon(props: HeartIconProps): ReactNode { const { full = true, size = IconSize.SMALL } = props; return full ? ( - <Heart size={size} color={HeartIconColor} weight="fill" /> + <Box component={Heart} size={size} c="red" weight="fill" /> ) : ( <Heart size={size} /> ); @@ -213,5 +213,5 @@ interface HeartBrokenIconProps { export function HeartBrokenIcon(props: HeartBrokenIconProps): ReactNode { const { size = IconSize.SMALL } = props; - return <HeartBreak size={size} color={HeartIconColor} />; + return <Box component={HeartBreak} size={size} c="red" />; } diff --git a/src/frontend/features/favorites/components/favorites-list.tsx b/src/frontend/features/favorites/components/favorites-list.tsx index c36fb4136..c1276a637 100644 --- a/src/frontend/features/favorites/components/favorites-list.tsx +++ b/src/frontend/features/favorites/components/favorites-list.tsx @@ -1,6 +1,7 @@ +import { Box } from "@mantine/core"; import { useAccessData } from "../../auth/access-level"; import { HeartBreak } from "@phosphor-icons/react"; -import { HeartIconColor, IconSize } from "../../../lib/style-constants"; +import { IconSize } from "../../../lib/style-constants"; import { ReactNode } from "react"; import { filterInsertables } from "../../search/filter"; import { getFavoriteForInsertable } from "@backend/features/favorites/contract"; @@ -41,7 +42,7 @@ export function FavoritesList(): ReactNode { <SectionError title="Failed to load favorites." icon={ - <HeartBreak size={IconSize.LARGE} color={HeartIconColor} /> + <Box component={HeartBreak} size={IconSize.LARGE} c="red" /> } /> ); diff --git a/src/frontend/features/library/components/card-components.tsx b/src/frontend/features/library/components/card-components.tsx index 89003e78d..13636822c 100644 --- a/src/frontend/features/library/components/card-components.tsx +++ b/src/frontend/features/library/components/card-components.tsx @@ -1,4 +1,4 @@ -import { Group, Menu, Stack, Table, Text } from "@mantine/core"; +import { Box, Group, Menu, Stack, Table, Text } from "@mantine/core"; import { ArrowSquareOut, ArrowsClockwise, @@ -7,7 +7,7 @@ import { Link, Plus } from "@phosphor-icons/react"; -import { IconColor, IconSize } from "../../../lib/style-constants"; +import { IconSize } from "../../../lib/style-constants"; import { copyUrlToClipboard, makeUrl, openUrlInNewTab } from "../../../lib/url"; import { Fragment, PropsWithChildren, ReactNode, useCallback } from "react"; import { AppContextMenu, MenuButton } from "../../../components/app-menu"; @@ -207,9 +207,10 @@ export function CardTitle(props: CardTitleProps) { {/* After the badge: toggling visibility would otherwise shift the badge, dragging its open hover card out from under the cursor. */} {isHidden && ( - <EyeSlash + <Box + component={EyeSlash} size={IconSize.SMALL} - color={IconColor.YELLOW} + c="yellow" alt="Hidden" /> )} diff --git a/src/frontend/features/library/library-path.ts b/src/frontend/features/library/library-path.ts index fe1213b46..cefec55ec 100644 --- a/src/frontend/features/library/library-path.ts +++ b/src/frontend/features/library/library-path.ts @@ -13,6 +13,10 @@ export function useLibraryId(): LibraryId { return params?.libraryId ?? DEFAULT_SETTINGS.libraryId; } +export function isLibraryId(libraryId: string): libraryId is LibraryId { + return (Object.values(LibraryId) as string[]).includes(libraryId); +} + export function toLibraryPath(libraryId: LibraryId): string { return `/library/${libraryId}`; } diff --git a/src/frontend/features/search/components/search-errors.tsx b/src/frontend/features/search/components/search-errors.tsx index 7fd31ff7a..04541ab92 100644 --- a/src/frontend/features/search/components/search-errors.tsx +++ b/src/frontend/features/search/components/search-errors.tsx @@ -1,10 +1,6 @@ -import { Alert, Button, Group } from "@mantine/core"; +import { Alert, Box, Button, Group } from "@mantine/core"; import { HeartBreak, MagnifyingGlass } from "@phosphor-icons/react"; -import { - HeartIconColor, - IconColor, - IconSize -} from "../../../lib/style-constants"; +import { IconSize } from "../../../lib/style-constants"; import { ReactNode } from "react"; import { ClearFiltersButton } from "../../settings/components/vendor-filters"; import { FilterResult, ObjectLabel, plural } from "../search"; @@ -76,9 +72,9 @@ export function NoSearchResultError( const icon = objectLabel === "search result" ? ( - <MagnifyingGlass size={IconSize.LARGE} color={IconColor.YELLOW} /> + <Box component={MagnifyingGlass} size={IconSize.LARGE} c="yellow" /> ) : ( - <HeartBreak size={IconSize.LARGE} color={HeartIconColor} /> + <Box component={HeartBreak} size={IconSize.LARGE} c="red" /> ); if (filtered.byGroup > 0) { diff --git a/src/frontend/lib/errors.ts b/src/frontend/lib/errors.ts index 361e49c24..20f8c531f 100644 --- a/src/frontend/lib/errors.ts +++ b/src/frontend/lib/errors.ts @@ -1,18 +1,14 @@ import { ApiErrorKind, type ApiErrorBody } from "@backend/lib/api-error"; -import { showErrorToast, showInfoToast } from "./notifications"; +import { showErrorToast } from "./notifications"; /** * A failure worth telling the user about, from the backend or raised here. - * `kind` decides how it is shown, so a caller only supplies the wording for - * the case it cannot know about. + * `body` is the same discriminated shape the backend sends, so a caller reads + * whatever that kind carries without optional fields on every other kind. */ export class AppError extends Error { - constructor( - readonly kind: ApiErrorKind, - message: string, - readonly retryAfterSeconds?: number - ) { - super(message); + constructor(readonly body: ApiErrorBody) { + super(body.message); this.name = "AppError"; Object.setPrototypeOf(this, new.target.prototype); } @@ -20,19 +16,29 @@ export class AppError extends Error { /** Raised on the client, with wording already written for the user. */ export function appError(message: string): AppError { - return new AppError(ApiErrorKind.HANDLED, message); + return new AppError({ kind: ApiErrorKind.HANDLED, message }); } /** Builds an {@link AppError} from a failed /api response body. */ export function fromApiErrorBody(body: unknown): AppError { - const { kind, message, retryAfterSeconds } = (body ?? - {}) as Partial<ApiErrorBody>; - switch (kind) { + const parsed = body as Partial<ApiErrorBody> | undefined; + switch (parsed?.kind) { case ApiErrorKind.HANDLED: - case ApiErrorKind.NOTICE: - return new AppError(kind, message ?? "", retryAfterSeconds); + return new AppError({ + kind: ApiErrorKind.HANDLED, + message: parsed.message ?? "" + }); + case ApiErrorKind.RATE_LIMITED: + return new AppError({ + kind: ApiErrorKind.RATE_LIMITED, + message: parsed.message ?? "", + retryAfterSeconds: parsed.retryAfterSeconds ?? 0 + }); default: - return new AppError(ApiErrorKind.INTERNAL, message ?? ""); + return new AppError({ + kind: ApiErrorKind.INTERNAL, + message: parsed?.message ?? "" + }); } } @@ -42,8 +48,8 @@ export function getAppErrorHandler(defaultMessage: string, toastId?: string) { /** * Shows an error. Only an error carrying wording meant for the user shows its - * own message; anything else — including every rejected request — gets - * `defaultMessage`, which the caller writes for its own context. + * own message; anything else gets `defaultMessage`, which the caller writes for + * its own context. */ export function handleAppError( error: Error, @@ -51,12 +57,10 @@ export function handleAppError( toastKey?: string ) { if (error instanceof AppError) { - switch (error.kind) { + switch (error.body.kind) { case ApiErrorKind.HANDLED: - showErrorToast(error.message, toastKey); - return; - case ApiErrorKind.NOTICE: - showInfoToast(error.message, toastKey); + case ApiErrorKind.RATE_LIMITED: + showErrorToast(error.body.message, toastKey); return; case ApiErrorKind.INTERNAL: break; diff --git a/src/frontend/lib/format-time.ts b/src/frontend/lib/format-time.ts index 14b5eb95e..a3307adc4 100644 --- a/src/frontend/lib/format-time.ts +++ b/src/frontend/lib/format-time.ts @@ -1,17 +1,34 @@ /** Shared helpers for rendering timestamps and durations in the UI. */ +const RELATIVE = new Intl.RelativeTimeFormat(undefined, { + numeric: "auto", + style: "narrow" +}); + +/** Largest unit first; the first one the elapsed time reaches is the one used. */ +const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [ + ["day", 24 * 60 * 60], + ["hour", 60 * 60], + ["minute", 60] +]; + +/** Anything older reads better as a date than as a count of days. */ +const MAX_RELATIVE_DAYS = 7; + /** - * A short, human relative time like "just now", "5m ago", "3h ago", "2d ago", + * A short, localized relative time like "just now", "5 min ago", "yesterday", * falling back to a locale date for anything older than a week. */ export function formatRelativeTime(timestamp: number): string { const seconds = Math.floor((Date.now() - timestamp) / 1000); - if (seconds < 60) return "just now"; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days < 7) return `${days}d ago`; - return new Date(timestamp).toLocaleDateString(); + if (seconds >= MAX_RELATIVE_DAYS * 24 * 60 * 60) { + return new Date(timestamp).toLocaleDateString(); + } + for (const [unit, unitSeconds] of UNITS) { + const elapsed = Math.floor(seconds / unitSeconds); + if (elapsed >= 1) { + return RELATIVE.format(-elapsed, unit); + } + } + return "just now"; } diff --git a/src/frontend/lib/notifications.tsx b/src/frontend/lib/notifications.tsx index de7df9386..f3a81ac7f 100644 --- a/src/frontend/lib/notifications.tsx +++ b/src/frontend/lib/notifications.tsx @@ -46,14 +46,12 @@ interface ToastConfig { withCloseButton?: boolean; } -/** Shows a toast, replacing any existing toast with the same id. */ +/** Ids currently on screen, so a repeat updates rather than replaces. */ +const liveToasts = new Set<string>(); + +/** Shows a toast, updating any existing toast with the same id. */ function showToast(config: ToastConfig): string { - // Mantine's `show` no-ops when a toast with this id already exists, so hide - // it first to replace it (e.g. a loading toast upgraded to success). - if (config.id) { - notifications.hide(config.id); - } - return notifications.show({ + const props = { id: config.id, color: config.color, icon: config.icon, @@ -61,7 +59,21 @@ function showToast(config: ToastConfig): string { loading: config.loading, autoClose: config.autoClose, withCloseButton: config.withCloseButton + }; + + // Updating keeps the toast in place, so a loading toast becoming a success + // one reads as the same toast rather than one leaving and another arriving. + if (config.id && liveToasts.has(config.id)) { + notifications.update(props); + return config.id; + } + + const id = notifications.show({ + ...props, + onClose: () => liveToasts.delete(id) }); + liveToasts.add(id); + return id; } export function showInfoToast(message: string, id?: string): string { diff --git a/src/frontend/lib/onshape-params.ts b/src/frontend/lib/onshape-params.ts index 7f7d9db33..39e71a8ac 100644 --- a/src/frontend/lib/onshape-params.ts +++ b/src/frontend/lib/onshape-params.ts @@ -24,14 +24,16 @@ export interface OnshapeParams extends ElementPath { */ export type ColorTheme = "light" | "dark"; +/** + * Resolves the theme to an actual color scheme. `systemTheme` is Onshape's, + * forwarded by the entry redirect; standalone there is none, so the caller + * passes the OS preference instead. + */ export function getColorTheme( theme: Theme, - systemTheme: ColorTheme = "light" + systemTheme: ColorTheme ): ColorTheme { - if (theme === Theme.SYSTEM) { - return systemTheme; - } - return theme; + return theme === Theme.SYSTEM ? systemTheme : theme; } /** diff --git a/src/frontend/lib/query-client.ts b/src/frontend/lib/query-client.ts index 872c24c22..2f55dac31 100644 --- a/src/frontend/lib/query-client.ts +++ b/src/frontend/lib/query-client.ts @@ -13,7 +13,7 @@ export const queryClient = new QueryClient({ // Retrying will not change an answer the backend meant. if ( error instanceof AppError && - error.kind !== ApiErrorKind.INTERNAL + error.body.kind !== ApiErrorKind.INTERNAL ) { return false; } diff --git a/src/frontend/lib/style-constants.ts b/src/frontend/lib/style-constants.ts index bc39d2c08..860780a33 100644 --- a/src/frontend/lib/style-constants.ts +++ b/src/frontend/lib/style-constants.ts @@ -43,17 +43,3 @@ export enum PrimaryColor { * and hover tints, and anything else silently resolves to black. */ export const HEADER_CONTROL_COLOR = "#fff"; - -/** Red used for heart/favorite icons. */ -export const HeartIconColor = "var(--mantine-color-red-6)"; - -/** - * Icon color intents for use with Phosphor icons. - * For native mantine components, just use yellow, red, blue, etc. directly. - */ -export enum IconColor { - YELLOW = "var(--mantine-color-yellow-6)", - BLUE = "var(--mantine-color-blue-6)", - RED = HeartIconColor, - GREEN = "var(--mantine-color-green-6)" -} diff --git a/src/frontend/routes/__root.tsx b/src/frontend/routes/__root.tsx index 10d05727d..3630b2e7d 100644 --- a/src/frontend/routes/__root.tsx +++ b/src/frontend/routes/__root.tsx @@ -9,11 +9,13 @@ import { MantineProvider } from "@mantine/core"; import { ModalsProvider } from "@mantine/modals"; import { Notifications } from "@mantine/notifications"; import { ReactNode, useMemo } from "react"; +import { useColorScheme } from "@mantine/hooks"; import { queryClient } from "../lib/query-client"; import { createAppTheme } from "../theme"; import { getColorTheme } from "../lib/onshape-params"; import { DEFAULT_SETTINGS } from "@backend/features/settings/settings"; import { NotFoundError, RootCrash } from "../components/root-error"; +import { isLibraryId } from "../features/library/library-path"; export const Route = createRootRoute({ component: RootComponent, @@ -30,11 +32,19 @@ function RootComponent(): ReactNode { // rewrites them — so the first paint is already the right colors. const params = useParams({ strict: false }); - const libraryId = params.libraryId ?? DEFAULT_SETTINGS.libraryId; + // The root renders around a not-found too, so the url may name a library + // that does not exist; its theme still has to resolve to something. + const libraryId = + params.libraryId && isLibraryId(params.libraryId) + ? params.libraryId + : DEFAULT_SETTINGS.libraryId; const theme = useMemo(() => createAppTheme(libraryId), [libraryId]); + // Onshape puts its own scheme on the url when it launches us; standalone + // there is none, and the OS is what "system" means. + const osColorScheme = useColorScheme(); const colorTheme = getColorTheme( search.theme ?? DEFAULT_SETTINGS.theme, - search.systemTheme + search.systemTheme ?? osColorScheme ); return ( diff --git a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx index e508694be..5dc92d8dd 100644 --- a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx +++ b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx @@ -10,7 +10,6 @@ import { ArrowLeft, ArrowUUpLeft, Warning } from "@phosphor-icons/react"; import { BORDER, FontWeight, - IconColor, IconSize } from "../../../../../lib/style-constants"; import { ReactNode } from "react"; @@ -179,7 +178,7 @@ export function GroupListContent(props: GroupListCardsProps): ReactNode { return ( <SectionError icon={ - <Warning size={IconSize.LARGE} color={IconColor.YELLOW} /> + <Box component={Warning} size={IconSize.LARGE} c="yellow" /> } title="All elements are hidden by filters" action={<ClearFiltersButton />} diff --git a/src/frontend/routes/app/library/$libraryId/route.tsx b/src/frontend/routes/app/library/$libraryId/route.tsx index e5773aeb3..563192143 100644 --- a/src/frontend/routes/app/library/$libraryId/route.tsx +++ b/src/frontend/routes/app/library/$libraryId/route.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, redirect } from "@tanstack/react-router"; +import { createFileRoute, notFound, redirect } from "@tanstack/react-router"; import { queryClient } from "../../../../lib/query-client"; import { getAccessDataQuery } from "../../../../features/auth/access-level"; import { getFavoritesQuery } from "../../../../features/favorites/queries"; @@ -8,28 +8,23 @@ import { } from "../../../../features/library/queries"; import { getSearchDbQuery } from "../../../../features/search/queries"; import { LibraryId } from "@backend/features/library/library-id"; -import { DEFAULT_SETTINGS } from "@backend/features/settings/settings"; import { getUiState } from "../../../../lib/ui-state"; +import { isLibraryId } from "../../../../features/library/library-path"; /** Restoring the last group is an entry behavior, so it happens once per load. */ let restoredGroup = false; -function isLibraryId(libraryId: string): libraryId is LibraryId { - return (Object.values(LibraryId) as string[]).includes(libraryId); -} - export const Route = createFileRoute("/app/library/$libraryId")({ params: { - // Narrowed by beforeLoad, which sends an unknown library elsewhere. + // Narrowed by beforeLoad, which 404s an unknown library. parse: ({ libraryId }) => ({ libraryId: libraryId as LibraryId }), stringify: ({ libraryId }) => ({ libraryId }) }, beforeLoad: ({ params }) => { + // Quietly showing a different library would hide the bad url and leave + // the caller wondering why they are somewhere else. if (!isLibraryId(params.libraryId)) { - throw redirect({ - to: "/app/library/$libraryId", - params: { libraryId: DEFAULT_SETTINGS.libraryId } - }); + throw notFound(); } // Client state, so the entry redirect can't restore it. const { openGroupId } = getUiState(); From 5e0197741ff56697e462939e22f8b931b6bde536 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 21 Aug 2026 19:01:44 +0000 Subject: [PATCH 20/56] refactor: name the placement-specific icon sizes for their placement IconSize mixed magnitudes with one context name (CONTROL). Usage says the split is real rather than accidental: TINY, SMALL and MEDIUM are general sizes for an icon in a line of content (55 of 64 uses, across buttons, menus, toasts and rows), while the three largest each have exactly one placement. So the three general ones keep magnitude names and the rest say where they go: CONTROL stays, LARGE becomes SECTION and HUGE becomes PAGE. The doc comments now describe where each is actually used rather than where it was first meant to be. No size keeps its name with a different value, so nothing silently re-points. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- src/frontend/components/app-zero-state.tsx | 2 +- .../favorites/components/favorites-list.tsx | 6 +++++- .../search/components/search-errors.tsx | 8 ++++++-- src/frontend/lib/style-constants.ts | 20 ++++++++++--------- .../library/$libraryId/groups/$groupId.tsx | 6 +++++- 5 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/frontend/components/app-zero-state.tsx b/src/frontend/components/app-zero-state.tsx index c71a51b70..10a7c4419 100644 --- a/src/frontend/components/app-zero-state.tsx +++ b/src/frontend/components/app-zero-state.tsx @@ -3,7 +3,7 @@ import { X } from "@phosphor-icons/react"; import { IconSize } from "../lib/style-constants"; import { type JSX, ReactNode } from "react"; -const DEFAULT_ERROR_ICON = <Box component={X} size={IconSize.HUGE} c="red" />; +const DEFAULT_ERROR_ICON = <Box component={X} size={IconSize.PAGE} c="red" />; interface ZeroStateProps { icon?: ReactNode; diff --git a/src/frontend/features/favorites/components/favorites-list.tsx b/src/frontend/features/favorites/components/favorites-list.tsx index c1276a637..94d50b423 100644 --- a/src/frontend/features/favorites/components/favorites-list.tsx +++ b/src/frontend/features/favorites/components/favorites-list.tsx @@ -42,7 +42,11 @@ export function FavoritesList(): ReactNode { <SectionError title="Failed to load favorites." icon={ - <Box component={HeartBreak} size={IconSize.LARGE} c="red" /> + <Box + component={HeartBreak} + size={IconSize.SECTION} + c="red" + /> } /> ); diff --git a/src/frontend/features/search/components/search-errors.tsx b/src/frontend/features/search/components/search-errors.tsx index 04541ab92..de522e350 100644 --- a/src/frontend/features/search/components/search-errors.tsx +++ b/src/frontend/features/search/components/search-errors.tsx @@ -72,9 +72,13 @@ export function NoSearchResultError( const icon = objectLabel === "search result" ? ( - <Box component={MagnifyingGlass} size={IconSize.LARGE} c="yellow" /> + <Box + component={MagnifyingGlass} + size={IconSize.SECTION} + c="yellow" + /> ) : ( - <Box component={HeartBreak} size={IconSize.LARGE} c="red" /> + <Box component={HeartBreak} size={IconSize.SECTION} c="red" /> ); if (filtered.byGroup > 0) { diff --git a/src/frontend/lib/style-constants.ts b/src/frontend/lib/style-constants.ts index 860780a33..f327cdb9d 100644 --- a/src/frontend/lib/style-constants.ts +++ b/src/frontend/lib/style-constants.ts @@ -1,19 +1,21 @@ /** - * Standard icon sizes to pass to Phosphor icons. + * Standard icon sizes to pass to Phosphor icons. The first three are general + * magnitudes for an icon sitting in a line of content; the rest each name the + * one place they are used. */ export enum IconSize { - /** Icons on badges */ + /** Beside xs text: badge labels and metadata rows. */ TINY = 12, - /** Menu options */ + /** The default, beside a label in a button or menu option. */ SMALL = 16, - /** Buttons */ + /** Standalone in a row, and the icon of a toast. */ MEDIUM = 18, - /** Input-height controls, which sit next to full-height buttons */ + /** Icon-only controls, at input height next to full-height buttons. */ CONTROL = 24, - /** In-line error states */ - LARGE = 36, - /** Full-page error states */ - HUGE = 48 + /** Section-level empty and error states. */ + SECTION = 36, + /** Full-page error states. */ + PAGE = 48 } /** diff --git a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx index 5dc92d8dd..29fe564b5 100644 --- a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx +++ b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx @@ -178,7 +178,11 @@ export function GroupListContent(props: GroupListCardsProps): ReactNode { return ( <SectionError icon={ - <Box component={Warning} size={IconSize.LARGE} c="yellow" /> + <Box + component={Warning} + size={IconSize.SECTION} + c="yellow" + /> } title="All elements are hidden by filters" action={<ClearFiltersButton />} From abe0183bf871bac8ade8e9e55ec9ba316977347c Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 21 Aug 2026 19:11:03 +0000 Subject: [PATCH 21/56] refactor: dissolve lib/utils, and let the menu files fast-refresh utils.ts held five unrelated things. Each moves to where it is used: getQueryUpdater and patchQuery become lib/query-cache.ts, useIsHome joins the other library route helpers, and capitalize and handleBooleanChange move into their single callers. The re-export of query-params went to api-client alone, which now imports it directly, so nothing is left and the file is gone. The Fast Refresh warnings were real, and the cause is the opposite of what it looks like: each menu file exports only its opener function while defining components privately. React Refresh can only swap a module whose exports are all components, so an edit invalidated the module and propagated to every importer - editing a menu remounted its callers instead of preserving their state. The openers move to sibling modules and the component files export their components, which silences the warning. Verified by driving HMR in a browser: the invalidate messages are gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- src/frontend/components/app-navbar.tsx | 2 +- .../favorites/components/favorite-button.tsx | 2 +- .../favorites/components/favorite-card.tsx | 4 +- .../favorites/components/favorite-menu.tsx | 35 ++------- .../features/favorites/open-favorite-menu.tsx | 31 ++++++++ .../insert/components/configurations.tsx | 16 +++-- .../insert/components/insert-menu.tsx | 69 +----------------- .../features/insert/open-insert-menu.tsx | 72 +++++++++++++++++++ src/frontend/features/library/card-hooks.ts | 2 +- .../library/components/group-card.tsx | 4 +- .../library/components/insertable-card.tsx | 2 +- src/frontend/features/library/library-path.ts | 12 +++- .../settings/components/settings-menu.tsx | 17 ++--- .../features/settings/open-settings-menu.tsx | 14 ++++ src/frontend/lib/api-client.ts | 2 +- src/frontend/lib/query-cache.ts | 28 ++++++++ src/frontend/lib/utils.ts | 63 ---------------- 17 files changed, 191 insertions(+), 184 deletions(-) create mode 100644 src/frontend/features/favorites/open-favorite-menu.tsx create mode 100644 src/frontend/features/insert/open-insert-menu.tsx create mode 100644 src/frontend/features/settings/open-settings-menu.tsx create mode 100644 src/frontend/lib/query-cache.ts delete mode 100644 src/frontend/lib/utils.ts diff --git a/src/frontend/components/app-navbar.tsx b/src/frontend/components/app-navbar.tsx index 2333d53ab..d1f6dfee3 100644 --- a/src/frontend/components/app-navbar.tsx +++ b/src/frontend/components/app-navbar.tsx @@ -14,7 +14,7 @@ import { ReactNode, RefObject, useRef } from "react"; import { useNavigate } from "@tanstack/react-router"; import frcDesignBook from "/frc-design-book.svg"; -import { openSettingsMenu } from "../features/settings/components/settings-menu"; +import { openSettingsMenu } from "../features/settings/open-settings-menu"; import { VendorMenu } from "../features/settings/components/vendor-filters"; import { useUiState } from "../lib/ui-state"; import { getLibraryName, useLibraryId } from "../features/library/library-path"; diff --git a/src/frontend/features/favorites/components/favorite-button.tsx b/src/frontend/features/favorites/components/favorite-button.tsx index dab9310e2..e952c450e 100644 --- a/src/frontend/features/favorites/components/favorite-button.tsx +++ b/src/frontend/features/favorites/components/favorite-button.tsx @@ -13,7 +13,7 @@ import { LibraryId } from "@backend/features/library/library-id"; import { queryClient } from "../../../lib/query-client"; import { useRouter } from "@tanstack/react-router"; import { appError, handleAppError } from "../../../lib/errors"; -import { getQueryUpdater } from "../../../lib/utils"; +import { getQueryUpdater } from "../../../lib/query-cache"; import { toFavoritePath, toLibraryPath, diff --git a/src/frontend/features/favorites/components/favorite-card.tsx b/src/frontend/features/favorites/components/favorite-card.tsx index 61f5fa9b3..f2cb36294 100644 --- a/src/frontend/features/favorites/components/favorite-card.tsx +++ b/src/frontend/features/favorites/components/favorite-card.tsx @@ -9,8 +9,8 @@ import { Menu } from "@mantine/core"; import { Pencil } from "@phosphor-icons/react"; import { IconSize } from "../../../lib/style-constants"; import { useRouter } from "@tanstack/react-router"; -import { openInsertMenu } from "../../insert/components/insert-menu"; -import { openFavoriteMenu } from "./favorite-menu"; +import { openInsertMenu } from "../../insert/open-insert-menu"; +import { openFavoriteMenu } from "../open-favorite-menu"; import { FavoriteButton, FavoriteInsertableItem } from "./favorite-button"; import { CardTitle, diff --git a/src/frontend/features/favorites/components/favorite-menu.tsx b/src/frontend/features/favorites/components/favorite-menu.tsx index 5fffc475c..62bd94a28 100644 --- a/src/frontend/features/favorites/components/favorite-menu.tsx +++ b/src/frontend/features/favorites/components/favorite-menu.tsx @@ -1,5 +1,5 @@ -import { Button, Group, Stack, Text } from "@mantine/core"; import { modals } from "@mantine/modals"; +import { Button, Group, Stack, Text } from "@mantine/core"; import { FloppyDisk } from "@phosphor-icons/react"; import { FontWeight, IconSize } from "../../../lib/style-constants"; import { ReactNode, useEffect, useState } from "react"; @@ -20,38 +20,13 @@ import { encodeCanonicalConfiguration } from "@backend/features/configurations/c import { useFavoritesQuery } from "../queries"; import { useLibraryQuery } from "../../library/queries"; import { favoritesQueryKey } from "../../../lib/query-keys"; -import { getQueryUpdater } from "../../../lib/utils"; +import { getQueryUpdater } from "../../../lib/query-cache"; import { toFavoritePath, useLibraryId } from "../../library/library-path"; import { useRefreshFavorites } from "../../../lib/refresh"; import { PageError } from "../../../components/app-zero-state"; -interface OpenFavoriteMenuProps { - favoriteId: string; - insertableName: string; - defaultConfiguration?: ParameterValues; -} - -export function openFavoriteMenu(props: OpenFavoriteMenuProps) { - const { favoriteId, insertableName, defaultConfiguration } = props; - // Minted here so the content can update the header as the selection changes. - const modalId = crypto.randomUUID(); - modals.open({ - modalId, - title: <FavoriteMenuTitle name={insertableName} />, - size: 500, - centered: true, - children: ( - <FavoriteMenuContent - favoriteId={favoriteId} - modalId={modalId} - defaultConfiguration={defaultConfiguration} - /> - ) - }); -} - /** The element's name, and what the saved configuration produces beneath it. */ -function FavoriteMenuTitle({ +export function FavoriteMenuTitle({ name, record }: { @@ -85,7 +60,9 @@ interface FavoriteMenuContentProps { defaultConfiguration?: ParameterValues; } -function FavoriteMenuContent(props: FavoriteMenuContentProps): ReactNode { +export function FavoriteMenuContent( + props: FavoriteMenuContentProps +): ReactNode { const { favoriteId, modalId, defaultConfiguration } = props; const router = useRouter(); diff --git a/src/frontend/features/favorites/open-favorite-menu.tsx b/src/frontend/features/favorites/open-favorite-menu.tsx new file mode 100644 index 000000000..2c7eae4bb --- /dev/null +++ b/src/frontend/features/favorites/open-favorite-menu.tsx @@ -0,0 +1,31 @@ +import { modals } from "@mantine/modals"; +import { type ParameterValues } from "@backend/features/configurations/models"; +import { + FavoriteMenuContent, + FavoriteMenuTitle +} from "./components/favorite-menu"; + +interface OpenFavoriteMenuProps { + favoriteId: string; + insertableName: string; + defaultConfiguration?: ParameterValues; +} + +export function openFavoriteMenu(props: OpenFavoriteMenuProps) { + const { favoriteId, insertableName, defaultConfiguration } = props; + // Minted here so the content can update the header as the selection changes. + const modalId = crypto.randomUUID(); + modals.open({ + modalId, + title: <FavoriteMenuTitle name={insertableName} />, + size: 500, + centered: true, + children: ( + <FavoriteMenuContent + favoriteId={favoriteId} + modalId={modalId} + defaultConfiguration={defaultConfiguration} + /> + ) + }); +} diff --git a/src/frontend/features/insert/components/configurations.tsx b/src/frontend/features/insert/components/configurations.tsx index d005387e5..f26576c50 100644 --- a/src/frontend/features/insert/components/configurations.tsx +++ b/src/frontend/features/insert/components/configurations.tsx @@ -10,12 +10,13 @@ import { import { useQuery } from "@tanstack/react-query"; import { useSearch } from "@tanstack/react-router"; import { - Dispatch, - useEffect, + type Dispatch, ReactNode, + type SyntheticEvent, + useCallback, + useEffect, useRef, - useState, - useCallback + useState } from "react"; import { apiGet } from "../../../lib/api-client"; import { @@ -40,7 +41,6 @@ import { getVisibleOptions } from "@backend/features/configurations/utils"; import { canonicalizeConfiguration } from "@backend/features/configurations/canonical"; -import { handleBooleanChange } from "../../../lib/utils"; import { formatValueWithUnits, valueWithUnits, @@ -69,6 +69,12 @@ interface ConfigurationWrapperProps { onRecord?: (record: SearchRecord | undefined) => void; } +/** Event handler that exposes the target element's value as a boolean. */ +function handleBooleanChange(handler: Dispatch<boolean>) { + return (event: SyntheticEvent<HTMLElement>) => + handler((event.target as HTMLInputElement).checked); +} + export function ConfigurationWrapper(props: ConfigurationWrapperProps) { const { insertableId, diff --git a/src/frontend/features/insert/components/insert-menu.tsx b/src/frontend/features/insert/components/insert-menu.tsx index 470518693..8cd39590f 100644 --- a/src/frontend/features/insert/components/insert-menu.tsx +++ b/src/frontend/features/insert/components/insert-menu.tsx @@ -11,10 +11,7 @@ import { useIsFetching } from "@tanstack/react-query"; import { insertableConfigurationQueryMatchKey } from "../../../lib/query-keys"; import { PreviewImageCard } from "../../thumbnails/components/thumbnail"; import { FavoriteButton } from "../../favorites/components/favorite-button"; -import { - NotificationAction, - renderNotification -} from "../../../lib/notifications"; +import { renderNotification } from "../../../lib/notifications"; import { MenuButton } from "../../../components/app-menu"; import { InsertableMenuItems } from "../../library/components/insertable-card"; import { ConfigurationWrapper } from "./configurations"; @@ -31,46 +28,7 @@ import { RequireSignIn, useIsSignedIn } from "../../auth/access-level"; import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; import { startSignIn } from "../../auth/sign-in"; -interface OpenInsertMenuProps { - insertable: InsertableOut; - defaultConfiguration?: ParameterValues; -} - -export function openInsertMenu(props: OpenInsertMenuProps) { - const { insertable, defaultConfiguration } = props; - let didInsert = false; - // Minted here so the content can address the modal it lives in, which is - // what lets the header follow the selected configuration. - const id = crypto.randomUUID(); - modals.open({ - modalId: id, - title: <InsertMenuTitle name={insertable.name} />, - size: 500, - centered: true, - onClose: () => { - if (!didInsert) { - showRestoreToast(insertable, defaultConfiguration); - } - }, - children: ( - <InsertMenuContent - insertable={insertable} - modalId={id} - defaultConfiguration={defaultConfiguration} - onInsert={() => { - didInsert = true; - modals.close(id); - }} - /> - ) - }); -} - -/** - * Both are shown: the element name is how the part was found, the part number - * and name are what gets inserted. - */ -function InsertMenuTitle({ +export function InsertMenuTitle({ name, record }: { @@ -102,7 +60,7 @@ interface InsertMenuContentProps { onInsert: () => void; } -function InsertMenuContent(props: InsertMenuContentProps): ReactNode { +export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { const { insertable, modalId, onInsert } = props; const favorites = useFavoritesQuery().data?.favorites; const isSignedIn = useIsSignedIn(); @@ -267,24 +225,3 @@ function showSignInPreviewToast() { ) }); } - -function showRestoreToast( - insertable: InsertableOut, - configuration?: ParameterValues -) { - const restoreButton: NotificationAction = { - text: "Restore", - onClick: () => - openInsertMenu({ insertable, defaultConfiguration: configuration }) - }; - - notifications.show({ - message: renderNotification( - `Cancelled ${insertable.name}.`, - restoreButton - ), - color: "blue", - icon: <Info size={IconSize.MEDIUM} />, - autoClose: 3000 - }); -} diff --git a/src/frontend/features/insert/open-insert-menu.tsx b/src/frontend/features/insert/open-insert-menu.tsx new file mode 100644 index 000000000..85bb24b43 --- /dev/null +++ b/src/frontend/features/insert/open-insert-menu.tsx @@ -0,0 +1,72 @@ +import { modals } from "@mantine/modals"; +import { notifications } from "@mantine/notifications"; +import { Info } from "@phosphor-icons/react"; +import type { InsertableOut } from "@backend/features/library/contract"; +import { type ParameterValues } from "@backend/features/configurations/models"; +import { IconSize } from "../../lib/style-constants"; +import { + type NotificationAction, + renderNotification +} from "../../lib/notifications"; +import { InsertMenuContent, InsertMenuTitle } from "./components/insert-menu"; + +interface OpenInsertMenuProps { + insertable: InsertableOut; + defaultConfiguration?: ParameterValues; +} + +export function openInsertMenu(props: OpenInsertMenuProps) { + const { insertable, defaultConfiguration } = props; + let didInsert = false; + // Minted here so the content can address the modal it lives in, which is + // what lets the header follow the selected configuration. + const id = crypto.randomUUID(); + modals.open({ + modalId: id, + title: <InsertMenuTitle name={insertable.name} />, + size: 500, + centered: true, + onClose: () => { + if (!didInsert) { + showRestoreToast(insertable, defaultConfiguration); + } + }, + children: ( + <InsertMenuContent + insertable={insertable} + modalId={id} + defaultConfiguration={defaultConfiguration} + onInsert={() => { + didInsert = true; + modals.close(id); + }} + /> + ) + }); +} + +/** + * Both are shown: the element name is how the part was found, the part number + * and name are what gets inserted. + */ + +function showRestoreToast( + insertable: InsertableOut, + configuration?: ParameterValues +) { + const restoreButton: NotificationAction = { + text: "Restore", + onClick: () => + openInsertMenu({ insertable, defaultConfiguration: configuration }) + }; + + notifications.show({ + message: renderNotification( + `Cancelled ${insertable.name}.`, + restoreButton + ), + color: "blue", + icon: <Info size={IconSize.MEDIUM} />, + autoClose: 3000 + }); +} diff --git a/src/frontend/features/library/card-hooks.ts b/src/frontend/features/library/card-hooks.ts index eb136f009..6d63bf494 100644 --- a/src/frontend/features/library/card-hooks.ts +++ b/src/frontend/features/library/card-hooks.ts @@ -21,7 +21,7 @@ import { getAppErrorHandler } from "../../lib/errors"; import { useCacheVersion } from "./queries"; import { buildStatusQueryKey } from "../../lib/query-keys"; import { useRefreshLibrary } from "../../lib/refresh"; -import { patchQuery } from "../../lib/utils"; +import { patchQuery } from "../../lib/query-cache"; import { useCloseBuildCard } from "../build-status/components/build-status"; /** The build-status query key for the currently-viewed library. */ diff --git a/src/frontend/features/library/components/group-card.tsx b/src/frontend/features/library/components/group-card.tsx index 78dba30fc..226ab66df 100644 --- a/src/frontend/features/library/components/group-card.tsx +++ b/src/frontend/features/library/components/group-card.tsx @@ -23,8 +23,8 @@ import { useRefreshLibrary } from "../../../lib/refresh"; import { useBuildStatusQuery } from "../../build-status/queries"; import { useCacheVersion, useLibraryQuery } from "../queries"; import { libraryQueryKey } from "../../../lib/query-keys"; -import { toLibraryPath, useLibraryId } from "../library-path"; -import { getQueryUpdater, useIsHome } from "../../../lib/utils"; +import { toLibraryPath, useIsHome, useLibraryId } from "../library-path"; +import { getQueryUpdater } from "../../../lib/query-cache"; interface GroupCardProps extends PropsWithChildren { group: GroupOut; diff --git a/src/frontend/features/library/components/insertable-card.tsx b/src/frontend/features/library/components/insertable-card.tsx index 6344f040b..d1effa4b4 100644 --- a/src/frontend/features/library/components/insertable-card.tsx +++ b/src/frontend/features/library/components/insertable-card.tsx @@ -24,7 +24,7 @@ import { } from "./card-components"; import { openCannotDeriveAssemblyAlert } from "../../../components/alerts"; import { useIsAssemblyInPartStudio } from "../../insert/insert-hooks"; -import { openInsertMenu } from "../../insert/components/insert-menu"; +import { openInsertMenu } from "../../insert/open-insert-menu"; import { useFavoritesQuery } from "../../favorites/queries"; import { RequireSignIn } from "../../auth/access-level"; import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; diff --git a/src/frontend/features/library/library-path.ts b/src/frontend/features/library/library-path.ts index cefec55ec..8aaac607e 100644 --- a/src/frontend/features/library/library-path.ts +++ b/src/frontend/features/library/library-path.ts @@ -1,4 +1,4 @@ -import { useParams } from "@tanstack/react-router"; +import { useMatch, useParams } from "@tanstack/react-router"; import { LibraryId } from "@backend/features/library/library-id"; import { DEFAULT_SETTINGS } from "@backend/features/settings/settings"; @@ -44,3 +44,13 @@ export function getLibraryName(libraryId: string): string { } throw new Error("Unknown library: " + libraryId); } + +/** Whether the library's own page is showing, rather than one of its groups. */ +export function useIsHome(): boolean { + return ( + useMatch({ + from: "/app/library/$libraryId/", + shouldThrow: false + }) !== undefined + ); +} diff --git a/src/frontend/features/settings/components/settings-menu.tsx b/src/frontend/features/settings/components/settings-menu.tsx index fbdd57a5e..ee3b800b7 100644 --- a/src/frontend/features/settings/components/settings-menu.tsx +++ b/src/frontend/features/settings/components/settings-menu.tsx @@ -1,7 +1,6 @@ import { useNavigate, useRouterState } from "@tanstack/react-router"; import { DEFAULT_SETTINGS } from "@backend/features/settings/settings"; import { Divider, Group, Text, Title } from "@mantine/core"; -import { modals } from "@mantine/modals"; import { FontWeight } from "../../../lib/style-constants"; import { Dispatch, ReactNode, useMemo } from "react"; import { Theme } from "@backend/features/settings/settings"; @@ -9,7 +8,6 @@ import { hasEditorAccess } from "@backend/features/auth/access-level"; import { isWithinAccessLevel } from "@backend/features/auth/access-level"; import { AccessLevel } from "@backend/features/auth/access-level"; import { useSaveSettings } from "../settings"; -import { capitalize } from "../../../lib/utils"; import { OpenUrlButton } from "../../../components/open-url-button"; import { RequireAccessLevel, useAccessData } from "../../auth/access-level"; import { useUiState } from "../../../lib/ui-state"; @@ -21,14 +19,6 @@ import { } from "../../../components/select-utils"; import { ReloadGroupsButton } from "../../library/components/reload-groups-button"; -export function openSettingsMenu() { - modals.open({ - title: "Settings", - centered: true, - children: <SettingsMenuContent /> - }); -} - /** * A labeled row holding a single setting control. */ @@ -43,7 +33,12 @@ function SettingRow(props: { label: string; children: ReactNode }): ReactNode { ); } -function SettingsMenuContent(): ReactNode { +/** Capitalizes the first letter of a string and lower cases everything else. */ +function capitalize(val: string) { + return val[0].toUpperCase() + val.slice(1).toLowerCase(); +} + +export function SettingsMenuContent(): ReactNode { const accessData = useAccessData(); let adminSettings: ReactNode = null; diff --git a/src/frontend/features/settings/open-settings-menu.tsx b/src/frontend/features/settings/open-settings-menu.tsx new file mode 100644 index 000000000..0cec234a6 --- /dev/null +++ b/src/frontend/features/settings/open-settings-menu.tsx @@ -0,0 +1,14 @@ +import { modals } from "@mantine/modals"; +import { SettingsMenuContent } from "./components/settings-menu"; + +/** + * Kept out of the component file so that file exports only components, which + * is what lets React Refresh swap it in place instead of reloading its callers. + */ +export function openSettingsMenu() { + modals.open({ + title: "Settings", + centered: true, + children: <SettingsMenuContent /> + }); +} diff --git a/src/frontend/lib/api-client.ts b/src/frontend/lib/api-client.ts index 9a793094e..02e8efed0 100644 --- a/src/frontend/lib/api-client.ts +++ b/src/frontend/lib/api-client.ts @@ -3,7 +3,7 @@ import { type URLSearchParamsInit, type QueryOptions, type PostOptions -} from "./utils"; +} from "@backend/lib/query-params"; import { fromApiErrorBody } from "./errors"; import { THUMBNAIL_FALLBACK_HEADER } from "@backend/features/thumbnails/keys"; import { HttpStatus } from "http-status-ts"; diff --git a/src/frontend/lib/query-cache.ts b/src/frontend/lib/query-cache.ts new file mode 100644 index 000000000..f70cbf6dc --- /dev/null +++ b/src/frontend/lib/query-cache.ts @@ -0,0 +1,28 @@ +import { produce } from "immer"; +import type { QueryKey } from "@tanstack/react-query"; +import { queryClient } from "./query-client"; + +type Updater<T> = (value: T | undefined) => T | undefined; + +/** + * A wrapper around Immer which can be used to update query data. + * Unlike normal updating, you can fully mutate the value without any issues. + */ +export function getQueryUpdater<T>(recipe: (draft: T) => void): Updater<T> { + return (value: T | undefined) => { + if (value === undefined) return undefined; + return produce(value, recipe); + }; +} + +/** + * A helper which can be used to make an optimistic update to a query with the given queryKey. + */ +export async function patchQuery<T>( + queryKey: QueryKey, + recipe: (draft: T) => void +): Promise<void> { + await queryClient.cancelQueries({ queryKey }); + const queryUpdater = getQueryUpdater<T>(recipe); + queryClient.setQueryData(queryKey, queryUpdater); +} diff --git a/src/frontend/lib/utils.ts b/src/frontend/lib/utils.ts deleted file mode 100644 index ac44b1f8b..000000000 --- a/src/frontend/lib/utils.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { useMatch } from "@tanstack/react-router"; -import { produce } from "immer"; -import { Dispatch, SyntheticEvent } from "react"; -import { queryClient } from "./query-client"; -import { QueryKey } from "@tanstack/react-query"; - -export { createSearchParams } from "@backend/lib/query-params"; -export type { - URLSearchParamsInit, - ParamKeyValuePair, - QueryOptions, - PostOptions -} from "@backend/lib/query-params"; - -/** - * Capitalizes the first letter of a string and lower cases everything else. - */ -export function capitalize(val: string) { - return val[0].toUpperCase() + val.slice(1).toLowerCase(); -} - -/** Event handler that exposes the target element's value as a boolean. */ -export function handleBooleanChange(handler: Dispatch<boolean>) { - return (event: SyntheticEvent<HTMLElement>) => - handler((event.target as HTMLInputElement).checked); -} - -type Updater<T> = (value: T | undefined) => T | undefined; - -/** - * A wrapper around Immer which can be used to update query data. - * Unlike normal updating, you can fully mutate the value without any issues. - */ -export function getQueryUpdater<T>(recipe: (draft: T) => void): Updater<T> { - return (value: T | undefined) => { - if (value === undefined) return undefined; - return produce(value, recipe); - }; -} - -/** - * A helper which can be used to make an optimistic update to a query with the given queryKey. - */ -export async function patchQuery<T>( - queryKey: QueryKey, - recipe: (draft: T) => void -): Promise<void> { - await queryClient.cancelQueries({ queryKey }); - const queryUpdater = getQueryUpdater<T>(recipe); - queryClient.setQueryData(queryKey, queryUpdater); -} - -/** - * Returns true if the current route is the home route, and false if it is a document route. - */ -export function useIsHome(): boolean { - return ( - useMatch({ - from: "/app/library/$libraryId/", - shouldThrow: false - }) !== undefined - ); -} From dbd9eaaf6b9085d9d6df3f1ba2fd1445ba38807c Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 21 Aug 2026 22:42:09 +0000 Subject: [PATCH 22/56] Dedupe the restore toast, log requests, and add a quick-insert tip Give the cancel/restore toast an id keyed on the insertable, so opening and cancelling the same one repeatedly refreshes a single toast instead of stacking up a column of them. showInfoToast now takes an options object rather than a bare id, since it also needs autoClose. Mount hono/logger on the app. Only the Worker routes run it (assets are served ahead of the Worker by run_worker_first), and console.log reaches Workers Logs in production because observability is already enabled. Add a one-time tip after inserting an element's default configuration from the insert menu, pointing out that a right-click would have done the same thing. The condition is the canonical configuration being empty rather than "unchanged since the menu opened": right-click quick insert on a card passes no configuration, so a search hit's configuration is not what the tip would have gotten the user. --- src/backend/app.ts | 6 +++++ .../insert/components/insert-menu.tsx | 22 ++++++++++++++++-- .../features/insert/open-insert-menu.tsx | 23 ++++++++----------- .../features/insert/quick-insert-tip.ts | 18 +++++++++++++++ .../library/components/add-group-menu.tsx | 2 +- src/frontend/lib/notifications.tsx | 15 +++++++++--- src/frontend/lib/ui-state.ts | 4 +++- 7 files changed, 70 insertions(+), 20 deletions(-) create mode 100644 src/frontend/features/insert/quick-insert-tip.ts diff --git a/src/backend/app.ts b/src/backend/app.ts index fd44c0fb7..be65e5182 100644 --- a/src/backend/app.ts +++ b/src/backend/app.ts @@ -12,6 +12,7 @@ import { insertableRoutes } from "./features/library/insertables/routes"; import { libraryRoutes } from "./features/library/routes"; import { settingsRoutes } from "./features/settings/routes"; import { thumbnailRoutes } from "./features/thumbnails/routes"; +import { logger } from "hono/logger"; import { cacheMiddleware } from "./lib/cache"; import { bindCaller, getApp, type CallerFactory } from "./lib/context"; import { errorHandler } from "./lib/errors"; @@ -31,6 +32,11 @@ const apiRoutes = [ export function createApp(makeCaller: CallerFactory) { const app = getApp(); + // console.log reaches Workers Logs, since wrangler.jsonc enables + // observability. Only /init, /api/* and /auth/* run the Worker at all + // (see run_worker_first), so static assets are not logged. + app.use("*", logger()); + app.use("*", bindCaller(makeCaller)); for (const routes of apiRoutes) { diff --git a/src/frontend/features/insert/components/insert-menu.tsx b/src/frontend/features/insert/components/insert-menu.tsx index 8cd39590f..3dd4a982c 100644 --- a/src/frontend/features/insert/components/insert-menu.tsx +++ b/src/frontend/features/insert/components/insert-menu.tsx @@ -7,6 +7,7 @@ import { Button, Checkbox, Group, Stack, Text } from "@mantine/core"; import { Info, Plus } from "@phosphor-icons/react"; import { FontWeight, IconSize } from "../../../lib/style-constants"; import { modals } from "@mantine/modals"; +import { showQuickInsertTip } from "../quick-insert-tip"; import { useIsFetching } from "@tanstack/react-query"; import { insertableConfigurationQueryMatchKey } from "../../../lib/query-keys"; import { PreviewImageCard } from "../../thumbnails/components/thumbnail"; @@ -142,6 +143,9 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { <InsertButtons insertable={insertable} configuration={configuration} + isElementDefault={ + Object.keys(canonicalConfiguration).length === 0 + } isFavorite={favorite !== undefined} onInsert={onInsert} /> @@ -151,6 +155,11 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { } interface InsertButtonsProps { + /** + * Whether this is the element's own default configuration — which is what a + * right-click on its card inserts, and so what the tip is about. + */ + isElementDefault: boolean; insertable: InsertableOut; configuration?: ParameterValues; isFavorite: boolean; @@ -161,7 +170,13 @@ interface InsertButtonsProps { * The derive/insert button plus the insert and fasten checkbox. */ function InsertButtons(props: InsertButtonsProps): ReactNode { - const { insertable, configuration, isFavorite, onInsert } = props; + const { + insertable, + configuration, + isElementDefault, + isFavorite, + onInsert + } = props; const search = useSearch({ from: "/app" }); // Inserting targets the current Onshape document; there's nothing to insert @@ -183,8 +198,11 @@ function InsertButtons(props: InsertButtonsProps): ReactNode { const handleClick = useCallback(() => { insertMutation.mutate(canFasten && uiState.fasten); + if (isElementDefault) { + showQuickInsertTip(); + } onInsert(); - }, [insertMutation, onInsert, canFasten, uiState.fasten]); + }, [insertMutation, onInsert, canFasten, uiState.fasten, isElementDefault]); if (!isConnected) { return null; diff --git a/src/frontend/features/insert/open-insert-menu.tsx b/src/frontend/features/insert/open-insert-menu.tsx index 85bb24b43..796f4f4d8 100644 --- a/src/frontend/features/insert/open-insert-menu.tsx +++ b/src/frontend/features/insert/open-insert-menu.tsx @@ -1,12 +1,12 @@ import { modals } from "@mantine/modals"; -import { notifications } from "@mantine/notifications"; -import { Info } from "@phosphor-icons/react"; + import type { InsertableOut } from "@backend/features/library/contract"; import { type ParameterValues } from "@backend/features/configurations/models"; -import { IconSize } from "../../lib/style-constants"; + import { type NotificationAction, - renderNotification + renderNotification, + showInfoToast } from "../../lib/notifications"; import { InsertMenuContent, InsertMenuTitle } from "./components/insert-menu"; @@ -60,13 +60,10 @@ function showRestoreToast( openInsertMenu({ insertable, defaultConfiguration: configuration }) }; - notifications.show({ - message: renderNotification( - `Cancelled ${insertable.name}.`, - restoreButton - ), - color: "blue", - icon: <Info size={IconSize.MEDIUM} />, - autoClose: 3000 - }); + // Keyed on the insertable, so opening and cancelling the same one repeatedly + // refreshes one toast rather than stacking up a column of them. + showInfoToast( + renderNotification(`Cancelled ${insertable.name}.`, restoreButton), + { id: "restore-" + insertable.id, autoClose: 3000 } + ); } diff --git a/src/frontend/features/insert/quick-insert-tip.ts b/src/frontend/features/insert/quick-insert-tip.ts new file mode 100644 index 000000000..90ff56c9d --- /dev/null +++ b/src/frontend/features/insert/quick-insert-tip.ts @@ -0,0 +1,18 @@ +import { showInfoToast } from "../../lib/notifications"; +import { getUiState, updateUiState } from "../../lib/ui-state"; + +/** + * Points out the faster route once, after an insert that took nothing from + * opening the menu: the same insert was one right-click away. Shown only once, + * since someone who wants the menu should not be told off for using it. + */ +export function showQuickInsertTip(): void { + if (getUiState().hasSeenQuickInsertTip) { + return; + } + updateUiState({ hasSeenQuickInsertTip: true }); + showInfoToast( + "Tip: right-click an item to insert it without opening this menu.", + { id: "quick-insert-tip", autoClose: 8000 } + ); +} diff --git a/src/frontend/features/library/components/add-group-menu.tsx b/src/frontend/features/library/components/add-group-menu.tsx index d16d87ba0..b9d5d7356 100644 --- a/src/frontend/features/library/components/add-group-menu.tsx +++ b/src/frontend/features/library/components/add-group-menu.tsx @@ -48,7 +48,7 @@ function AddGroupMenuContent(props: AddGroupMenuContentProps): ReactNode { "add-group" ), onSuccess: () => { - showInfoToast("Adding document...", "add-group"); + showInfoToast("Adding document...", { id: "add-group" }); // Starts the job poll, which stays idle until something is known to // be running, and shows the spinner without waiting for a request. const justStarted: JobStatus = { running: true, runningForMs: 0 }; diff --git a/src/frontend/lib/notifications.tsx b/src/frontend/lib/notifications.tsx index f3a81ac7f..f7b789326 100644 --- a/src/frontend/lib/notifications.tsx +++ b/src/frontend/lib/notifications.tsx @@ -76,12 +76,21 @@ function showToast(config: ToastConfig): string { return id; } -export function showInfoToast(message: string, id?: string): string { +interface InfoToastOptions { + /** Repeats with the same id update the toast rather than stacking one up. */ + id?: string; + autoClose?: number | false; +} + +export function showInfoToast( + message: ReactNode, + options: InfoToastOptions = {} +): string { return showToast({ - id, color: "blue", icon: <Info size={IconSize.MEDIUM} />, - message + message, + ...options }); } diff --git a/src/frontend/lib/ui-state.ts b/src/frontend/lib/ui-state.ts index 9e67fca14..147de9e24 100644 --- a/src/frontend/lib/ui-state.ts +++ b/src/frontend/lib/ui-state.ts @@ -18,7 +18,9 @@ const UiStateSchema = z.object({ openGroupId: z.string().optional(), fasten: z.boolean().default(true), /** The access level to view the app as; absent means the granted default. */ - accessLevel: AccessLevelType.optional() + accessLevel: AccessLevelType.optional(), + /** Whether the quick-insert tip has been shown; it is only worth saying once. */ + hasSeenQuickInsertTip: z.boolean().default(false) }); type UiState = z.infer<typeof UiStateSchema>; From e873ee504b74f3c74463c7ee31c8aa05000f03b6 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 21 Aug 2026 22:56:02 +0000 Subject: [PATCH 23/56] Quick insert a search hit's configuration, not the element defaults InsertableCard rendered its menu without a configuration, so right-click quick insert on a search hit inserted the element defaults while the card's own thumbnail and the insert menu both showed the hit's configuration. Pass it through, as FavoriteCard already does for a favorite's default configuration. With that fixed, the quick-insert tip can use the condition it should have: the configuration is still the one the menu opened with, compared canonically so an untouched menu counts as unchanged whether or not it was opened with a configuration. --- .../insert/components/insert-menu.tsx | 40 ++++++++++++------- .../features/insert/quick-insert-tip.ts | 6 +-- .../library/components/insertable-card.tsx | 4 ++ 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/src/frontend/features/insert/components/insert-menu.tsx b/src/frontend/features/insert/components/insert-menu.tsx index 3dd4a982c..6f1076cbc 100644 --- a/src/frontend/features/insert/components/insert-menu.tsx +++ b/src/frontend/features/insert/components/insert-menu.tsx @@ -73,6 +73,18 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { // canonical form needs. Empty means the element's default configuration. const [canonicalConfiguration, setCanonicalConfiguration] = useState<ParameterValues>({}); + // The first report is what the menu opened with, and so what a right-click + // on the card would have inserted. Absent until the parameters load. + const [openedWithConfiguration, setOpenedWithConfiguration] = + useState<ParameterValues>(); + + const handleCanonicalConfiguration = useCallback( + (canonical: ParameterValues) => { + setCanonicalConfiguration(canonical); + setOpenedWithConfiguration((opened) => opened ?? canonical); + }, + [] + ); const [record, setRecord] = useState<SearchRecord | undefined>(undefined); // The title lives in the modal's chrome, so it's updated rather than @@ -104,7 +116,7 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { microversionId={insertable.microversionId} configuration={configuration} setConfiguration={setConfiguration} - onCanonicalConfiguration={setCanonicalConfiguration} + onCanonicalConfiguration={handleCanonicalConfiguration} onRecord={setRecord} /> ); @@ -143,8 +155,11 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { <InsertButtons insertable={insertable} configuration={configuration} - isElementDefault={ - Object.keys(canonicalConfiguration).length === 0 + isUnchanged={ + encodeCanonicalConfiguration(canonicalConfiguration) === + encodeCanonicalConfiguration( + openedWithConfiguration ?? {} + ) } isFavorite={favorite !== undefined} onInsert={onInsert} @@ -156,10 +171,10 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { interface InsertButtonsProps { /** - * Whether this is the element's own default configuration — which is what a - * right-click on its card inserts, and so what the tip is about. + * Whether the configuration is still the one the menu opened with, which a + * right-click on the card would have inserted without opening anything. */ - isElementDefault: boolean; + isUnchanged: boolean; insertable: InsertableOut; configuration?: ParameterValues; isFavorite: boolean; @@ -170,13 +185,8 @@ interface InsertButtonsProps { * The derive/insert button plus the insert and fasten checkbox. */ function InsertButtons(props: InsertButtonsProps): ReactNode { - const { - insertable, - configuration, - isElementDefault, - isFavorite, - onInsert - } = props; + const { insertable, configuration, isUnchanged, isFavorite, onInsert } = + props; const search = useSearch({ from: "/app" }); // Inserting targets the current Onshape document; there's nothing to insert @@ -198,11 +208,11 @@ function InsertButtons(props: InsertButtonsProps): ReactNode { const handleClick = useCallback(() => { insertMutation.mutate(canFasten && uiState.fasten); - if (isElementDefault) { + if (isUnchanged) { showQuickInsertTip(); } onInsert(); - }, [insertMutation, onInsert, canFasten, uiState.fasten, isElementDefault]); + }, [insertMutation, onInsert, canFasten, uiState.fasten, isUnchanged]); if (!isConnected) { return null; diff --git a/src/frontend/features/insert/quick-insert-tip.ts b/src/frontend/features/insert/quick-insert-tip.ts index 90ff56c9d..9469d8ec9 100644 --- a/src/frontend/features/insert/quick-insert-tip.ts +++ b/src/frontend/features/insert/quick-insert-tip.ts @@ -2,9 +2,9 @@ import { showInfoToast } from "../../lib/notifications"; import { getUiState, updateUiState } from "../../lib/ui-state"; /** - * Points out the faster route once, after an insert that took nothing from - * opening the menu: the same insert was one right-click away. Shown only once, - * since someone who wants the menu should not be told off for using it. + * Points out the faster route after an insert that changed nothing in the menu: + * the same insert was one right-click away. Shown only once, since someone who + * wants the menu should not be told off for using it. */ export function showQuickInsertTip(): void { if (getUiState().hasSeenQuickInsertTip) { diff --git a/src/frontend/features/library/components/insertable-card.tsx b/src/frontend/features/library/components/insertable-card.tsx index d1effa4b4..629998d84 100644 --- a/src/frontend/features/library/components/insertable-card.tsx +++ b/src/frontend/features/library/components/insertable-card.tsx @@ -109,6 +109,7 @@ export function InsertableCard(props: InsertableCardProps): ReactNode { <InsertableMenuItems favorite={favorite} insertable={insertable} + configuration={searchHit?.configuration} /> } /> @@ -119,6 +120,8 @@ interface InsertableMenuItemsProps { favorite: Favorite | undefined; insertable: InsertableOut; inInsertMenu?: boolean; + /** What quick insert inserts and "Open document" opens: a search hit's + * configuration on a card, the selected one inside the insert menu. */ configuration?: ParameterValues; } @@ -134,6 +137,7 @@ export function InsertableMenuItems( <> <QuickInsertItems insertable={insertable} + configuration={configuration} isFavorite={favorite !== undefined} /> <Menu.Divider /> From 7a9d82e0aac8eb5c5b76cbd8c989a8a9bfc498f4 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 22 Aug 2026 02:59:48 +0000 Subject: [PATCH 24/56] Rebuild the navbar as library tabs over a search row The navbar was one colored strip holding the brand, a library dropdown, search and settings. It is now two rows: library tabs with the brand and settings alongside, over a full-width search input and its vendor filter. Switching libraries is a tab click rather than a two-step menu, and the placeholder names the library being searched. The chrome goes neutral with it. The header is no longer painted with the primary color, so its controls drop the white override they needed to stay legible, and every library now shares one theme with the brand green as an accent on controls and the active tab, in place of a green, orange and blue that changed the whole app per library. The root no longer needs to resolve a library id to build a theme. Tab labels leave no room for a name plus its status, so the name and the status are separate: the tabs show the short label, and the tooltip, search placeholder and accordion title spell out the rest. --- src/frontend/components/app-navbar.tsx | 176 ++++++++++-------- src/frontend/features/library/library-path.ts | 35 +++- .../settings/components/vendor-filters.tsx | 6 +- src/frontend/lib/style-constants.ts | 6 - src/frontend/routes/__root.tsx | 26 +-- .../routes/app/library/$libraryId/index.tsx | 4 +- src/frontend/routes/app/route.tsx | 3 +- src/frontend/theme.ts | 29 +-- 8 files changed, 156 insertions(+), 129 deletions(-) diff --git a/src/frontend/components/app-navbar.tsx b/src/frontend/components/app-navbar.tsx index d1f6dfee3..76fa8e7a9 100644 --- a/src/frontend/components/app-navbar.tsx +++ b/src/frontend/components/app-navbar.tsx @@ -1,15 +1,17 @@ import { ActionIcon, + Box, Button, Group, Input, Loader, - Menu, + Stack, + Tabs, TextInput, Tooltip } from "@mantine/core"; -import { CaretDown, Gear, MagnifyingGlass } from "@phosphor-icons/react"; -import { HEADER_CONTROL_COLOR, IconSize } from "../lib/style-constants"; +import { Gear, MagnifyingGlass } from "@phosphor-icons/react"; +import { BORDER, IconSize } from "../lib/style-constants"; import { ReactNode, RefObject, useRef } from "react"; import { useNavigate } from "@tanstack/react-router"; @@ -17,7 +19,12 @@ import frcDesignBook from "/frc-design-book.svg"; import { openSettingsMenu } from "../features/settings/open-settings-menu"; import { VendorMenu } from "../features/settings/components/vendor-filters"; import { useUiState } from "../lib/ui-state"; -import { getLibraryName, useLibraryId } from "../features/library/library-path"; +import { + getLibraryFullName, + getLibraryName, + getLibraryTabLabel, + useLibraryId +} from "../features/library/library-path"; import { RequireAccessLevel } from "../features/auth/access-level"; import { useSaveSettings } from "../features/settings/settings"; import { useAccessData } from "../features/auth/access-level"; @@ -28,29 +35,34 @@ import { queryClient } from "../lib/query-client"; import { getLibraryVersionQuery } from "../features/library/queries"; /** - * Provides top-level navigation for the app. A single colored control row holds - * the brand, library menu, search, vendor filter menu, and settings. + * Provides top-level navigation for the app: a row of library tabs with the + * brand and settings alongside, over a row holding search and its filters. */ export function AppNavbar(): ReactNode { - // Create a subgroup that won't wrap and flexes to take up the entire space - const leftGroup = ( - <Group wrap="nowrap" flex={1} miw={0}> - <FrcDesignBookIcon /> - <LibraryMenu /> - <SearchBar /> - <VendorMenu /> - </Group> - ); - return ( - <Group justify="space-between" wrap="nowrap" gap="xs" p="sm"> - {leftGroup} - <Group wrap="nowrap" gap="xs"> - <JobIndicator /> - <SignInButton /> - <SettingsButton /> + <Stack gap={0}> + {/* Stretched so the tabs run the full height and their underline + lands on the row's own border. */} + <Group + gap="sm" + px="sm" + wrap="nowrap" + align="stretch" + style={{ borderBottom: BORDER }} + > + <FrcDesignBookIcon /> + <LibraryTabs /> + <Group gap="xs" wrap="nowrap" ml="auto"> + <JobIndicator /> + <SignInButton /> + <SettingsButton /> + </Group> + </Group> + <Group gap="xs" p="sm" wrap="nowrap"> + <SearchBar /> + <VendorMenu /> </Group> - </Group> + </Stack> ); } @@ -65,11 +77,7 @@ function SignInButton(): ReactNode { if (isPending || signedIn) return null; return ( - <Button - variant="outline" - color={HEADER_CONTROL_COLOR} - onClick={startSignIn} - > + <Button variant="outline" size="compact-sm" onClick={startSignIn}> Sign in </Button> ); @@ -93,32 +101,44 @@ function RunningJobLoader(): ReactNode { withArrow label="The library is being loaded from Onshape in the background" > - <Loader size="md" color="white" /> + <Loader size="sm" /> </Tooltip> ); } function FrcDesignBookIcon(): ReactNode { return ( - <a href="https://frcdesign.org" target="_blank"> - <img - src={frcDesignBook} - alt="FRCDesign.org" - width={24} - // Render the book in the header's contrast color (white on the - // colored header) instead of its native gray. - style={{ display: "block", filter: "brightness(0) invert(1)" }} - /> - </a> + <Box + component="a" + href="https://frcdesign.org" + target="_blank" + aria-label="FRCDesign.org" + w={IconSize.CONTROL} + h={IconSize.CONTROL} + my="auto" + // Not the link color the anchor would otherwise hand the mask. + c="var(--mantine-color-text)" + // Masked rather than drawn, so the book takes the text color rather + // than the gray baked into the file. The url has to be quoted: + // Vite inlines this asset as a data uri containing apostrophes. + style={{ + backgroundColor: "currentColor", + maskImage: `url("${frcDesignBook}")`, + maskSize: "contain", + maskRepeat: "no-repeat", + maskPosition: "center" + }} + /> ); } -function LibraryMenu(): ReactNode { +/** Switches libraries; the url is what actually selects one. */ +function LibraryTabs(): ReactNode { const currentLibraryId = useLibraryId(); const saveSettings = useSaveSettings(); const navigate = useNavigate(); - // Warm the versions on open, so picking one has nothing left to wait for. + // Warm the versions on hover, so picking one has nothing left to wait for. const prefetchVersions = () => { for (const libraryId of Object.values(LibraryId)) { void queryClient.prefetchQuery(getLibraryVersionQuery(libraryId)); @@ -126,37 +146,46 @@ function LibraryMenu(): ReactNode { }; return ( - <Menu position="bottom-start" withinPortal onOpen={prefetchVersions}> - <Menu.Target> - <Button - variant="default" - rightSection={<CaretDown size={IconSize.SMALL} />} - > - {getLibraryName(currentLibraryId)} - </Button> - </Menu.Target> - <Menu.Dropdown> + <Tabs + value={currentLibraryId} + onMouseEnter={prefetchVersions} + onChange={(value) => { + if (!value || value === currentLibraryId) { + return; + } + const libraryId = value as LibraryId; + // Write-behind: the url displays it, this only decides where + // `/init` lands next time. + saveSettings({ libraryId }); + void navigate({ + to: "/app/library/$libraryId", + params: { libraryId } + }); + }} + styles={{ + // Hides the line Mantine draws under the tab list alone — + // the row owns one that runs the full width. The active tab's + // indicator is colored separately and survives this. + root: { "--tab-border-color": "transparent" }, + // Pulled onto that divider, so the active tab's indicator + // replaces it instead of stacking a second line above it. + tab: { marginBottom: -1 } + }} + > + <Tabs.List aria-label="Libraries"> {Object.values(LibraryId).map((libraryId) => ( - <Menu.Item + <Tooltip key={libraryId} - onClick={() => { - if (libraryId === currentLibraryId) { - return; - } - // Write-behind: the url displays it, this only - // decides where `/init` lands next time. - saveSettings({ libraryId }); - void navigate({ - to: "/app/library/$libraryId", - params: { libraryId } - }); - }} + withArrow + label={getLibraryFullName(libraryId)} > - {getLibraryName(libraryId)} - </Menu.Item> + <Tabs.Tab value={libraryId}> + {getLibraryTabLabel(libraryId)} + </Tabs.Tab> + </Tooltip> ))} - </Menu.Dropdown> - </Menu> + </Tabs.List> + </Tabs> ); } @@ -164,10 +193,10 @@ export function SettingsButton() { return ( <ActionIcon variant="subtle" - color={HEADER_CONTROL_COLOR} - // Match the height of the buttons and search input beside it. - size="input-sm" + color="gray" title="Settings" + my="auto" + size="input-sm" onClick={() => openSettingsMenu()} > <Gear size={IconSize.CONTROL} /> @@ -187,6 +216,7 @@ function selectAllInputText(ref: RefObject<HTMLInputElement | null>) { export function SearchBar() { const ref = useRef<HTMLInputElement>(null); const [uiState, setUiState] = useUiState(); + const libraryId = useLibraryId(); const clearButton = uiState.searchQuery ? ( <Input.ClearButton @@ -203,9 +233,9 @@ export function SearchBar() { return ( <TextInput type="search" - maw={200} // Hardcode search bar width as max so close button doesn't expand + flex={1} leftSection={<MagnifyingGlass size={IconSize.SMALL} />} - placeholder="Search library..." + placeholder={`Search ${getLibraryName(libraryId)}...`} ref={ref} value={uiState.searchQuery ?? ""} onFocus={() => { diff --git a/src/frontend/features/library/library-path.ts b/src/frontend/features/library/library-path.ts index 8aaac607e..3c0429c08 100644 --- a/src/frontend/features/library/library-path.ts +++ b/src/frontend/features/library/library-path.ts @@ -38,13 +38,44 @@ export function getLibraryName(libraryId: string): string { case LibraryId.FRC_DESIGN_LIB: return "FRCDesignLib"; case LibraryId.FTC_DESIGN_LIB: - return "FTCDesignLib (Beta)"; + return "FTCDesignLib"; case LibraryId.MKCAD: - return "MKCAD (Deprecated)"; + return "MKCAD"; } throw new Error("Unknown library: " + libraryId); } +/** The label on a library's tab, where there is only room for a few characters. */ +export function getLibraryTabLabel(libraryId: string): string { + switch (libraryId) { + case LibraryId.FRC_DESIGN_LIB: + return "FRC"; + case LibraryId.FTC_DESIGN_LIB: + return "FTC"; + case LibraryId.MKCAD: + return "MKCad"; + } + throw new Error("Unknown library: " + libraryId); +} + +/** Where a library is in its life; undefined once it is simply supported. */ +export function getLibraryStatus(libraryId: string): string | undefined { + switch (libraryId) { + case LibraryId.FTC_DESIGN_LIB: + return "Beta"; + case LibraryId.MKCAD: + return "Deprecated"; + } + return undefined; +} + +/** The name with its status, for the places with room to spell it out. */ +export function getLibraryFullName(libraryId: string): string { + const status = getLibraryStatus(libraryId); + const name = getLibraryName(libraryId); + return status ? `${name} (${status})` : name; +} + /** Whether the library's own page is showing, rather than one of its groups. */ export function useIsHome(): boolean { return ( diff --git a/src/frontend/features/settings/components/vendor-filters.tsx b/src/frontend/features/settings/components/vendor-filters.tsx index 15fe21513..ebd0626b0 100644 --- a/src/frontend/features/settings/components/vendor-filters.tsx +++ b/src/frontend/features/settings/components/vendor-filters.tsx @@ -1,6 +1,6 @@ import { ActionIcon, Button, Menu } from "@mantine/core"; import { Funnel, FunnelX } from "@phosphor-icons/react"; -import { HEADER_CONTROL_COLOR, IconSize } from "../../../lib/style-constants"; +import { IconSize } from "../../../lib/style-constants"; import { ReactNode } from "react"; import { getVendorName } from "@backend/features/library/vendors"; import { Vendor } from "@backend/features/library/vendors"; @@ -82,8 +82,8 @@ export function VendorMenu(): ReactNode { <AppContextMenu wideMenu menuItems={menuItems} controlledByButton> <ActionIcon variant={hasFilters ? "light" : "subtle"} - color={HEADER_CONTROL_COLOR} - // Match the height of the buttons and search input beside it. + color={hasFilters ? undefined : "gray"} + // Match the height of the search input beside it. size="input-sm" title="Filter vendors" > diff --git a/src/frontend/lib/style-constants.ts b/src/frontend/lib/style-constants.ts index f327cdb9d..c84f7ea2c 100644 --- a/src/frontend/lib/style-constants.ts +++ b/src/frontend/lib/style-constants.ts @@ -39,9 +39,3 @@ export enum PrimaryColor { */ CONTRAST = "var(--mantine-primary-color-contrast)" } - -/** - * Hex, not a css var or a named color: Mantine parses `color` to derive border - * and hover tints, and anything else silently resolves to black. - */ -export const HEADER_CONTROL_COLOR = "#fff"; diff --git a/src/frontend/routes/__root.tsx b/src/frontend/routes/__root.tsx index 3630b2e7d..97f7f10d5 100644 --- a/src/frontend/routes/__root.tsx +++ b/src/frontend/routes/__root.tsx @@ -1,21 +1,15 @@ -import { - createRootRoute, - Outlet, - useParams, - useSearch -} from "@tanstack/react-router"; +import { createRootRoute, Outlet, useSearch } from "@tanstack/react-router"; import { QueryClientProvider } from "@tanstack/react-query"; import { MantineProvider } from "@mantine/core"; import { ModalsProvider } from "@mantine/modals"; import { Notifications } from "@mantine/notifications"; -import { ReactNode, useMemo } from "react"; +import { ReactNode } from "react"; import { useColorScheme } from "@mantine/hooks"; import { queryClient } from "../lib/query-client"; -import { createAppTheme } from "../theme"; +import { appTheme } from "../theme"; import { getColorTheme } from "../lib/onshape-params"; import { DEFAULT_SETTINGS } from "@backend/features/settings/settings"; import { NotFoundError, RootCrash } from "../components/root-error"; -import { isLibraryId } from "../features/library/library-path"; export const Route = createRootRoute({ component: RootComponent, @@ -27,18 +21,10 @@ export const Route = createRootRoute({ }); function RootComponent(): ReactNode { + // The theme comes off the url — the entry redirect seeds it — so the first + // paint is already the right colors. const search = useSearch({ strict: false }); - // Both come off the url — the entry redirect seeds them and a switch - // rewrites them — so the first paint is already the right colors. - const params = useParams({ strict: false }); - // The root renders around a not-found too, so the url may name a library - // that does not exist; its theme still has to resolve to something. - const libraryId = - params.libraryId && isLibraryId(params.libraryId) - ? params.libraryId - : DEFAULT_SETTINGS.libraryId; - const theme = useMemo(() => createAppTheme(libraryId), [libraryId]); // Onshape puts its own scheme on the url when it launches us; standalone // there is none, and the OS is what "system" means. const osColorScheme = useColorScheme(); @@ -49,7 +35,7 @@ function RootComponent(): ReactNode { return ( <QueryClientProvider client={queryClient}> - <MantineProvider theme={theme} forceColorScheme={colorTheme}> + <MantineProvider theme={appTheme} forceColorScheme={colorTheme}> <ModalsProvider labels={{ confirm: "Confirm", cancel: "Cancel" }} > diff --git a/src/frontend/routes/app/library/$libraryId/index.tsx b/src/frontend/routes/app/library/$libraryId/index.tsx index f72603f5e..ba1efef8e 100644 --- a/src/frontend/routes/app/library/$libraryId/index.tsx +++ b/src/frontend/routes/app/library/$libraryId/index.tsx @@ -20,7 +20,7 @@ import { AddGroupButton } from "../../../../features/library/components/add-grou import { FavoritesList } from "../../../../features/favorites/components/favorites-list"; import { useLibraryQuery } from "../../../../features/library/queries"; import { - getLibraryName, + getLibraryFullName, useLibraryId } from "../../../../features/library/library-path"; import { updateUiState, useUiState } from "../../../../lib/ui-state"; @@ -94,7 +94,7 @@ function HomeList(): ReactNode { color={PrimaryColor.FILLED} /> ), - title: getLibraryName(libraryId), + title: getLibraryFullName(libraryId), panel: <LibraryList />, opened: uiState.isLibraryOpen, setOpened: (opened) => setUiState({ isLibraryOpen: opened }) diff --git a/src/frontend/routes/app/route.tsx b/src/frontend/routes/app/route.tsx index 2c589fafb..0967ab054 100644 --- a/src/frontend/routes/app/route.tsx +++ b/src/frontend/routes/app/route.tsx @@ -14,7 +14,6 @@ import { SectionLoading } from "../../components/app-zero-state"; import { useMessageListener } from "../../lib/messages"; import { useSignInToast } from "../../features/auth/sign-in"; import { RootAppError } from "../../components/root-error"; -import { PrimaryColor } from "../../lib/style-constants"; export const Route = createFileRoute("/app")({ component: App, @@ -37,7 +36,7 @@ function App() { return ( <AppShell header={{ height: headerHeight || 56 }}> - <AppShell.Header bg={PrimaryColor.FILLED} c={PrimaryColor.CONTRAST}> + <AppShell.Header> <div ref={headerRef}> <AppNavbar /> </div> diff --git a/src/frontend/theme.ts b/src/frontend/theme.ts index 323431780..e663326fd 100644 --- a/src/frontend/theme.ts +++ b/src/frontend/theme.ts @@ -1,5 +1,4 @@ import { createTheme, type MantineColorsTuple } from "@mantine/core"; -import { LibraryId } from "@backend/features/library/library-id"; /** * FRCDesign brand green ramp (index 6 = #4cae4f, the brand color). @@ -19,24 +18,12 @@ const frcGreen: MantineColorsTuple = [ ]; /** - * Maps a library to its primary Mantine color. + * One theme for every library: the chrome is neutral and the brand green is an + * accent on controls and the active tab, rather than a color per library. */ -export function getLibraryColor(libraryId: LibraryId): string { - switch (libraryId) { - case LibraryId.FTC_DESIGN_LIB: - return "orange"; - case LibraryId.MKCAD: - return "blue"; - case LibraryId.FRC_DESIGN_LIB: - return "frcGreen"; - } -} - -export function createAppTheme(libraryId: LibraryId) { - return createTheme({ - colors: { frcGreen }, - primaryColor: getLibraryColor(libraryId), - autoContrast: true, - cursorType: "pointer" - }); -} +export const appTheme = createTheme({ + colors: { frcGreen }, + primaryColor: "frcGreen", + autoContrast: true, + cursorType: "pointer" +}); From 77a15677c4628a048f3f01b9d4d678ad141183ec Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 22 Aug 2026 03:15:21 +0000 Subject: [PATCH 25/56] Give each library its accent color back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The navbar refactor collapsed the three libraries onto one green theme. Restore the per-library primary color — green, orange, blue — now as an accent on controls and the active tab rather than a painted header. getLibraryColor falls back instead of returning undefined for an unknown library, so the root can theme a url naming one that does not exist; that is what forced the id check the root used to carry, and the 404 for such a url still comes from the library route. --- src/frontend/routes/__root.tsx | 23 +++++++++++++++++------ src/frontend/theme.ts | 31 +++++++++++++++++++++++-------- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/frontend/routes/__root.tsx b/src/frontend/routes/__root.tsx index 97f7f10d5..b40e8cb26 100644 --- a/src/frontend/routes/__root.tsx +++ b/src/frontend/routes/__root.tsx @@ -1,12 +1,17 @@ -import { createRootRoute, Outlet, useSearch } from "@tanstack/react-router"; +import { + createRootRoute, + Outlet, + useParams, + useSearch +} from "@tanstack/react-router"; import { QueryClientProvider } from "@tanstack/react-query"; import { MantineProvider } from "@mantine/core"; import { ModalsProvider } from "@mantine/modals"; import { Notifications } from "@mantine/notifications"; -import { ReactNode } from "react"; +import { ReactNode, useMemo } from "react"; import { useColorScheme } from "@mantine/hooks"; import { queryClient } from "../lib/query-client"; -import { appTheme } from "../theme"; +import { createAppTheme } from "../theme"; import { getColorTheme } from "../lib/onshape-params"; import { DEFAULT_SETTINGS } from "@backend/features/settings/settings"; import { NotFoundError, RootCrash } from "../components/root-error"; @@ -21,9 +26,15 @@ export const Route = createRootRoute({ }); function RootComponent(): ReactNode { - // The theme comes off the url — the entry redirect seeds it — so the first - // paint is already the right colors. + // Both come off the url — the entry redirect seeds them and a switch + // rewrites them — so the first paint is already the right colors. const search = useSearch({ strict: false }); + const params = useParams({ strict: false }); + + const theme = useMemo( + () => createAppTheme(params.libraryId ?? DEFAULT_SETTINGS.libraryId), + [params.libraryId] + ); // Onshape puts its own scheme on the url when it launches us; standalone // there is none, and the OS is what "system" means. @@ -35,7 +46,7 @@ function RootComponent(): ReactNode { return ( <QueryClientProvider client={queryClient}> - <MantineProvider theme={appTheme} forceColorScheme={colorTheme}> + <MantineProvider theme={theme} forceColorScheme={colorTheme}> <ModalsProvider labels={{ confirm: "Confirm", cancel: "Cancel" }} > diff --git a/src/frontend/theme.ts b/src/frontend/theme.ts index e663326fd..a06eead14 100644 --- a/src/frontend/theme.ts +++ b/src/frontend/theme.ts @@ -1,4 +1,5 @@ import { createTheme, type MantineColorsTuple } from "@mantine/core"; +import { LibraryId } from "@backend/features/library/library-id"; /** * FRCDesign brand green ramp (index 6 = #4cae4f, the brand color). @@ -18,12 +19,26 @@ const frcGreen: MantineColorsTuple = [ ]; /** - * One theme for every library: the chrome is neutral and the brand green is an - * accent on controls and the active tab, rather than a color per library. + * Falls back rather than throwing: the root themes the app even when the url + * names a library that does not exist, which the route 404s separately. */ -export const appTheme = createTheme({ - colors: { frcGreen }, - primaryColor: "frcGreen", - autoContrast: true, - cursorType: "pointer" -}); +function getLibraryColor(libraryId: string): string { + switch (libraryId) { + case LibraryId.FTC_DESIGN_LIB: + return "orange"; + case LibraryId.MKCAD: + return "blue"; + default: + return "frcGreen"; + } +} + +/** The chrome stays neutral; a library's color is an accent on its controls. */ +export function createAppTheme(libraryId: string) { + return createTheme({ + colors: { frcGreen }, + primaryColor: getLibraryColor(libraryId), + autoContrast: true, + cursorType: "pointer" + }); +} From 620b6a08a1a10eed2395ec021ccc044399c7d661 Mon Sep 17 00:00:00 2001 From: Alex Kempen <alex.bookreader@gmail.com> Date: Fri, 21 Aug 2026 23:43:40 -0500 Subject: [PATCH 26/56] Remove console.log --- src/backend/features/library/db.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/backend/features/library/db.ts b/src/backend/features/library/db.ts index 858806850..d3c2758ad 100644 --- a/src/backend/features/library/db.ts +++ b/src/backend/features/library/db.ts @@ -165,7 +165,6 @@ export async function rebuildSearchDb( db: Db, libraryId: LibraryId ): Promise<string> { - const start = Date.now(); const [libraryData, recordsMap] = await Promise.all([ getLibraryOut(db, libraryId), getRecordsMap(db, libraryId) @@ -176,10 +175,6 @@ export async function rebuildSearchDb( await bucket.put(searchIndexKey(libraryId), searchDb, { httpMetadata: { contentType: "application/json" } }); - console.log( - `Rebuilt search index for ${libraryId}: ` + - `${searchDb.length} B, ${Date.now() - start} ms` - ); return searchDb; } From 6951183506f47cd19282266d3f8d2175dcc8e5ee Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 22 Aug 2026 04:55:28 +0000 Subject: [PATCH 27/56] Polish the navbar: brand tile, status badges, distinct rows Put the brand's colored tile back under the book, now filled with the library's accent instead of painting the whole header, and darken the tab row a step so it reads apart from the search row below it. Move Beta and Deprecated out of the name and into badges in the library title, the one place with room to spell them out. The name is plain everywhere else, so the tab tooltip and search placeholder no longer carry a parenthetical. Also spell MKCad the way its tab does, and swap the library section's single book for a shelf of them, which reads as a library rather than a document. --- src/frontend/components/app-navbar.tsx | 42 ++++++++++++------- src/frontend/features/library/library-path.ts | 21 +++++----- .../routes/app/library/$libraryId/index.tsx | 28 ++++++++++--- 3 files changed, 60 insertions(+), 31 deletions(-) diff --git a/src/frontend/components/app-navbar.tsx b/src/frontend/components/app-navbar.tsx index 76fa8e7a9..ca096da6b 100644 --- a/src/frontend/components/app-navbar.tsx +++ b/src/frontend/components/app-navbar.tsx @@ -11,7 +11,7 @@ import { Tooltip } from "@mantine/core"; import { Gear, MagnifyingGlass } from "@phosphor-icons/react"; -import { BORDER, IconSize } from "../lib/style-constants"; +import { BORDER, IconSize, PrimaryColor } from "../lib/style-constants"; import { ReactNode, RefObject, useRef } from "react"; import { useNavigate } from "@tanstack/react-router"; @@ -20,7 +20,6 @@ import { openSettingsMenu } from "../features/settings/open-settings-menu"; import { VendorMenu } from "../features/settings/components/vendor-filters"; import { useUiState } from "../lib/ui-state"; import { - getLibraryFullName, getLibraryName, getLibraryTabLabel, useLibraryId @@ -34,6 +33,10 @@ import { LibraryId } from "@backend/features/library/library-id"; import { queryClient } from "../lib/query-client"; import { getLibraryVersionQuery } from "../features/library/queries"; +/** A step off the page, so the tab row reads apart from the search row. */ +const TOP_ROW_BACKGROUND = + "light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8))"; + /** * Provides top-level navigation for the app: a row of library tabs with the * brand and settings alongside, over a row holding search and its filters. @@ -48,6 +51,7 @@ export function AppNavbar(): ReactNode { px="sm" wrap="nowrap" align="stretch" + bg={TOP_ROW_BACKGROUND} style={{ borderBottom: BORDER }} > <FrcDesignBookIcon /> @@ -116,19 +120,29 @@ function FrcDesignBookIcon(): ReactNode { w={IconSize.CONTROL} h={IconSize.CONTROL} my="auto" - // Not the link color the anchor would otherwise hand the mask. - c="var(--mantine-color-text)" - // Masked rather than drawn, so the book takes the text color rather - // than the gray baked into the file. The url has to be quoted: - // Vite inlines this asset as a data uri containing apostrophes. + bg={PrimaryColor.FILLED} + c={PrimaryColor.CONTRAST} style={{ - backgroundColor: "currentColor", - maskImage: `url("${frcDesignBook}")`, - maskSize: "contain", - maskRepeat: "no-repeat", - maskPosition: "center" + borderRadius: "var(--mantine-radius-sm)", + display: "grid", + placeItems: "center" }} - /> + > + {/* Masked rather than drawn, so the book takes the tile's contrast + color rather than the gray baked into the file. The url has to + be quoted: Vite inlines this asset as a data uri with quotes. */} + <Box + w={IconSize.SMALL} + h={IconSize.SMALL} + style={{ + backgroundColor: "currentColor", + maskImage: `url("${frcDesignBook}")`, + maskSize: "contain", + maskRepeat: "no-repeat", + maskPosition: "center" + }} + /> + </Box> ); } @@ -177,7 +191,7 @@ function LibraryTabs(): ReactNode { <Tooltip key={libraryId} withArrow - label={getLibraryFullName(libraryId)} + label={getLibraryName(libraryId)} > <Tabs.Tab value={libraryId}> {getLibraryTabLabel(libraryId)} diff --git a/src/frontend/features/library/library-path.ts b/src/frontend/features/library/library-path.ts index 3c0429c08..e263ced50 100644 --- a/src/frontend/features/library/library-path.ts +++ b/src/frontend/features/library/library-path.ts @@ -40,7 +40,7 @@ export function getLibraryName(libraryId: string): string { case LibraryId.FTC_DESIGN_LIB: return "FTCDesignLib"; case LibraryId.MKCAD: - return "MKCAD"; + return "MKCad"; } throw new Error("Unknown library: " + libraryId); } @@ -58,24 +58,23 @@ export function getLibraryTabLabel(libraryId: string): string { throw new Error("Unknown library: " + libraryId); } +export interface LibraryStatus { + label: string; + /** Mantine color for the badge; how much the label should alarm. */ + color: string; +} + /** Where a library is in its life; undefined once it is simply supported. */ -export function getLibraryStatus(libraryId: string): string | undefined { +export function getLibraryStatus(libraryId: string): LibraryStatus | undefined { switch (libraryId) { case LibraryId.FTC_DESIGN_LIB: - return "Beta"; + return { label: "Beta", color: "blue" }; case LibraryId.MKCAD: - return "Deprecated"; + return { label: "Deprecated", color: "red" }; } return undefined; } -/** The name with its status, for the places with room to spell it out. */ -export function getLibraryFullName(libraryId: string): string { - const status = getLibraryStatus(libraryId); - const name = getLibraryName(libraryId); - return status ? `${name} (${status})` : name; -} - /** Whether the library's own page is showing, rather than one of its groups. */ export function useIsHome(): boolean { return ( diff --git a/src/frontend/routes/app/library/$libraryId/index.tsx b/src/frontend/routes/app/library/$libraryId/index.tsx index ba1efef8e..d26623fe7 100644 --- a/src/frontend/routes/app/library/$libraryId/index.tsx +++ b/src/frontend/routes/app/library/$libraryId/index.tsx @@ -1,6 +1,6 @@ import { createFileRoute, Outlet } from "@tanstack/react-router"; -import { Accordion } from "@mantine/core"; -import { Book, MagnifyingGlass } from "@phosphor-icons/react"; +import { Accordion, Badge, Group } from "@mantine/core"; +import { Books, MagnifyingGlass } from "@phosphor-icons/react"; import { BORDER, IconSize, @@ -20,7 +20,8 @@ import { AddGroupButton } from "../../../../features/library/components/add-grou import { FavoritesList } from "../../../../features/favorites/components/favorites-list"; import { useLibraryQuery } from "../../../../features/library/queries"; import { - getLibraryFullName, + getLibraryName, + getLibraryStatus, useLibraryId } from "../../../../features/library/library-path"; import { updateUiState, useUiState } from "../../../../lib/ui-state"; @@ -37,7 +38,7 @@ export const Route = createFileRoute("/app/library/$libraryId/")({ interface Section { value: string; icon: ReactNode; - title: string; + title: ReactNode; panel: ReactNode; opened: boolean; setOpened: (opened: boolean) => void; @@ -89,12 +90,12 @@ function HomeList(): ReactNode { : { value: "library", icon: ( - <Book + <Books size={IconSize.MEDIUM} color={PrimaryColor.FILLED} /> ), - title: getLibraryFullName(libraryId), + title: <LibraryTitle libraryId={libraryId} />, panel: <LibraryList />, opened: uiState.isLibraryOpen, setOpened: (opened) => setUiState({ isLibraryOpen: opened }) @@ -140,6 +141,21 @@ function HomeList(): ReactNode { ); } +/** The library's name, and a badge when it is not simply supported. */ +function LibraryTitle({ libraryId }: { libraryId: string }): ReactNode { + const status = getLibraryStatus(libraryId); + return ( + <Group gap="xs" wrap="nowrap"> + {getLibraryName(libraryId)} + {status && ( + <Badge size="sm" variant="light" color={status.color}> + {status.label} + </Badge> + )} + </Group> + ); +} + function LibraryList() { const libraryQuery = useLibraryQuery(); From 43d99f98196124a76223e4c4fe08b011305a5421 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 22 Aug 2026 05:09:42 +0000 Subject: [PATCH 28/56] Frame the insert menu, and stop the favorite icon shrinking Give the insert menu chrome around its configuration: the header and a new footer holding the favorite and insert controls both sit on the surface the navbar's tab row uses, which moves up to style-constants now that two places want it. The favorite icon shrank from 24px to 16px on hover, because a hovered non-favorite swaps a bare Phosphor icon for one wrapped in Box, and Box builds its own style over the font-size that Phosphor's size prop sets. Size the wrapped icons with fz so the value survives. Also let the status badges take the library's color rather than one per status, and shrink the settings gear so the top row breathes. --- src/frontend/components/app-navbar.tsx | 17 +++++---- .../favorites/components/favorite-button.tsx | 6 ++- .../insert/components/insert-menu.tsx | 38 +++++++++++++------ .../features/insert/open-insert-menu.tsx | 10 +++++ src/frontend/features/library/library-path.ts | 12 ++---- src/frontend/lib/style-constants.ts | 7 ++++ .../routes/app/library/$libraryId/index.tsx | 4 +- 7 files changed, 61 insertions(+), 33 deletions(-) diff --git a/src/frontend/components/app-navbar.tsx b/src/frontend/components/app-navbar.tsx index ca096da6b..6513b841c 100644 --- a/src/frontend/components/app-navbar.tsx +++ b/src/frontend/components/app-navbar.tsx @@ -11,7 +11,12 @@ import { Tooltip } from "@mantine/core"; import { Gear, MagnifyingGlass } from "@phosphor-icons/react"; -import { BORDER, IconSize, PrimaryColor } from "../lib/style-constants"; +import { + BORDER, + CHROME_BACKGROUND, + IconSize, + PrimaryColor +} from "../lib/style-constants"; import { ReactNode, RefObject, useRef } from "react"; import { useNavigate } from "@tanstack/react-router"; @@ -33,10 +38,6 @@ import { LibraryId } from "@backend/features/library/library-id"; import { queryClient } from "../lib/query-client"; import { getLibraryVersionQuery } from "../features/library/queries"; -/** A step off the page, so the tab row reads apart from the search row. */ -const TOP_ROW_BACKGROUND = - "light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8))"; - /** * Provides top-level navigation for the app: a row of library tabs with the * brand and settings alongside, over a row holding search and its filters. @@ -51,7 +52,7 @@ export function AppNavbar(): ReactNode { px="sm" wrap="nowrap" align="stretch" - bg={TOP_ROW_BACKGROUND} + bg={CHROME_BACKGROUND} style={{ borderBottom: BORDER }} > <FrcDesignBookIcon /> @@ -210,10 +211,10 @@ export function SettingsButton() { color="gray" title="Settings" my="auto" - size="input-sm" + size="lg" onClick={() => openSettingsMenu()} > - <Gear size={IconSize.CONTROL} /> + <Gear size={IconSize.MEDIUM} /> </ActionIcon> ); } diff --git a/src/frontend/features/favorites/components/favorite-button.tsx b/src/frontend/features/favorites/components/favorite-button.tsx index e952c450e..7d380c950 100644 --- a/src/frontend/features/favorites/components/favorite-button.tsx +++ b/src/frontend/features/favorites/components/favorite-button.tsx @@ -197,8 +197,10 @@ interface HeartIconProps { export function HeartIcon(props: HeartIconProps): ReactNode { const { full = true, size = IconSize.SMALL } = props; + // fz, not size: Box builds its own `style`, dropping the font-size that + // Phosphor's `size` sets, which shrank the icon to 1em. return full ? ( - <Box component={Heart} size={size} c="red" weight="fill" /> + <Box component={Heart} fz={size} c="red" weight="fill" /> ) : ( <Heart size={size} /> ); @@ -213,5 +215,5 @@ interface HeartBrokenIconProps { export function HeartBrokenIcon(props: HeartBrokenIconProps): ReactNode { const { size = IconSize.SMALL } = props; - return <Box component={HeartBreak} size={size} c="red" />; + return <Box component={HeartBreak} fz={size} c="red" />; } diff --git a/src/frontend/features/insert/components/insert-menu.tsx b/src/frontend/features/insert/components/insert-menu.tsx index 6f1076cbc..8af698663 100644 --- a/src/frontend/features/insert/components/insert-menu.tsx +++ b/src/frontend/features/insert/components/insert-menu.tsx @@ -5,7 +5,12 @@ import { InsertableOut } from "@backend/features/library/contract"; import { ElementType } from "@backend/lib/onshape/element-type"; import { Button, Checkbox, Group, Stack, Text } from "@mantine/core"; import { Info, Plus } from "@phosphor-icons/react"; -import { FontWeight, IconSize } from "../../../lib/style-constants"; +import { + BORDER, + CHROME_BACKGROUND, + FontWeight, + IconSize +} from "../../../lib/style-constants"; import { modals } from "@mantine/modals"; import { showQuickInsertTip } from "../quick-insert-tip"; import { useIsFetching } from "@tanstack/react-query"; @@ -124,17 +129,26 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { return ( <> - <PreviewImageCard - path={insertable.path} - insertableId={insertable.id} - microversionId={insertable.microversionId} - largeThumbnailUrl={insertable.largeThumbnailUrl} - canonicalConfiguration={encodeCanonicalConfiguration( - canonicalConfiguration - )} - /> - {parameters} - <Group justify="space-between" wrap="nowrap" mt="md"> + <Stack p="md"> + <PreviewImageCard + path={insertable.path} + insertableId={insertable.id} + microversionId={insertable.microversionId} + largeThumbnailUrl={insertable.largeThumbnailUrl} + canonicalConfiguration={encodeCanonicalConfiguration( + canonicalConfiguration + )} + /> + {parameters} + </Stack> + {/* Chrome, like the header: the body scrolls between them. */} + <Group + justify="space-between" + wrap="nowrap" + p="md" + bg={CHROME_BACKGROUND} + style={{ borderTop: BORDER }} + > <Group gap={4}> <RequireSignIn> <FavoriteButton diff --git a/src/frontend/features/insert/open-insert-menu.tsx b/src/frontend/features/insert/open-insert-menu.tsx index 796f4f4d8..e5fca84ce 100644 --- a/src/frontend/features/insert/open-insert-menu.tsx +++ b/src/frontend/features/insert/open-insert-menu.tsx @@ -1,4 +1,5 @@ import { modals } from "@mantine/modals"; +import { BORDER, CHROME_BACKGROUND } from "../../lib/style-constants"; import type { InsertableOut } from "@backend/features/library/contract"; import { type ParameterValues } from "@backend/features/configurations/models"; @@ -26,6 +27,15 @@ export function openInsertMenu(props: OpenInsertMenuProps) { title: <InsertMenuTitle name={insertable.name} />, size: 500, centered: true, + // The header and footer are chrome around the configuration, so they + // sit on their own surface and the body spans the full width. + styles: { + header: { + background: CHROME_BACKGROUND, + borderBottom: BORDER + }, + body: { padding: 0 } + }, onClose: () => { if (!didInsert) { showRestoreToast(insertable, defaultConfiguration); diff --git a/src/frontend/features/library/library-path.ts b/src/frontend/features/library/library-path.ts index e263ced50..1bbc0812c 100644 --- a/src/frontend/features/library/library-path.ts +++ b/src/frontend/features/library/library-path.ts @@ -58,19 +58,13 @@ export function getLibraryTabLabel(libraryId: string): string { throw new Error("Unknown library: " + libraryId); } -export interface LibraryStatus { - label: string; - /** Mantine color for the badge; how much the label should alarm. */ - color: string; -} - /** Where a library is in its life; undefined once it is simply supported. */ -export function getLibraryStatus(libraryId: string): LibraryStatus | undefined { +export function getLibraryStatus(libraryId: string): string | undefined { switch (libraryId) { case LibraryId.FTC_DESIGN_LIB: - return { label: "Beta", color: "blue" }; + return "Beta"; case LibraryId.MKCAD: - return { label: "Deprecated", color: "red" }; + return "Deprecated"; } return undefined; } diff --git a/src/frontend/lib/style-constants.ts b/src/frontend/lib/style-constants.ts index c84f7ea2c..09ed74b47 100644 --- a/src/frontend/lib/style-constants.ts +++ b/src/frontend/lib/style-constants.ts @@ -28,6 +28,13 @@ export enum FontWeight { export const BORDER = "1px solid var(--mantine-color-default-border)"; +/** + * A step off the page, for the app's chrome: the navbar's tab row and a + * modal's header and footer, which read apart from the content between them. + */ +export const CHROME_BACKGROUND = + "light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8))"; + /** The app's primary color as a filled background. */ export enum PrimaryColor { /** diff --git a/src/frontend/routes/app/library/$libraryId/index.tsx b/src/frontend/routes/app/library/$libraryId/index.tsx index d26623fe7..190ae3e48 100644 --- a/src/frontend/routes/app/library/$libraryId/index.tsx +++ b/src/frontend/routes/app/library/$libraryId/index.tsx @@ -148,8 +148,8 @@ function LibraryTitle({ libraryId }: { libraryId: string }): ReactNode { <Group gap="xs" wrap="nowrap"> {getLibraryName(libraryId)} {status && ( - <Badge size="sm" variant="light" color={status.color}> - {status.label} + <Badge size="sm" variant="light"> + {status} </Badge> )} </Group> From 54b279d1233bd76c1904d3bd453427f32a332ca5 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 22 Aug 2026 05:31:47 +0000 Subject: [PATCH 29/56] Share one modal chrome, and spell library names out in full Extract openAppModal with AppModalBody and AppModalFooter, so a modal's header and footer sit on the same surface with the same tight padding and its body spans the full width between them. The insert, favorite, settings and add-group menus all wear it now; add-group's action moves into the footer alongside the others. Name libraries in full on their tabs, which the tooltips existed to supply, so both the tooltips and the short labels go. Three full names outgrow a panel under about 540px, so the list scrolls rather than reflowing the navbar into two rows. Also drop Mantine's active class, whose 1px translate pushed every button down on click. --- src/frontend/components/app-modal.tsx | 38 ++++++++++++++++ src/frontend/components/app-navbar.tsx | 31 ++++++------- src/frontend/components/open-app-modal.ts | 44 +++++++++++++++++++ .../favorites/components/favorite-menu.tsx | 42 ++++++++++-------- .../features/favorites/open-favorite-menu.tsx | 5 +-- .../insert/components/insert-menu.tsx | 23 +++------- .../features/insert/open-insert-menu.tsx | 14 +----- .../library/components/add-group-menu.tsx | 43 ++++++++++-------- src/frontend/features/library/library-path.ts | 13 ------ .../features/settings/open-settings-menu.tsx | 13 ++++-- src/frontend/lib/style-constants.ts | 3 ++ src/frontend/theme.ts | 5 ++- 12 files changed, 169 insertions(+), 105 deletions(-) create mode 100644 src/frontend/components/app-modal.tsx create mode 100644 src/frontend/components/open-app-modal.ts diff --git a/src/frontend/components/app-modal.tsx b/src/frontend/components/app-modal.tsx new file mode 100644 index 000000000..a81482902 --- /dev/null +++ b/src/frontend/components/app-modal.tsx @@ -0,0 +1,38 @@ +import { Group, type MantineSpacing, Stack } from "@mantine/core"; +import { PropsWithChildren, ReactNode } from "react"; +import { + BORDER, + CHROME_BACKGROUND, + CHROME_PADDING +} from "../lib/style-constants"; + +interface AppModalBodyProps extends PropsWithChildren { + /** Space between children; content that spaces itself should pass 0. */ + gap?: MantineSpacing; +} + +/** A modal's content, padded away from the chrome framing it. */ +export function AppModalBody(props: AppModalBodyProps): ReactNode { + return ( + <Stack p="md" gap={props.gap}> + {props.children} + </Stack> + ); +} + +/** + * A modal's actions, flush against the bottom on the header's surface. Lay + * children out as leading and trailing groups; a lone child sits at the end. + */ +export function AppModalFooter(props: PropsWithChildren): ReactNode { + return ( + <Group + justify="space-between" + wrap="nowrap" + bg={CHROME_BACKGROUND} + style={{ padding: CHROME_PADDING, borderTop: BORDER }} + > + {props.children} + </Group> + ); +} diff --git a/src/frontend/components/app-navbar.tsx b/src/frontend/components/app-navbar.tsx index 6513b841c..58d29b356 100644 --- a/src/frontend/components/app-navbar.tsx +++ b/src/frontend/components/app-navbar.tsx @@ -24,11 +24,7 @@ import frcDesignBook from "/frc-design-book.svg"; import { openSettingsMenu } from "../features/settings/open-settings-menu"; import { VendorMenu } from "../features/settings/components/vendor-filters"; import { useUiState } from "../lib/ui-state"; -import { - getLibraryName, - getLibraryTabLabel, - useLibraryId -} from "../features/library/library-path"; +import { getLibraryName, useLibraryId } from "../features/library/library-path"; import { RequireAccessLevel } from "../features/auth/access-level"; import { useSaveSettings } from "../features/settings/settings"; import { useAccessData } from "../features/auth/access-level"; @@ -182,22 +178,23 @@ function LibraryTabs(): ReactNode { // the row owns one that runs the full width. The active tab's // indicator is colored separately and survives this. root: { "--tab-border-color": "transparent" }, - // Pulled onto that divider, so the active tab's indicator - // replaces it instead of stacking a second line above it. - tab: { marginBottom: -1 } + // Three full names outgrow a narrow panel; scrolling them + // keeps the navbar one row rather than reflowing into two. + list: { flexWrap: "nowrap", overflowX: "auto" }, + tab: { + // Pulled onto that divider, so the active tab's indicator + // replaces it instead of stacking a second line above it. + marginBottom: -1, + paddingInline: "var(--mantine-spacing-sm)", + whiteSpace: "nowrap" + } }} > <Tabs.List aria-label="Libraries"> {Object.values(LibraryId).map((libraryId) => ( - <Tooltip - key={libraryId} - withArrow - label={getLibraryName(libraryId)} - > - <Tabs.Tab value={libraryId}> - {getLibraryTabLabel(libraryId)} - </Tabs.Tab> - </Tooltip> + <Tabs.Tab key={libraryId} value={libraryId}> + {getLibraryName(libraryId)} + </Tabs.Tab> ))} </Tabs.List> </Tabs> diff --git a/src/frontend/components/open-app-modal.ts b/src/frontend/components/open-app-modal.ts new file mode 100644 index 000000000..0b5199fbc --- /dev/null +++ b/src/frontend/components/open-app-modal.ts @@ -0,0 +1,44 @@ +import { modals } from "@mantine/modals"; +import type { ReactNode } from "react"; +import { + BORDER, + CHROME_BACKGROUND, + CHROME_PADDING +} from "../lib/style-constants"; + +interface OpenAppModalProps { + title: ReactNode; + children: ReactNode; + /** Pass one minted by the caller to update the modal while it is open. */ + modalId?: string; + size?: string | number; + onClose?: () => void; +} + +/** + * Opens a modal wearing the app's chrome: a tight header on its own surface + * over an unpadded body, so an `AppModalFooter` can sit flush at the bottom. + * Content belongs in an `AppModalBody`, which supplies the padding instead. + */ +export function openAppModal(props: OpenAppModalProps): void { + const { title, children, modalId, size, onClose } = props; + modals.open({ + modalId, + title, + size, + children, + onClose, + centered: true, + // The default close button is what makes an otherwise tight header tall. + closeButtonProps: { size: "sm" }, + styles: { + header: { + background: CHROME_BACKGROUND, + borderBottom: BORDER, + padding: CHROME_PADDING, + minHeight: 0 + }, + body: { padding: 0 } + } + }); +} diff --git a/src/frontend/features/favorites/components/favorite-menu.tsx b/src/frontend/features/favorites/components/favorite-menu.tsx index 62bd94a28..8ca186afb 100644 --- a/src/frontend/features/favorites/components/favorite-menu.tsx +++ b/src/frontend/features/favorites/components/favorite-menu.tsx @@ -1,4 +1,5 @@ import { modals } from "@mantine/modals"; +import { AppModalBody, AppModalFooter } from "../../../components/app-modal"; import { Button, Group, Stack, Text } from "@mantine/core"; import { FloppyDisk } from "@phosphor-icons/react"; import { FontWeight, IconSize } from "../../../lib/style-constants"; @@ -149,25 +150,28 @@ export function FavoriteMenuContent( return ( <> - <PreviewImageCard - path={insertable.path} - insertableId={insertable.id} - microversionId={insertable.microversionId} - largeThumbnailUrl={insertable.largeThumbnailUrl} - canonicalConfiguration={encodeCanonicalConfiguration( - canonicalConfiguration ?? {} - )} - /> - <ConfigurationWrapper - onCanonicalConfiguration={setCanonicalConfiguration} - onRecord={setRecord} - configuration={configuration} - setConfiguration={setConfiguration} - insertableId={insertable.id} - microversionId={insertable.microversionId} - /> - <Group justify="flex-end" mt="md"> + <AppModalBody> + <PreviewImageCard + path={insertable.path} + insertableId={insertable.id} + microversionId={insertable.microversionId} + largeThumbnailUrl={insertable.largeThumbnailUrl} + canonicalConfiguration={encodeCanonicalConfiguration( + canonicalConfiguration ?? {} + )} + /> + <ConfigurationWrapper + onCanonicalConfiguration={setCanonicalConfiguration} + onRecord={setRecord} + configuration={configuration} + setConfiguration={setConfiguration} + insertableId={insertable.id} + microversionId={insertable.microversionId} + /> + </AppModalBody> + <AppModalFooter> <Button + ml="auto" leftSection={<FloppyDisk size={IconSize.SMALL} />} // Saving before the wrapper reports would store {}, wiping // the favorite's configuration. @@ -179,7 +183,7 @@ export function FavoriteMenuContent( > Save </Button> - </Group> + </AppModalFooter> </> ); } diff --git a/src/frontend/features/favorites/open-favorite-menu.tsx b/src/frontend/features/favorites/open-favorite-menu.tsx index 2c7eae4bb..41d7c9cd4 100644 --- a/src/frontend/features/favorites/open-favorite-menu.tsx +++ b/src/frontend/features/favorites/open-favorite-menu.tsx @@ -1,4 +1,4 @@ -import { modals } from "@mantine/modals"; +import { openAppModal } from "../../components/open-app-modal"; import { type ParameterValues } from "@backend/features/configurations/models"; import { FavoriteMenuContent, @@ -15,11 +15,10 @@ export function openFavoriteMenu(props: OpenFavoriteMenuProps) { const { favoriteId, insertableName, defaultConfiguration } = props; // Minted here so the content can update the header as the selection changes. const modalId = crypto.randomUUID(); - modals.open({ + openAppModal({ modalId, title: <FavoriteMenuTitle name={insertableName} />, size: 500, - centered: true, children: ( <FavoriteMenuContent favoriteId={favoriteId} diff --git a/src/frontend/features/insert/components/insert-menu.tsx b/src/frontend/features/insert/components/insert-menu.tsx index 8af698663..37ce0c6f3 100644 --- a/src/frontend/features/insert/components/insert-menu.tsx +++ b/src/frontend/features/insert/components/insert-menu.tsx @@ -5,12 +5,8 @@ import { InsertableOut } from "@backend/features/library/contract"; import { ElementType } from "@backend/lib/onshape/element-type"; import { Button, Checkbox, Group, Stack, Text } from "@mantine/core"; import { Info, Plus } from "@phosphor-icons/react"; -import { - BORDER, - CHROME_BACKGROUND, - FontWeight, - IconSize -} from "../../../lib/style-constants"; +import { FontWeight, IconSize } from "../../../lib/style-constants"; +import { AppModalBody, AppModalFooter } from "../../../components/app-modal"; import { modals } from "@mantine/modals"; import { showQuickInsertTip } from "../quick-insert-tip"; import { useIsFetching } from "@tanstack/react-query"; @@ -129,7 +125,7 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { return ( <> - <Stack p="md"> + <AppModalBody> <PreviewImageCard path={insertable.path} insertableId={insertable.id} @@ -140,15 +136,8 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { )} /> {parameters} - </Stack> - {/* Chrome, like the header: the body scrolls between them. */} - <Group - justify="space-between" - wrap="nowrap" - p="md" - bg={CHROME_BACKGROUND} - style={{ borderTop: BORDER }} - > + </AppModalBody> + <AppModalFooter> <Group gap={4}> <RequireSignIn> <FavoriteButton @@ -178,7 +167,7 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { isFavorite={favorite !== undefined} onInsert={onInsert} /> - </Group> + </AppModalFooter> </> ); } diff --git a/src/frontend/features/insert/open-insert-menu.tsx b/src/frontend/features/insert/open-insert-menu.tsx index e5fca84ce..a93a9b01d 100644 --- a/src/frontend/features/insert/open-insert-menu.tsx +++ b/src/frontend/features/insert/open-insert-menu.tsx @@ -1,5 +1,5 @@ import { modals } from "@mantine/modals"; -import { BORDER, CHROME_BACKGROUND } from "../../lib/style-constants"; +import { openAppModal } from "../../components/open-app-modal"; import type { InsertableOut } from "@backend/features/library/contract"; import { type ParameterValues } from "@backend/features/configurations/models"; @@ -22,20 +22,10 @@ export function openInsertMenu(props: OpenInsertMenuProps) { // Minted here so the content can address the modal it lives in, which is // what lets the header follow the selected configuration. const id = crypto.randomUUID(); - modals.open({ + openAppModal({ modalId: id, title: <InsertMenuTitle name={insertable.name} />, size: 500, - centered: true, - // The header and footer are chrome around the configuration, so they - // sit on their own surface and the body spans the full width. - styles: { - header: { - background: CHROME_BACKGROUND, - borderBottom: BORDER - }, - body: { padding: 0 } - }, onClose: () => { if (!didInsert) { showRestoreToast(insertable, defaultConfiguration); diff --git a/src/frontend/features/library/components/add-group-menu.tsx b/src/frontend/features/library/components/add-group-menu.tsx index b9d5d7356..10a89fe7c 100644 --- a/src/frontend/features/library/components/add-group-menu.tsx +++ b/src/frontend/features/library/components/add-group-menu.tsx @@ -1,5 +1,7 @@ -import { Button, Group, Menu, TextInput } from "@mantine/core"; +import { Button, Menu, TextInput } from "@mantine/core"; import { modals } from "@mantine/modals"; +import { openAppModal } from "../../../components/open-app-modal"; +import { AppModalBody, AppModalFooter } from "../../../components/app-modal"; import { Plus } from "@phosphor-icons/react"; import { IconSize } from "../../../lib/style-constants"; import { ReactNode, useState } from "react"; @@ -14,9 +16,8 @@ import { jobStatusQueryKey } from "../../../lib/query-keys"; import type { JobStatus } from "@backend/features/load/contract"; function openAddGroupMenu(selectedGroupId?: string) { - modals.open({ + openAppModal({ title: "Add group", - centered: true, children: <AddGroupMenuContent selectedGroupId={selectedGroupId} /> }); } @@ -60,22 +61,26 @@ function AddGroupMenuContent(props: AddGroupMenuContentProps): ReactNode { }); return ( - <Group align="flex-end" gap="sm" wrap="nowrap"> - <TextInput - flex={1} - placeholder="Document url..." - value={url} - onChange={(event) => setUrl(event.currentTarget.value)} - error={mutation.isError} - /> - <Button - leftSection={<Plus size={IconSize.SMALL} />} - onClick={() => mutation.mutate()} - loading={mutation.isPending} - > - Add - </Button> - </Group> + <> + <AppModalBody> + <TextInput + placeholder="Document url..." + value={url} + onChange={(event) => setUrl(event.currentTarget.value)} + error={mutation.isError} + /> + </AppModalBody> + <AppModalFooter> + <Button + ml="auto" + leftSection={<Plus size={IconSize.SMALL} />} + onClick={() => mutation.mutate()} + loading={mutation.isPending} + > + Add + </Button> + </AppModalFooter> + </> ); } diff --git a/src/frontend/features/library/library-path.ts b/src/frontend/features/library/library-path.ts index 1bbc0812c..ee4082d19 100644 --- a/src/frontend/features/library/library-path.ts +++ b/src/frontend/features/library/library-path.ts @@ -45,19 +45,6 @@ export function getLibraryName(libraryId: string): string { throw new Error("Unknown library: " + libraryId); } -/** The label on a library's tab, where there is only room for a few characters. */ -export function getLibraryTabLabel(libraryId: string): string { - switch (libraryId) { - case LibraryId.FRC_DESIGN_LIB: - return "FRC"; - case LibraryId.FTC_DESIGN_LIB: - return "FTC"; - case LibraryId.MKCAD: - return "MKCad"; - } - throw new Error("Unknown library: " + libraryId); -} - /** Where a library is in its life; undefined once it is simply supported. */ export function getLibraryStatus(libraryId: string): string | undefined { switch (libraryId) { diff --git a/src/frontend/features/settings/open-settings-menu.tsx b/src/frontend/features/settings/open-settings-menu.tsx index 0cec234a6..b4fad6a49 100644 --- a/src/frontend/features/settings/open-settings-menu.tsx +++ b/src/frontend/features/settings/open-settings-menu.tsx @@ -1,4 +1,5 @@ -import { modals } from "@mantine/modals"; +import { openAppModal } from "../../components/open-app-modal"; +import { AppModalBody } from "../../components/app-modal"; import { SettingsMenuContent } from "./components/settings-menu"; /** @@ -6,9 +7,13 @@ import { SettingsMenuContent } from "./components/settings-menu"; * is what lets React Refresh swap it in place instead of reloading its callers. */ export function openSettingsMenu() { - modals.open({ + openAppModal({ title: "Settings", - centered: true, - children: <SettingsMenuContent /> + children: ( + // The setting rows carry their own spacing. + <AppModalBody gap={0}> + <SettingsMenuContent /> + </AppModalBody> + ) }); } diff --git a/src/frontend/lib/style-constants.ts b/src/frontend/lib/style-constants.ts index 09ed74b47..9d6f6a565 100644 --- a/src/frontend/lib/style-constants.ts +++ b/src/frontend/lib/style-constants.ts @@ -35,6 +35,9 @@ export const BORDER = "1px solid var(--mantine-color-default-border)"; export const CHROME_BACKGROUND = "light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8))"; +/** Padding for a modal's header and footer, tighter than the body they frame. */ +export const CHROME_PADDING = "6px var(--mantine-spacing-md)"; + /** The app's primary color as a filled background. */ export enum PrimaryColor { /** diff --git a/src/frontend/theme.ts b/src/frontend/theme.ts index a06eead14..4ab275b1f 100644 --- a/src/frontend/theme.ts +++ b/src/frontend/theme.ts @@ -39,6 +39,9 @@ export function createAppTheme(libraryId: string) { colors: { frcGreen }, primaryColor: getLibraryColor(libraryId), autoContrast: true, - cursorType: "pointer" + cursorType: "pointer", + // Drops the class carrying Mantine's 1px press-down translate, which + // nudged every button and icon button down on click. + activeClassName: "" }); } From 998526b6455d6a72e68d6b7f8dfc593f6f9c5b6b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 22 Aug 2026 05:42:48 +0000 Subject: [PATCH 30/56] Square the corners off and tighten the modal card Drop the default radius from Mantine's md to sm, so buttons, inputs and cards read crisper in a dense panel. Draw a border around a modal's card and bring the body's padding in to match the header and footer, which now share one inset: the preview card lines up with the favorite below it and the title above. Also hide the tab list's scrollbar, which reserved a 1px track in the top bar even with nothing to scroll. --- src/frontend/components/app-modal.tsx | 2 +- src/frontend/components/app-navbar.tsx | 6 +++++- src/frontend/components/open-app-modal.ts | 3 +++ src/frontend/lib/style-constants.ts | 2 +- src/frontend/theme.ts | 2 ++ 5 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/frontend/components/app-modal.tsx b/src/frontend/components/app-modal.tsx index a81482902..d724dba97 100644 --- a/src/frontend/components/app-modal.tsx +++ b/src/frontend/components/app-modal.tsx @@ -14,7 +14,7 @@ interface AppModalBodyProps extends PropsWithChildren { /** A modal's content, padded away from the chrome framing it. */ export function AppModalBody(props: AppModalBodyProps): ReactNode { return ( - <Stack p="md" gap={props.gap}> + <Stack p="sm" gap={props.gap}> {props.children} </Stack> ); diff --git a/src/frontend/components/app-navbar.tsx b/src/frontend/components/app-navbar.tsx index 58d29b356..64faf623d 100644 --- a/src/frontend/components/app-navbar.tsx +++ b/src/frontend/components/app-navbar.tsx @@ -180,7 +180,11 @@ function LibraryTabs(): ReactNode { root: { "--tab-border-color": "transparent" }, // Three full names outgrow a narrow panel; scrolling them // keeps the navbar one row rather than reflowing into two. - list: { flexWrap: "nowrap", overflowX: "auto" }, + list: { + flexWrap: "nowrap", + overflowX: "auto", + scrollbarWidth: "none" + }, tab: { // Pulled onto that divider, so the active tab's indicator // replaces it instead of stacking a second line above it. diff --git a/src/frontend/components/open-app-modal.ts b/src/frontend/components/open-app-modal.ts index 0b5199fbc..309d942a1 100644 --- a/src/frontend/components/open-app-modal.ts +++ b/src/frontend/components/open-app-modal.ts @@ -32,6 +32,9 @@ export function openAppModal(props: OpenAppModalProps): void { // The default close button is what makes an otherwise tight header tall. closeButtonProps: { size: "sm" }, styles: { + // Drawn, not just shadowed, so the card reads as one panel against + // the app behind it. + content: { border: BORDER }, header: { background: CHROME_BACKGROUND, borderBottom: BORDER, diff --git a/src/frontend/lib/style-constants.ts b/src/frontend/lib/style-constants.ts index 9d6f6a565..da45c3b42 100644 --- a/src/frontend/lib/style-constants.ts +++ b/src/frontend/lib/style-constants.ts @@ -36,7 +36,7 @@ export const CHROME_BACKGROUND = "light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8))"; /** Padding for a modal's header and footer, tighter than the body they frame. */ -export const CHROME_PADDING = "6px var(--mantine-spacing-md)"; +export const CHROME_PADDING = "6px var(--mantine-spacing-sm)"; /** The app's primary color as a filled background. */ export enum PrimaryColor { diff --git a/src/frontend/theme.ts b/src/frontend/theme.ts index 4ab275b1f..33f7149ed 100644 --- a/src/frontend/theme.ts +++ b/src/frontend/theme.ts @@ -39,6 +39,8 @@ export function createAppTheme(libraryId: string) { colors: { frcGreen }, primaryColor: getLibraryColor(libraryId), autoContrast: true, + // Mantine's "md" default reads soft for a dense CAD panel. + defaultRadius: "sm", cursorType: "pointer", // Drops the class carrying Mantine's 1px press-down translate, which // nudged every button and icon button down on click. From 7ec1689f692e7188c43fd6ea665b94d7b0b24e13 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 22 Aug 2026 08:32:27 +0000 Subject: [PATCH 31/56] Stop the preview card double-insetting itself The card carried its own margin from before the modal body had padding, so the two stacked and pushed the preview 25px off the modal's edge. Drop the margin and tighten the card's own padding, leaving the preview inset once, in line with the header and footer. --- src/frontend/features/thumbnails/components/thumbnail.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/frontend/features/thumbnails/components/thumbnail.tsx b/src/frontend/features/thumbnails/components/thumbnail.tsx index 49501c1f9..f5c27b684 100644 --- a/src/frontend/features/thumbnails/components/thumbnail.tsx +++ b/src/frontend/features/thumbnails/components/thumbnail.tsx @@ -148,7 +148,9 @@ function Thumbnail(props: ThumbnailProps): ReactNode { export function PreviewImageCard(props: PreviewImageProps): ReactNode { return ( - <Card withBorder pos="relative" m="sm" mb={0}> + // No margin: the modal body it sits in supplies the inset, and the + // padding stays tight so the preview is not lost inside its frame. + <Card withBorder pos="relative" p="xs"> <Center> <PreviewImage {...props} /> </Center> From 32fff235a33537f2d156220a1be69fd4bc2f21a4 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 22 Aug 2026 12:20:14 +0000 Subject: [PATCH 32/56] Give every icon-and-text heading one component AppTitle fixes the weight, size, gap and truncation for a heading, with slots for a leading icon, a quieter second line, and trailing content. MenuTitle builds on it for the modal headers, replacing the near-identical title each of the insert and favorite menus carried. Apply it to the headings that had each drifted their own way: accordion sections, which had no weight of their own, the group header, which set its size and weight inline, and the warning alerts, which now lead with an icon like everything else. The favorites accordion icon also grows to the size the two beside it already used. --- src/frontend/components/alerts.tsx | 14 +++- src/frontend/components/app-title.tsx | 67 +++++++++++++++++++ .../favorites/components/favorite-menu.tsx | 41 +++--------- .../features/favorites/open-favorite-menu.tsx | 15 +++-- .../insert/components/insert-menu.tsx | 31 ++------- .../features/insert/open-insert-menu.tsx | 5 +- .../library/$libraryId/groups/$groupId.tsx | 19 ++---- .../routes/app/library/$libraryId/index.tsx | 27 ++++---- 8 files changed, 128 insertions(+), 91 deletions(-) create mode 100644 src/frontend/components/app-title.tsx diff --git a/src/frontend/components/alerts.tsx b/src/frontend/components/alerts.tsx index c1dfa72e8..71d356211 100644 --- a/src/frontend/components/alerts.tsx +++ b/src/frontend/components/alerts.tsx @@ -1,5 +1,8 @@ import { modals } from "@mantine/modals"; -import { Text } from "@mantine/core"; +import { Box, Text } from "@mantine/core"; +import { Warning } from "@phosphor-icons/react"; +import { AppTitle } from "./app-title"; +import { IconSize } from "../lib/style-constants"; interface OpenWarningAlertProps { title: string; @@ -8,7 +11,14 @@ interface OpenWarningAlertProps { function openWarningAlert(props: OpenWarningAlertProps): void { modals.openConfirmModal({ - title: props.title, + title: ( + <AppTitle + icon={ + <Box component={Warning} fz={IconSize.MEDIUM} c="yellow" /> + } + title={props.title} + /> + ), children: <Text size="sm">{props.text}</Text>, labels: { confirm: "Close", cancel: null }, centered: true, diff --git a/src/frontend/components/app-title.tsx b/src/frontend/components/app-title.tsx new file mode 100644 index 000000000..39fe8ba5f --- /dev/null +++ b/src/frontend/components/app-title.tsx @@ -0,0 +1,67 @@ +import { Group, Stack, Text } from "@mantine/core"; +import type { ReactNode } from "react"; +import type { SearchRecord } from "@backend/features/configurations/models"; +import { FontWeight } from "../lib/style-constants"; + +interface AppTitleProps { + title: ReactNode; + /** Leading icon, at `IconSize.MEDIUM` to match the title's size. */ + icon?: ReactNode; + /** A quieter second line, e.g. what the current configuration resolves to. */ + subtitle?: ReactNode; + /** Trailing content on the title's own line, e.g. a status badge. */ + rightSection?: ReactNode; +} + +/** + * The app's heading: one weight and size for every icon-and-text title, so a + * modal header, an accordion section, and a group header all read alike. + */ +export function AppTitle(props: AppTitleProps): ReactNode { + const { title, icon, subtitle, rightSection } = props; + return ( + <Group gap="sm" wrap="nowrap" miw={0}> + {icon} + <Stack gap={0} miw={0}> + <Group gap="xs" wrap="nowrap" miw={0}> + <Text fw={FontWeight.SEMI_BOLD} truncate> + {title} + </Text> + {rightSection} + </Group> + {subtitle && ( + <Text size="xs" c="dimmed" truncate> + {subtitle} + </Text> + )} + </Stack> + </Group> + ); +} + +interface MenuTitleProps { + name: string; + /** The configuration in view, which names the part the element resolves to. */ + record?: SearchRecord; + icon?: ReactNode; +} + +/** + * A menu's header. Both names are shown: the element is how the part was found, + * the record is what actually gets inserted. + */ +export function MenuTitle(props: MenuTitleProps): ReactNode { + const { name, record, icon } = props; + const details = record + ? [record.partNumber, record.name].filter( + (value): value is string => !!value && value !== name + ) + : []; + return ( + <AppTitle + icon={icon} + title={name} + subtitle={details.length > 0 ? details.join(" · ") : undefined} + /> + ); +} diff --git a/src/frontend/features/favorites/components/favorite-menu.tsx b/src/frontend/features/favorites/components/favorite-menu.tsx index 8ca186afb..5fea71c45 100644 --- a/src/frontend/features/favorites/components/favorite-menu.tsx +++ b/src/frontend/features/favorites/components/favorite-menu.tsx @@ -1,8 +1,9 @@ import { modals } from "@mantine/modals"; import { AppModalBody, AppModalFooter } from "../../../components/app-modal"; -import { Button, Group, Stack, Text } from "@mantine/core"; +import { MenuTitle } from "../../../components/app-title"; +import { Button } from "@mantine/core"; import { FloppyDisk } from "@phosphor-icons/react"; -import { FontWeight, IconSize } from "../../../lib/style-constants"; +import { IconSize } from "../../../lib/style-constants"; import { ReactNode, useEffect, useState } from "react"; import { useRouter } from "@tanstack/react-router"; import { useMutation } from "@tanstack/react-query"; @@ -26,34 +27,6 @@ import { toFavoritePath, useLibraryId } from "../../library/library-path"; import { useRefreshFavorites } from "../../../lib/refresh"; import { PageError } from "../../../components/app-zero-state"; -/** The element's name, and what the saved configuration produces beneath it. */ -export function FavoriteMenuTitle({ - name, - record -}: { - name: string; - record?: SearchRecord; -}): ReactNode { - const details = record - ? [record.partNumber, record.name].filter( - (value): value is string => !!value && value !== name - ) - : []; - return ( - <Group gap="xs" wrap="nowrap"> - <HeartIcon /> - <Stack gap={0}> - <Text fw={FontWeight.SEMI_BOLD}>{name}</Text> - {details.length > 0 && ( - <Text size="xs" c="dimmed"> - {details.join(" · ")} - </Text> - )} - </Stack> - </Group> - ); -} - interface FavoriteMenuContentProps { favoriteId: string; /** The modal this renders in, so the header can track the selection. */ @@ -95,7 +68,13 @@ export function FavoriteMenuContent( } modals.updateModal({ modalId, - title: <FavoriteMenuTitle name={insertableName} record={record} /> + title: ( + <MenuTitle + name={insertableName} + record={record} + icon={<HeartIcon size={IconSize.MEDIUM} />} + /> + ) }); }, [modalId, insertableName, record]); diff --git a/src/frontend/features/favorites/open-favorite-menu.tsx b/src/frontend/features/favorites/open-favorite-menu.tsx index 41d7c9cd4..13e998e17 100644 --- a/src/frontend/features/favorites/open-favorite-menu.tsx +++ b/src/frontend/features/favorites/open-favorite-menu.tsx @@ -1,9 +1,9 @@ import { openAppModal } from "../../components/open-app-modal"; import { type ParameterValues } from "@backend/features/configurations/models"; -import { - FavoriteMenuContent, - FavoriteMenuTitle -} from "./components/favorite-menu"; +import { FavoriteMenuContent } from "./components/favorite-menu"; +import { MenuTitle } from "../../components/app-title"; +import { HeartIcon } from "./components/favorite-button"; +import { IconSize } from "../../lib/style-constants"; interface OpenFavoriteMenuProps { favoriteId: string; @@ -17,7 +17,12 @@ export function openFavoriteMenu(props: OpenFavoriteMenuProps) { const modalId = crypto.randomUUID(); openAppModal({ modalId, - title: <FavoriteMenuTitle name={insertableName} />, + title: ( + <MenuTitle + name={insertableName} + icon={<HeartIcon size={IconSize.MEDIUM} />} + /> + ), size: 500, children: ( <FavoriteMenuContent diff --git a/src/frontend/features/insert/components/insert-menu.tsx b/src/frontend/features/insert/components/insert-menu.tsx index 37ce0c6f3..1bc7ca2a5 100644 --- a/src/frontend/features/insert/components/insert-menu.tsx +++ b/src/frontend/features/insert/components/insert-menu.tsx @@ -3,10 +3,11 @@ import { ReactNode, useCallback, useEffect, useState } from "react"; import { getFavoriteForInsertable } from "@backend/features/favorites/contract"; import { InsertableOut } from "@backend/features/library/contract"; import { ElementType } from "@backend/lib/onshape/element-type"; -import { Button, Checkbox, Group, Stack, Text } from "@mantine/core"; +import { Button, Checkbox, Group } from "@mantine/core"; import { Info, Plus } from "@phosphor-icons/react"; -import { FontWeight, IconSize } from "../../../lib/style-constants"; +import { IconSize } from "../../../lib/style-constants"; import { AppModalBody, AppModalFooter } from "../../../components/app-modal"; +import { MenuTitle } from "../../../components/app-title"; import { modals } from "@mantine/modals"; import { showQuickInsertTip } from "../quick-insert-tip"; import { useIsFetching } from "@tanstack/react-query"; @@ -30,30 +31,6 @@ import { RequireSignIn, useIsSignedIn } from "../../auth/access-level"; import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; import { startSignIn } from "../../auth/sign-in"; -export function InsertMenuTitle({ - name, - record -}: { - name: string; - record?: SearchRecord; -}): ReactNode { - const details = record - ? [record.partNumber, record.name].filter( - (value): value is string => !!value && value !== name - ) - : []; - return ( - <Stack gap={0}> - <Text fw={FontWeight.SEMI_BOLD}>{name}</Text> - {details.length > 0 && ( - <Text size="xs" c="dimmed"> - {details.join(" · ")} - </Text> - )} - </Stack> - ); -} - interface InsertMenuContentProps { insertable: InsertableOut; /** The modal this renders in, so the header can track the selection. */ @@ -93,7 +70,7 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { useEffect(() => { modals.updateModal({ modalId, - title: <InsertMenuTitle name={insertable.name} record={record} /> + title: <MenuTitle name={insertable.name} record={record} /> }); }, [modalId, insertable.name, record]); diff --git a/src/frontend/features/insert/open-insert-menu.tsx b/src/frontend/features/insert/open-insert-menu.tsx index a93a9b01d..dde5b50e7 100644 --- a/src/frontend/features/insert/open-insert-menu.tsx +++ b/src/frontend/features/insert/open-insert-menu.tsx @@ -9,7 +9,8 @@ import { renderNotification, showInfoToast } from "../../lib/notifications"; -import { InsertMenuContent, InsertMenuTitle } from "./components/insert-menu"; +import { InsertMenuContent } from "./components/insert-menu"; +import { MenuTitle } from "../../components/app-title"; interface OpenInsertMenuProps { insertable: InsertableOut; @@ -24,7 +25,7 @@ export function openInsertMenu(props: OpenInsertMenuProps) { const id = crypto.randomUUID(); openAppModal({ modalId: id, - title: <InsertMenuTitle name={insertable.name} />, + title: <MenuTitle name={insertable.name} />, size: 500, onClose: () => { if (!didInsert) { diff --git a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx index 29fe564b5..4faf60ee7 100644 --- a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx +++ b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx @@ -1,17 +1,14 @@ import { useAccessData } from "../../../../../features/auth/access-level"; +import { AppTitle } from "../../../../../components/app-title"; import { createFileRoute, Outlet, useNavigate, useParams } from "@tanstack/react-router"; -import { Box, Button, Group, Text } from "@mantine/core"; +import { Box, Button, Group } from "@mantine/core"; import { ArrowLeft, ArrowUUpLeft, Warning } from "@phosphor-icons/react"; -import { - BORDER, - FontWeight, - IconSize -} from "../../../../../lib/style-constants"; +import { BORDER, IconSize } from "../../../../../lib/style-constants"; import { ReactNode } from "react"; import { SearchResults } from "../../../../../features/search/components/search-results"; import { GroupOut, Insertables } from "@backend/features/library/contract"; @@ -131,12 +128,10 @@ function GroupHeaderRow({ group }: { group: GroupOut }): ReactNode { p="sm" > <Group wrap="nowrap" justify="space-between"> - <Group gap="sm"> - <ArrowLeft size={IconSize.MEDIUM} /> - <Text size="md" fw={FontWeight.SEMI_BOLD} truncate> - {group.name} - </Text> - </Group> + <AppTitle + icon={<ArrowLeft size={IconSize.MEDIUM} />} + title={group.name} + /> <MenuButton>{menuItems}</MenuButton> </Group> </Box> diff --git a/src/frontend/routes/app/library/$libraryId/index.tsx b/src/frontend/routes/app/library/$libraryId/index.tsx index 190ae3e48..503baa70d 100644 --- a/src/frontend/routes/app/library/$libraryId/index.tsx +++ b/src/frontend/routes/app/library/$libraryId/index.tsx @@ -1,5 +1,6 @@ import { createFileRoute, Outlet } from "@tanstack/react-router"; -import { Accordion, Badge, Group } from "@mantine/core"; +import { Accordion, Badge } from "@mantine/core"; +import { AppTitle } from "../../../../components/app-title"; import { Books, MagnifyingGlass } from "@phosphor-icons/react"; import { BORDER, @@ -57,8 +58,8 @@ function HomeList(): ReactNode { if (isSignedIn) { sections.push({ value: "favorites", - icon: <HeartIcon />, - title: "Favorites", + icon: <HeartIcon size={IconSize.MEDIUM} />, + title: <AppTitle title="Favorites" />, panel: <FavoritesList />, opened: uiState.isFavoritesOpen, setOpened: (opened) => setUiState({ isFavoritesOpen: opened }) @@ -77,7 +78,7 @@ function HomeList(): ReactNode { color={PrimaryColor.FILLED} /> ), - title: "Search Results", + title: <AppTitle title="Search Results" />, panel: ( <SearchResults query={uiState.searchQuery} @@ -145,14 +146,16 @@ function HomeList(): ReactNode { function LibraryTitle({ libraryId }: { libraryId: string }): ReactNode { const status = getLibraryStatus(libraryId); return ( - <Group gap="xs" wrap="nowrap"> - {getLibraryName(libraryId)} - {status && ( - <Badge size="sm" variant="light"> - {status} - </Badge> - )} - </Group> + <AppTitle + title={getLibraryName(libraryId)} + rightSection={ + status && ( + <Badge size="sm" variant="light"> + {status} + </Badge> + ) + } + /> ); } From aa65ab8af8063b31f4fc4811f27859015c6d97bc Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 22 Aug 2026 14:40:33 +0000 Subject: [PATCH 33/56] Sit title icons on the text's optical centre The boxes were already centred to the pixel; what reads as misalignment is that text centres on its line box while the eye centres it on the cap height, about a pixel higher, leaving the icon low. CSS text-box would trim the line box to the caps and fix this outright, but it clips descenders under the overflow that truncation needs, and it makes row heights depend on their content. So title icons take the pixel back with a transform instead, which leaves layout untouched, from one constant used by AppTitle and the accordion's own icon slot. --- src/frontend/components/app-title.tsx | 6 +++--- src/frontend/lib/style-constants.ts | 8 ++++++++ src/frontend/routes/app/library/$libraryId/index.tsx | 6 ++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/frontend/components/app-title.tsx b/src/frontend/components/app-title.tsx index 39fe8ba5f..c6af675e8 100644 --- a/src/frontend/components/app-title.tsx +++ b/src/frontend/components/app-title.tsx @@ -1,7 +1,7 @@ -import { Group, Stack, Text } from "@mantine/core"; +import { Box, Group, Stack, Text } from "@mantine/core"; import type { ReactNode } from "react"; import type { SearchRecord } from "@backend/features/configurations/models"; -import { FontWeight } from "../lib/style-constants"; +import { FontWeight, TITLE_ICON_NUDGE } from "../lib/style-constants"; interface AppTitleProps { title: ReactNode; @@ -21,7 +21,7 @@ export function AppTitle(props: AppTitleProps): ReactNode { const { title, icon, subtitle, rightSection } = props; return ( <Group gap="sm" wrap="nowrap" miw={0}> - {icon} + {icon && <Box style={TITLE_ICON_NUDGE}>{icon}</Box>} <Stack gap={0} miw={0}> <Group gap="xs" wrap="nowrap" miw={0}> <Text fw={FontWeight.SEMI_BOLD} truncate> diff --git a/src/frontend/lib/style-constants.ts b/src/frontend/lib/style-constants.ts index da45c3b42..237fe4c05 100644 --- a/src/frontend/lib/style-constants.ts +++ b/src/frontend/lib/style-constants.ts @@ -35,6 +35,14 @@ export const BORDER = "1px solid var(--mantine-color-default-border)"; export const CHROME_BACKGROUND = "light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8))"; +/** + * An icon centres on the text's line box, but text reads as centred on its cap + * height, which sits about a pixel higher — so a centred icon looks low. CSS + * `text-box` would trim the box properly, but it clips descenders under the + * truncation titles need, so title icons take the pixel back by hand. + */ +export const TITLE_ICON_NUDGE = { transform: "translateY(-1px)" }; + /** Padding for a modal's header and footer, tighter than the body they frame. */ export const CHROME_PADDING = "6px var(--mantine-spacing-sm)"; diff --git a/src/frontend/routes/app/library/$libraryId/index.tsx b/src/frontend/routes/app/library/$libraryId/index.tsx index 503baa70d..3f16d5022 100644 --- a/src/frontend/routes/app/library/$libraryId/index.tsx +++ b/src/frontend/routes/app/library/$libraryId/index.tsx @@ -5,7 +5,8 @@ import { Books, MagnifyingGlass } from "@phosphor-icons/react"; import { BORDER, IconSize, - PrimaryColor + PrimaryColor, + TITLE_ICON_NUDGE } from "../../../../lib/style-constants"; import { ReactNode, useState } from "react"; import { GroupCard } from "../../../../features/library/components/group-card"; @@ -122,7 +123,8 @@ function HomeList(): ReactNode { // On the control, so a collapsed section still divides from // the next one; content closes off an open one. control: { borderBottom: BORDER }, - content: { padding: 0, borderBottom: BORDER } + content: { padding: 0, borderBottom: BORDER }, + icon: TITLE_ICON_NUDGE }} > {sections.map((section) => ( From 416c385de4231c23687113acda764eb4d8030fc3 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 22 Aug 2026 20:45:55 +0000 Subject: [PATCH 34/56] Balance the modal chrome, and stop refetching job status per tab Lead a part's details with its name rather than its number, in both the menu titles and the search cards. Even up the insert button, whose label side was half again as wide as its icon side, and use one gap inside a modal body so the preview sits as far from the parameters as they sit from the footer. In light mode the chrome moves from gray-0 to gray-2, which was too close to white to read as a separate surface. Cache job status privately for two seconds, so switching libraries and back does not refetch it, and widen the query's reuse window to cover a remount. Two seconds stays under the poll's three, so a poll is never answered with what it already had. Modals now take focus on a sentinel rather than the close button, which had been left circled whenever one opened. Focus still enters the dialog, so Tab stays scoped to it. --- .../features/library/groups/routes.test.ts | 5 +++-- src/backend/features/library/groups/routes.ts | 10 ++++++++-- src/backend/lib/cache.ts | 19 +++++++++++++++++++ src/frontend/components/alerts.tsx | 6 +++++- src/frontend/components/app-modal.tsx | 2 +- src/frontend/components/app-title.tsx | 2 +- .../{open-app-modal.ts => open-app-modal.tsx} | 9 ++++++++- .../insert/components/insert-menu.tsx | 3 +++ .../library/components/card-components.tsx | 4 ++-- src/frontend/features/library/queries.ts | 9 ++++++--- src/frontend/lib/style-constants.ts | 2 +- 11 files changed, 57 insertions(+), 14 deletions(-) rename src/frontend/components/{open-app-modal.ts => open-app-modal.tsx} (80%) diff --git a/src/backend/features/library/groups/routes.test.ts b/src/backend/features/library/groups/routes.test.ts index 92045855b..beea75fa4 100644 --- a/src/backend/features/library/groups/routes.test.ts +++ b/src/backend/features/library/groups/routes.test.ts @@ -231,8 +231,9 @@ describe("GET /job-status", () => { ); expect(res.status).toBe(200); expect(await res.json()).toEqual(status); - // Polled for live state, so it must never be served from a cache. - expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + // Cached briefly so revisiting a library costs nothing, but under the + // poll's interval so a poll is never answered from the cache. + expect(res.headers.get("Cache-Control")).toBe("private, max-age=2"); }); }); diff --git a/src/backend/features/library/groups/routes.ts b/src/backend/features/library/groups/routes.ts index 6c44cb158..b333be736 100644 --- a/src/backend/features/library/groups/routes.ts +++ b/src/backend/features/library/groups/routes.ts @@ -1,5 +1,5 @@ import { and, eq, inArray } from "drizzle-orm"; -import { cacheMiddleware } from "../../../lib/cache"; +import { shortCacheMiddleware } from "../../../lib/cache"; import { getApp } from "../../../lib/context"; import { getLibraryParam, libraryRoute } from "../../../lib/route-params"; import { getDb } from "../../../db/client"; @@ -77,11 +77,17 @@ groupRoutes.post( } ); +/** + * The poll's fastest interval, which the cache window has to stay under so a + * poll is never answered from the cache with what it already had. + */ +const JOB_STATUS_CACHE_SECONDS = 2; + /** GET /api/job-status/library/:libraryId — checked on load, then polled. */ groupRoutes.get( "/job-status" + libraryRoute(), requireEditorMiddleware, - cacheMiddleware(), + shortCacheMiddleware(JOB_STATUS_CACHE_SECONDS), async (c) => { return c.json(await getJobStatus(c.env, getLibraryParam(c))); } diff --git a/src/backend/lib/cache.ts b/src/backend/lib/cache.ts index bc7c025e9..dcbe6cb1a 100644 --- a/src/backend/lib/cache.ts +++ b/src/backend/lib/cache.ts @@ -23,6 +23,25 @@ export function immutableCacheControl( return `${policy}, max-age=${IMMUTABLE_CACHE_TTL}, immutable`; } +/** + * A short private cache for a body that goes stale on time rather than by url. + * Keep the window under the caller's poll interval, or a poll is served the + * answer it already has. + */ +export function shortCacheMiddleware( + maxAge: number +): MiddlewareHandler<AppContextEnv> { + return async (c, next) => { + await next(); + c.header( + "Cache-Control", + c.res.ok + ? `${CachePolicy.PRIVATE_CACHE}, max-age=${maxAge}` + : NO_STORE + ); + }; +} + /** Overrides the route's immutable default for a body its url does not pin. */ export function setCacheTtl(c: AppContext, maxAge: number): void { c.set("cacheTtl", maxAge); diff --git a/src/frontend/components/alerts.tsx b/src/frontend/components/alerts.tsx index 71d356211..6f46118d6 100644 --- a/src/frontend/components/alerts.tsx +++ b/src/frontend/components/alerts.tsx @@ -19,7 +19,11 @@ function openWarningAlert(props: OpenWarningAlertProps): void { title={props.title} /> ), - children: <Text size="sm">{props.text}</Text>, + children: ( + <Text data-autofocus tabIndex={-1} size="sm"> + {props.text} + </Text> + ), labels: { confirm: "Close", cancel: null }, centered: true, cancelProps: { display: "none" }, diff --git a/src/frontend/components/app-modal.tsx b/src/frontend/components/app-modal.tsx index d724dba97..2e1641a2e 100644 --- a/src/frontend/components/app-modal.tsx +++ b/src/frontend/components/app-modal.tsx @@ -14,7 +14,7 @@ interface AppModalBodyProps extends PropsWithChildren { /** A modal's content, padded away from the chrome framing it. */ export function AppModalBody(props: AppModalBodyProps): ReactNode { return ( - <Stack p="sm" gap={props.gap}> + <Stack p="sm" gap={props.gap ?? "sm"}> {props.children} </Stack> ); diff --git a/src/frontend/components/app-title.tsx b/src/frontend/components/app-title.tsx index c6af675e8..f5b6e3858 100644 --- a/src/frontend/components/app-title.tsx +++ b/src/frontend/components/app-title.tsx @@ -53,7 +53,7 @@ interface MenuTitleProps { export function MenuTitle(props: MenuTitleProps): ReactNode { const { name, record, icon } = props; const details = record - ? [record.partNumber, record.name].filter( + ? [record.name, record.partNumber].filter( (value): value is string => !!value && value !== name ) : []; diff --git a/src/frontend/components/open-app-modal.ts b/src/frontend/components/open-app-modal.tsx similarity index 80% rename from src/frontend/components/open-app-modal.ts rename to src/frontend/components/open-app-modal.tsx index 309d942a1..12e0eac30 100644 --- a/src/frontend/components/open-app-modal.ts +++ b/src/frontend/components/open-app-modal.tsx @@ -26,7 +26,14 @@ export function openAppModal(props: OpenAppModalProps): void { modalId, title, size, - children, + // Takes the focus the trap would otherwise land on the close button, + // which reads as the button being pre-selected. Focus still enters the + // dialog, so Tab stays scoped to it. + children: ( + <div data-autofocus tabIndex={-1} style={{ outline: "none" }}> + {children} + </div> + ), onClose, centered: true, // The default close button is what makes an otherwise tight header tall. diff --git a/src/frontend/features/insert/components/insert-menu.tsx b/src/frontend/features/insert/components/insert-menu.tsx index 1bc7ca2a5..909e40caf 100644 --- a/src/frontend/features/insert/components/insert-menu.tsx +++ b/src/frontend/features/insert/components/insert-menu.tsx @@ -208,6 +208,9 @@ function InsertButtons(props: InsertButtonsProps): ReactNode { /> )} <Button + // Mantine trims the icon side only, leaving the label's side + // half again as wide; even it up. + pr="sm" leftSection={<Plus size={IconSize.SMALL} />} loading={isLoadingConfiguration || insertMutation.isPending} onClick={handleClick} diff --git a/src/frontend/features/library/components/card-components.tsx b/src/frontend/features/library/components/card-components.tsx index 13636822c..2198811ef 100644 --- a/src/frontend/features/library/components/card-components.tsx +++ b/src/frontend/features/library/components/card-components.tsx @@ -167,8 +167,8 @@ export function CardTitle(props: CardTitleProps) { const details = searchHit ? ( [ - [searchHit.partNumber, searchHit.partNumberPositions], - [searchHit.partName, searchHit.partNamePositions] + [searchHit.partName, searchHit.partNamePositions], + [searchHit.partNumber, searchHit.partNumberPositions] ] as const ).filter( (detail): detail is [string, Position[] | undefined] => diff --git a/src/frontend/features/library/queries.ts b/src/frontend/features/library/queries.ts index 53d6e83bf..2d31a28dc 100644 --- a/src/frontend/features/library/queries.ts +++ b/src/frontend/features/library/queries.ts @@ -58,6 +58,8 @@ const POLL_STEPS = [ { untilMs: 75_000, intervalMs: 5_000 } ]; const SLOWEST_POLL_MS = 10_000; +/** How long a mount reuses the status it has rather than asking again. */ +const STATUS_REUSE_MS = 30_000; function jobPollInterval(runningForMs: number): number { const step = POLL_STEPS.find(({ untilMs }) => runningForMs < untilMs); @@ -73,9 +75,10 @@ export function getJobStatusQuery(libraryId: LibraryId, canPoll: boolean) { queryKey: jobStatusQueryKey(libraryId), queryFn: () => apiGet("/job-status/library/" + libraryId), enabled: canPoll, - // Every status badge observes this, so rows mounting as the user scrolls - // would each trigger a fetch. Only the poll should set the pace. - staleTime: FASTEST_POLL_MS, + // Every status badge observes this, and switching libraries remounts + // them all, so without a window each would trigger its own fetch. Only + // the poll should set the pace, and it ignores this. + staleTime: STATUS_REUSE_MS, refetchInterval: (query) => { const status = query.state.data; if (!status?.running) { diff --git a/src/frontend/lib/style-constants.ts b/src/frontend/lib/style-constants.ts index 237fe4c05..e4792e761 100644 --- a/src/frontend/lib/style-constants.ts +++ b/src/frontend/lib/style-constants.ts @@ -33,7 +33,7 @@ export const BORDER = "1px solid var(--mantine-color-default-border)"; * modal's header and footer, which read apart from the content between them. */ export const CHROME_BACKGROUND = - "light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8))"; + "light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8))"; /** * An icon centres on the text's line box, but text reads as centred on its cap From ed9a842b647ba3d1a6de99fd92d55bfd0a2c28b8 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 22 Aug 2026 21:04:43 +0000 Subject: [PATCH 35/56] Fix the title icon wrapper, and give section headers one height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nudge wrapper was a plain block, so the icon inside it went back to sitting on the text baseline — several pixels low rather than the one the nudge takes back. Centre it instead, which blockifies the icon: the back arrow was 4.4px out and is now half a pixel, in line with the accordion's own icons. Set a section header's height rather than leaving it to the tallest child, which was the label in an accordion and the menu button in a group header, and give the group header the accordion's inset so their icons share a column. Both are 48px with icons at 16px now, from 48/52 at 16/12. Also encode the configuration a document url carries, focus search on mount, and revert the job status caching — it is live state, as its test already said. --- .../features/library/groups/routes.test.ts | 5 ++--- src/backend/features/library/groups/routes.ts | 10 ++-------- src/backend/lib/cache.ts | 19 ------------------- src/frontend/components/app-navbar.tsx | 2 ++ src/frontend/components/app-title.tsx | 7 +++++-- src/frontend/features/library/queries.ts | 9 +++------ src/frontend/lib/style-constants.ts | 7 +++++++ src/frontend/lib/url.tsx | 5 ++++- .../library/$libraryId/groups/$groupId.tsx | 11 ++++++++--- .../routes/app/library/$libraryId/index.tsx | 8 +++++++- 10 files changed, 40 insertions(+), 43 deletions(-) diff --git a/src/backend/features/library/groups/routes.test.ts b/src/backend/features/library/groups/routes.test.ts index beea75fa4..92045855b 100644 --- a/src/backend/features/library/groups/routes.test.ts +++ b/src/backend/features/library/groups/routes.test.ts @@ -231,9 +231,8 @@ describe("GET /job-status", () => { ); expect(res.status).toBe(200); expect(await res.json()).toEqual(status); - // Cached briefly so revisiting a library costs nothing, but under the - // poll's interval so a poll is never answered from the cache. - expect(res.headers.get("Cache-Control")).toBe("private, max-age=2"); + // Polled for live state, so it must never be served from a cache. + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); }); }); diff --git a/src/backend/features/library/groups/routes.ts b/src/backend/features/library/groups/routes.ts index b333be736..6c44cb158 100644 --- a/src/backend/features/library/groups/routes.ts +++ b/src/backend/features/library/groups/routes.ts @@ -1,5 +1,5 @@ import { and, eq, inArray } from "drizzle-orm"; -import { shortCacheMiddleware } from "../../../lib/cache"; +import { cacheMiddleware } from "../../../lib/cache"; import { getApp } from "../../../lib/context"; import { getLibraryParam, libraryRoute } from "../../../lib/route-params"; import { getDb } from "../../../db/client"; @@ -77,17 +77,11 @@ groupRoutes.post( } ); -/** - * The poll's fastest interval, which the cache window has to stay under so a - * poll is never answered from the cache with what it already had. - */ -const JOB_STATUS_CACHE_SECONDS = 2; - /** GET /api/job-status/library/:libraryId — checked on load, then polled. */ groupRoutes.get( "/job-status" + libraryRoute(), requireEditorMiddleware, - shortCacheMiddleware(JOB_STATUS_CACHE_SECONDS), + cacheMiddleware(), async (c) => { return c.json(await getJobStatus(c.env, getLibraryParam(c))); } diff --git a/src/backend/lib/cache.ts b/src/backend/lib/cache.ts index dcbe6cb1a..bc7c025e9 100644 --- a/src/backend/lib/cache.ts +++ b/src/backend/lib/cache.ts @@ -23,25 +23,6 @@ export function immutableCacheControl( return `${policy}, max-age=${IMMUTABLE_CACHE_TTL}, immutable`; } -/** - * A short private cache for a body that goes stale on time rather than by url. - * Keep the window under the caller's poll interval, or a poll is served the - * answer it already has. - */ -export function shortCacheMiddleware( - maxAge: number -): MiddlewareHandler<AppContextEnv> { - return async (c, next) => { - await next(); - c.header( - "Cache-Control", - c.res.ok - ? `${CachePolicy.PRIVATE_CACHE}, max-age=${maxAge}` - : NO_STORE - ); - }; -} - /** Overrides the route's immutable default for a body its url does not pin. */ export function setCacheTtl(c: AppContext, maxAge: number): void { c.set("cacheTtl", maxAge); diff --git a/src/frontend/components/app-navbar.tsx b/src/frontend/components/app-navbar.tsx index 64faf623d..777a8c4b5 100644 --- a/src/frontend/components/app-navbar.tsx +++ b/src/frontend/components/app-navbar.tsx @@ -249,6 +249,8 @@ export function SearchBar() { return ( <TextInput type="search" + // The panel opens to a library the caller is here to search. + autoFocus flex={1} leftSection={<MagnifyingGlass size={IconSize.SMALL} />} placeholder={`Search ${getLibraryName(libraryId)}...`} diff --git a/src/frontend/components/app-title.tsx b/src/frontend/components/app-title.tsx index f5b6e3858..2e9f73d74 100644 --- a/src/frontend/components/app-title.tsx +++ b/src/frontend/components/app-title.tsx @@ -1,4 +1,4 @@ -import { Box, Group, Stack, Text } from "@mantine/core"; +import { Center, Group, Stack, Text } from "@mantine/core"; import type { ReactNode } from "react"; import type { SearchRecord } from "@backend/features/configurations/models"; import { FontWeight, TITLE_ICON_NUDGE } from "../lib/style-constants"; @@ -21,7 +21,10 @@ export function AppTitle(props: AppTitleProps): ReactNode { const { title, icon, subtitle, rightSection } = props; return ( <Group gap="sm" wrap="nowrap" miw={0}> - {icon && <Box style={TITLE_ICON_NUDGE}>{icon}</Box>} + {/* Centred rather than wrapped in a plain box: inside a block the + icon goes back to sitting on the text baseline, which drops it + several pixels rather than the one the nudge takes back. */} + {icon && <Center style={TITLE_ICON_NUDGE}>{icon}</Center>} <Stack gap={0} miw={0}> <Group gap="xs" wrap="nowrap" miw={0}> <Text fw={FontWeight.SEMI_BOLD} truncate> diff --git a/src/frontend/features/library/queries.ts b/src/frontend/features/library/queries.ts index 2d31a28dc..53d6e83bf 100644 --- a/src/frontend/features/library/queries.ts +++ b/src/frontend/features/library/queries.ts @@ -58,8 +58,6 @@ const POLL_STEPS = [ { untilMs: 75_000, intervalMs: 5_000 } ]; const SLOWEST_POLL_MS = 10_000; -/** How long a mount reuses the status it has rather than asking again. */ -const STATUS_REUSE_MS = 30_000; function jobPollInterval(runningForMs: number): number { const step = POLL_STEPS.find(({ untilMs }) => runningForMs < untilMs); @@ -75,10 +73,9 @@ export function getJobStatusQuery(libraryId: LibraryId, canPoll: boolean) { queryKey: jobStatusQueryKey(libraryId), queryFn: () => apiGet("/job-status/library/" + libraryId), enabled: canPoll, - // Every status badge observes this, and switching libraries remounts - // them all, so without a window each would trigger its own fetch. Only - // the poll should set the pace, and it ignores this. - staleTime: STATUS_REUSE_MS, + // Every status badge observes this, so rows mounting as the user scrolls + // would each trigger a fetch. Only the poll should set the pace. + staleTime: FASTEST_POLL_MS, refetchInterval: (query) => { const status = query.state.data; if (!status?.running) { diff --git a/src/frontend/lib/style-constants.ts b/src/frontend/lib/style-constants.ts index e4792e761..46209d9ce 100644 --- a/src/frontend/lib/style-constants.ts +++ b/src/frontend/lib/style-constants.ts @@ -43,6 +43,13 @@ export const CHROME_BACKGROUND = */ export const TITLE_ICON_NUDGE = { transform: "translateY(-1px)" }; +/** + * One height for a section's header row, whether an accordion section or a + * group's. Set rather than left to the content, whose tallest child differs: + * an accordion is sized by its label, a group header by its menu button. + */ +export const SECTION_HEADER_HEIGHT = 48; + /** Padding for a modal's header and footer, tighter than the body they frame. */ export const CHROME_PADDING = "6px var(--mantine-spacing-sm)"; diff --git a/src/frontend/lib/url.tsx b/src/frontend/lib/url.tsx index b03b91b72..29acaeb41 100644 --- a/src/frontend/lib/url.tsx +++ b/src/frontend/lib/url.tsx @@ -25,8 +25,11 @@ export function makeUrl(path: DocumentPath): string { url += `/e/${path.elementId}`; } if (isConfigurablePath(path)) { + // Encoded here rather than in the shared helper, whose raw output is + // what Onshape's api takes; a url needs its own escaping. url += - "?configuration=" + encodeConfigurationForQuery(path.configuration); + "?configuration=" + + encodeURIComponent(encodeConfigurationForQuery(path.configuration)); } return url; } diff --git a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx index 4faf60ee7..236d8dccd 100644 --- a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx +++ b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx @@ -8,7 +8,11 @@ import { } from "@tanstack/react-router"; import { Box, Button, Group } from "@mantine/core"; import { ArrowLeft, ArrowUUpLeft, Warning } from "@phosphor-icons/react"; -import { BORDER, IconSize } from "../../../../../lib/style-constants"; +import { + BORDER, + IconSize, + SECTION_HEADER_HEIGHT +} from "../../../../../lib/style-constants"; import { ReactNode } from "react"; import { SearchResults } from "../../../../../features/search/components/search-results"; import { GroupOut, Insertables } from "@backend/features/library/contract"; @@ -125,9 +129,10 @@ function GroupHeaderRow({ group }: { group: GroupOut }): ReactNode { params: { libraryId } }) } - p="sm" + px="md" + h={SECTION_HEADER_HEIGHT} > - <Group wrap="nowrap" justify="space-between"> + <Group wrap="nowrap" justify="space-between" h="100%"> <AppTitle icon={<ArrowLeft size={IconSize.MEDIUM} />} title={group.name} diff --git a/src/frontend/routes/app/library/$libraryId/index.tsx b/src/frontend/routes/app/library/$libraryId/index.tsx index 3f16d5022..06d8e9db9 100644 --- a/src/frontend/routes/app/library/$libraryId/index.tsx +++ b/src/frontend/routes/app/library/$libraryId/index.tsx @@ -6,6 +6,7 @@ import { BORDER, IconSize, PrimaryColor, + SECTION_HEADER_HEIGHT, TITLE_ICON_NUDGE } from "../../../../lib/style-constants"; import { ReactNode, useState } from "react"; @@ -122,7 +123,12 @@ function HomeList(): ReactNode { styles={{ // On the control, so a collapsed section still divides from // the next one; content closes off an open one. - control: { borderBottom: BORDER }, + control: { + borderBottom: BORDER, + minHeight: SECTION_HEADER_HEIGHT, + paddingBlock: 0 + }, + label: { paddingBlock: 0 }, content: { padding: 0, borderBottom: BORDER }, icon: TITLE_ICON_NUDGE }} From c6f32c35ddbd691190856f52e78a0917d659a5d1 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 22 Aug 2026 21:30:23 +0000 Subject: [PATCH 36/56] Even up the derive button, parameters, and group header Each parameter row carried its own top margin, so the first one added to the gap the modal body already leaves: 24px above the parameters against 12px below. Space them from the stack instead, and the two match. The button's icon side read wider because the plus is inset inside its own box, so an equal padding leaves an unequal gap. Trim that side: the ink now sits 12px from the top, left, and 12.8px from the right. The group header left its divider to the row below it while an accordion control carries its own, making the group's band a pixel taller. Give it the border, so both are 48px. --- .../features/insert/components/configurations.tsx | 7 +++++-- .../features/insert/components/insert-menu.tsx | 6 ++++-- .../routes/app/library/$libraryId/groups/$groupId.tsx | 10 ++++------ 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/frontend/features/insert/components/configurations.tsx b/src/frontend/features/insert/components/configurations.tsx index f26576c50..d6bb80822 100644 --- a/src/frontend/features/insert/components/configurations.tsx +++ b/src/frontend/features/insert/components/configurations.tsx @@ -4,6 +4,7 @@ import { Group, Loader, Select, + Stack, Text, TextInput } from "@mantine/core"; @@ -196,7 +197,9 @@ function ConfigurationParameters(props: ConfigurationParameterProps) { /> ); }); - return <div>{parameters}</div>; + // Spaced by the stack, not by a margin on each row, which the first row + // would add to the gap the body already leaves above it. + return <Stack gap="sm">{parameters}</Stack>; } interface ParameterProps<T extends ConfigurationParameter> { @@ -269,7 +272,7 @@ interface InputLabelProps { function InputLabel(props: InputLabelProps) { const { label, htmlFor, children } = props; return ( - <Group gap="sm" align="flex-start" mt="sm"> + <Group gap="sm" align="flex-start"> <Text size="sm" style={{ diff --git a/src/frontend/features/insert/components/insert-menu.tsx b/src/frontend/features/insert/components/insert-menu.tsx index 909e40caf..b4fd18173 100644 --- a/src/frontend/features/insert/components/insert-menu.tsx +++ b/src/frontend/features/insert/components/insert-menu.tsx @@ -208,8 +208,10 @@ function InsertButtons(props: InsertButtonsProps): ReactNode { /> )} <Button - // Mantine trims the icon side only, leaving the label's side - // half again as wide; even it up. + // Even up the visible gaps: Mantine leaves the label's side + // wider, and the plus is inset inside its own box, so the icon + // side needs less padding still. + pl={9} pr="sm" leftSection={<Plus size={IconSize.SMALL} />} loading={isLoadingConfiguration || insertMutation.isPending} diff --git a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx index 236d8dccd..e778309c0 100644 --- a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx +++ b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx @@ -102,12 +102,7 @@ function GroupList(): ReactNode { return ( <> <GroupHeaderRow group={group} /> - <Box - style={{ - borderBottom: BORDER, - borderTop: BORDER - }} - > + <Box style={{ borderBottom: BORDER }}> {content} <Outlet /> </Box> @@ -131,6 +126,9 @@ function GroupHeaderRow({ group }: { group: GroupOut }): ReactNode { } px="md" h={SECTION_HEADER_HEIGHT} + // Owned here, as an accordion control owns its own, so the row and + // its divider measure the same as a section header's. + style={{ borderBottom: BORDER }} > <Group wrap="nowrap" justify="space-between" h="100%"> <AppTitle From b55647de2bac14f15f2914a148629b7dd404f35a Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 22 Aug 2026 21:42:01 +0000 Subject: [PATCH 37/56] Inset the footer's buttons equally, and drop the button's own tuning The footer padded 6px above and below a button already inset 12 from the side, so the button sat closer to the divider than to the edge. Pad it evenly instead. The header keeps the tighter vertical, where a title line needs less room than a button, so the constant is now named for it. Revert the hand-tuned padding on the insert button itself, which was chasing the same complaint in the wrong place; Mantine's own is fine. --- src/frontend/components/app-modal.tsx | 9 +++------ src/frontend/components/open-app-modal.tsx | 4 ++-- src/frontend/features/insert/components/insert-menu.tsx | 5 ----- src/frontend/lib/style-constants.ts | 7 +++++-- 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/frontend/components/app-modal.tsx b/src/frontend/components/app-modal.tsx index 2e1641a2e..d9083149f 100644 --- a/src/frontend/components/app-modal.tsx +++ b/src/frontend/components/app-modal.tsx @@ -1,10 +1,6 @@ import { Group, type MantineSpacing, Stack } from "@mantine/core"; import { PropsWithChildren, ReactNode } from "react"; -import { - BORDER, - CHROME_BACKGROUND, - CHROME_PADDING -} from "../lib/style-constants"; +import { BORDER, CHROME_BACKGROUND } from "../lib/style-constants"; interface AppModalBodyProps extends PropsWithChildren { /** Space between children; content that spaces itself should pass 0. */ @@ -29,8 +25,9 @@ export function AppModalFooter(props: PropsWithChildren): ReactNode { <Group justify="space-between" wrap="nowrap" + p="sm" bg={CHROME_BACKGROUND} - style={{ padding: CHROME_PADDING, borderTop: BORDER }} + style={{ borderTop: BORDER }} > {props.children} </Group> diff --git a/src/frontend/components/open-app-modal.tsx b/src/frontend/components/open-app-modal.tsx index 12e0eac30..6434c4bf1 100644 --- a/src/frontend/components/open-app-modal.tsx +++ b/src/frontend/components/open-app-modal.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from "react"; import { BORDER, CHROME_BACKGROUND, - CHROME_PADDING + MODAL_HEADER_PADDING } from "../lib/style-constants"; interface OpenAppModalProps { @@ -45,7 +45,7 @@ export function openAppModal(props: OpenAppModalProps): void { header: { background: CHROME_BACKGROUND, borderBottom: BORDER, - padding: CHROME_PADDING, + padding: MODAL_HEADER_PADDING, minHeight: 0 }, body: { padding: 0 } diff --git a/src/frontend/features/insert/components/insert-menu.tsx b/src/frontend/features/insert/components/insert-menu.tsx index b4fd18173..1bc7ca2a5 100644 --- a/src/frontend/features/insert/components/insert-menu.tsx +++ b/src/frontend/features/insert/components/insert-menu.tsx @@ -208,11 +208,6 @@ function InsertButtons(props: InsertButtonsProps): ReactNode { /> )} <Button - // Even up the visible gaps: Mantine leaves the label's side - // wider, and the plus is inset inside its own box, so the icon - // side needs less padding still. - pl={9} - pr="sm" leftSection={<Plus size={IconSize.SMALL} />} loading={isLoadingConfiguration || insertMutation.isPending} onClick={handleClick} diff --git a/src/frontend/lib/style-constants.ts b/src/frontend/lib/style-constants.ts index 46209d9ce..5c111f971 100644 --- a/src/frontend/lib/style-constants.ts +++ b/src/frontend/lib/style-constants.ts @@ -50,8 +50,11 @@ export const TITLE_ICON_NUDGE = { transform: "translateY(-1px)" }; */ export const SECTION_HEADER_HEIGHT = 48; -/** Padding for a modal's header and footer, tighter than the body they frame. */ -export const CHROME_PADDING = "6px var(--mantine-spacing-sm)"; +/** + * Padding for a modal's header, tighter above and below than the inset it + * shares with the body: a title line needs less room around it than a button. + */ +export const MODAL_HEADER_PADDING = "6px var(--mantine-spacing-sm)"; /** The app's primary color as a filled background. */ export enum PrimaryColor { From 7f86d9ef4df76ba12c0358030862d9daad78fa06 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 22 Aug 2026 22:00:02 +0000 Subject: [PATCH 38/56] Give the modal header the standard inset, and trim what was overbaked The header took a hand-written 6px vertical padding and a shrunk close button to stay tight, which left a title with no second line cramped. Use the same inset as the body and footer, with the close button at its own size, and the constant that existed only for that padding goes away. Drop two declarations that turned out to do nothing: `white-space: nowrap` on a tab, which a non-wrapping list already gives, and `padding-block: 0` on an accordion control, whose default is already zero. The label's own padding-block is what had to go, and stays. Trim the comments that had grown past the two lines the project asks for. --- src/backend/app.ts | 5 +-- src/backend/lib/api-error.ts | 7 +--- src/backend/lib/validate.ts | 5 +-- src/frontend/components/app-modal.tsx | 5 +-- src/frontend/components/app-navbar.tsx | 22 +++++------ src/frontend/components/app-title.tsx | 16 +++----- src/frontend/components/open-app-modal.tsx | 22 +++-------- .../features/insert/quick-insert-tip.ts | 5 +-- src/frontend/lib/errors.ts | 3 +- src/frontend/lib/style-constants.ts | 39 +++++-------------- src/frontend/routes/__root.tsx | 5 +-- .../routes/app/library/$libraryId/index.tsx | 4 +- 12 files changed, 45 insertions(+), 93 deletions(-) diff --git a/src/backend/app.ts b/src/backend/app.ts index be65e5182..f5cc5ae80 100644 --- a/src/backend/app.ts +++ b/src/backend/app.ts @@ -32,9 +32,8 @@ const apiRoutes = [ export function createApp(makeCaller: CallerFactory) { const app = getApp(); - // console.log reaches Workers Logs, since wrangler.jsonc enables - // observability. Only /init, /api/* and /auth/* run the Worker at all - // (see run_worker_first), so static assets are not logged. + // Reaches Workers Logs, which wrangler.jsonc enables. Only /init, /api/* + // and /auth/* run the Worker, so static assets are not logged. app.use("*", logger()); app.use("*", bindCaller(makeCaller)); diff --git a/src/backend/lib/api-error.ts b/src/backend/lib/api-error.ts index 9bd740195..e70b24e53 100644 --- a/src/backend/lib/api-error.ts +++ b/src/backend/lib/api-error.ts @@ -1,9 +1,6 @@ /** - * The one shape every failed /api response takes. `kind` tells the client what - * to do, and each kind carries exactly the data that kind needs — a new kind - * brings its own fields rather than adding an optional one to every error. - * - * A leaf module: the frontend imports these to switch on them. + * The one shape every failed /api response takes; `kind` tells the client what + * to do and carries what that kind needs. A leaf module the frontend imports. */ export enum ApiErrorKind { diff --git a/src/backend/lib/validate.ts b/src/backend/lib/validate.ts index fc20f3c54..2ef8f34b8 100644 --- a/src/backend/lib/validate.ts +++ b/src/backend/lib/validate.ts @@ -5,9 +5,8 @@ import type { ZodType } from "zod"; import { internalError } from "./api-error"; /** - * `zValidator` with our error shape. On its own it answers with a body of its - * own design, which is the one response that would not look like every other - * failure. A malformed request is our bug, so the detail is for the logs. + * `zValidator` with our error shape; its own body is the one that would not + * match. A malformed request is our bug, so the detail is for the logs. */ export function validate< T extends ZodType, diff --git a/src/frontend/components/app-modal.tsx b/src/frontend/components/app-modal.tsx index d9083149f..f19a9a379 100644 --- a/src/frontend/components/app-modal.tsx +++ b/src/frontend/components/app-modal.tsx @@ -16,10 +16,7 @@ export function AppModalBody(props: AppModalBodyProps): ReactNode { ); } -/** - * A modal's actions, flush against the bottom on the header's surface. Lay - * children out as leading and trailing groups; a lone child sits at the end. - */ +/** A modal's actions. A lone child sits at the end; two split the row. */ export function AppModalFooter(props: PropsWithChildren): ReactNode { return ( <Group diff --git a/src/frontend/components/app-navbar.tsx b/src/frontend/components/app-navbar.tsx index 777a8c4b5..56dc80134 100644 --- a/src/frontend/components/app-navbar.tsx +++ b/src/frontend/components/app-navbar.tsx @@ -125,9 +125,9 @@ function FrcDesignBookIcon(): ReactNode { placeItems: "center" }} > - {/* Masked rather than drawn, so the book takes the tile's contrast - color rather than the gray baked into the file. The url has to - be quoted: Vite inlines this asset as a data uri with quotes. */} + {/* Masked, not drawn, so the book takes the tile's contrast color + rather than the gray in the file. The url needs quoting: Vite + inlines the asset as a data uri containing apostrophes. */} <Box w={IconSize.SMALL} h={IconSize.SMALL} @@ -174,23 +174,21 @@ function LibraryTabs(): ReactNode { }); }} styles={{ - // Hides the line Mantine draws under the tab list alone — - // the row owns one that runs the full width. The active tab's - // indicator is colored separately and survives this. + // Hides the line under the tab list alone; the row owns one + // that spans it. The active indicator is colored separately. root: { "--tab-border-color": "transparent" }, - // Three full names outgrow a narrow panel; scrolling them - // keeps the navbar one row rather than reflowing into two. + // Three full names outgrow a narrow panel; scrolling beats + // reflowing the navbar into two rows. list: { flexWrap: "nowrap", overflowX: "auto", scrollbarWidth: "none" }, + // Pulled onto that divider, so the active tab's indicator + // replaces it rather than stacking a line above it. tab: { - // Pulled onto that divider, so the active tab's indicator - // replaces it instead of stacking a second line above it. marginBottom: -1, - paddingInline: "var(--mantine-spacing-sm)", - whiteSpace: "nowrap" + paddingInline: "var(--mantine-spacing-sm)" } }} > diff --git a/src/frontend/components/app-title.tsx b/src/frontend/components/app-title.tsx index 2e9f73d74..530c8c014 100644 --- a/src/frontend/components/app-title.tsx +++ b/src/frontend/components/app-title.tsx @@ -13,17 +13,13 @@ interface AppTitleProps { rightSection?: ReactNode; } -/** - * The app's heading: one weight and size for every icon-and-text title, so a - * modal header, an accordion section, and a group header all read alike. - */ +/** One weight and size for every icon-and-text heading in the app. */ export function AppTitle(props: AppTitleProps): ReactNode { const { title, icon, subtitle, rightSection } = props; return ( <Group gap="sm" wrap="nowrap" miw={0}> - {/* Centred rather than wrapped in a plain box: inside a block the - icon goes back to sitting on the text baseline, which drops it - several pixels rather than the one the nudge takes back. */} + {/* Centred, not wrapped in a block, where the icon would go back + to sitting on the text baseline several pixels low. */} {icon && <Center style={TITLE_ICON_NUDGE}>{icon}</Center>} <Stack gap={0} miw={0}> <Group gap="xs" wrap="nowrap" miw={0}> @@ -49,10 +45,8 @@ interface MenuTitleProps { icon?: ReactNode; } -/** - * A menu's header. Both names are shown: the element is how the part was found, - * the record is what actually gets inserted. - */ +/** A menu's header: the element is how the part was found, the record is + * what gets inserted, so both are shown. */ export function MenuTitle(props: MenuTitleProps): ReactNode { const { name, record, icon } = props; const details = record diff --git a/src/frontend/components/open-app-modal.tsx b/src/frontend/components/open-app-modal.tsx index 6434c4bf1..ffed332ec 100644 --- a/src/frontend/components/open-app-modal.tsx +++ b/src/frontend/components/open-app-modal.tsx @@ -1,10 +1,6 @@ import { modals } from "@mantine/modals"; import type { ReactNode } from "react"; -import { - BORDER, - CHROME_BACKGROUND, - MODAL_HEADER_PADDING -} from "../lib/style-constants"; +import { BORDER, CHROME_BACKGROUND } from "../lib/style-constants"; interface OpenAppModalProps { title: ReactNode; @@ -16,9 +12,8 @@ interface OpenAppModalProps { } /** - * Opens a modal wearing the app's chrome: a tight header on its own surface - * over an unpadded body, so an `AppModalFooter` can sit flush at the bottom. - * Content belongs in an `AppModalBody`, which supplies the padding instead. + * Opens a modal wearing the app's chrome. Its body is unpadded, so content + * belongs in an `AppModalBody` and actions in an `AppModalFooter`. */ export function openAppModal(props: OpenAppModalProps): void { const { title, children, modalId, size, onClose } = props; @@ -27,8 +22,7 @@ export function openAppModal(props: OpenAppModalProps): void { title, size, // Takes the focus the trap would otherwise land on the close button, - // which reads as the button being pre-selected. Focus still enters the - // dialog, so Tab stays scoped to it. + // which reads as that button being pre-selected. children: ( <div data-autofocus tabIndex={-1} style={{ outline: "none" }}> {children} @@ -36,17 +30,13 @@ export function openAppModal(props: OpenAppModalProps): void { ), onClose, centered: true, - // The default close button is what makes an otherwise tight header tall. - closeButtonProps: { size: "sm" }, styles: { - // Drawn, not just shadowed, so the card reads as one panel against - // the app behind it. + // Drawn, not just shadowed, so the card reads as one panel. content: { border: BORDER }, header: { background: CHROME_BACKGROUND, borderBottom: BORDER, - padding: MODAL_HEADER_PADDING, - minHeight: 0 + padding: "var(--mantine-spacing-sm)" }, body: { padding: 0 } } diff --git a/src/frontend/features/insert/quick-insert-tip.ts b/src/frontend/features/insert/quick-insert-tip.ts index 9469d8ec9..db0ac99b5 100644 --- a/src/frontend/features/insert/quick-insert-tip.ts +++ b/src/frontend/features/insert/quick-insert-tip.ts @@ -2,9 +2,8 @@ import { showInfoToast } from "../../lib/notifications"; import { getUiState, updateUiState } from "../../lib/ui-state"; /** - * Points out the faster route after an insert that changed nothing in the menu: - * the same insert was one right-click away. Shown only once, since someone who - * wants the menu should not be told off for using it. + * After an insert that changed nothing in the menu, points out that a + * right-click would have done it. Once only: the menu is a fine way to work. */ export function showQuickInsertTip(): void { if (getUiState().hasSeenQuickInsertTip) { diff --git a/src/frontend/lib/errors.ts b/src/frontend/lib/errors.ts index 20f8c531f..0ab726d36 100644 --- a/src/frontend/lib/errors.ts +++ b/src/frontend/lib/errors.ts @@ -3,8 +3,7 @@ import { showErrorToast } from "./notifications"; /** * A failure worth telling the user about, from the backend or raised here. - * `body` is the same discriminated shape the backend sends, so a caller reads - * whatever that kind carries without optional fields on every other kind. + * `body` is the discriminated shape the backend sends, read by its kind. */ export class AppError extends Error { constructor(readonly body: ApiErrorBody) { diff --git a/src/frontend/lib/style-constants.ts b/src/frontend/lib/style-constants.ts index 5c111f971..564eb5e06 100644 --- a/src/frontend/lib/style-constants.ts +++ b/src/frontend/lib/style-constants.ts @@ -1,7 +1,6 @@ /** - * Standard icon sizes to pass to Phosphor icons. The first three are general - * magnitudes for an icon sitting in a line of content; the rest each name the - * one place they are used. + * Sizes for Phosphor icons. The first three are general magnitudes for an icon + * in a line of content; the rest each name the one place they are used. */ export enum IconSize { /** Beside xs text: badge labels and metadata rows. */ @@ -18,9 +17,7 @@ export enum IconSize { PAGE = 48 } -/** - * Standard Mantine FontWeights. - */ +/** Standard Mantine font weights. */ export enum FontWeight { SEMI_BOLD = 500, BOLD = 700 @@ -28,42 +25,26 @@ export enum FontWeight { export const BORDER = "1px solid var(--mantine-color-default-border)"; -/** - * A step off the page, for the app's chrome: the navbar's tab row and a - * modal's header and footer, which read apart from the content between them. - */ +/** A step off the page: the navbar's tab row, a modal's header and footer. */ export const CHROME_BACKGROUND = "light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8))"; /** - * An icon centres on the text's line box, but text reads as centred on its cap - * height, which sits about a pixel higher — so a centred icon looks low. CSS - * `text-box` would trim the box properly, but it clips descenders under the - * truncation titles need, so title icons take the pixel back by hand. + * Text reads as centred on its cap height, a pixel above its line box, so an + * icon centred on that box looks low. `text-box` clips descenders under truncation. */ export const TITLE_ICON_NUDGE = { transform: "translateY(-1px)" }; /** - * One height for a section's header row, whether an accordion section or a - * group's. Set rather than left to the content, whose tallest child differs: - * an accordion is sized by its label, a group header by its menu button. + * One height for a section header, set rather than left to the content: an + * accordion is sized by its label, a group header by its menu button. */ export const SECTION_HEADER_HEIGHT = 48; -/** - * Padding for a modal's header, tighter above and below than the inset it - * shares with the body: a title line needs less room around it than a button. - */ -export const MODAL_HEADER_PADDING = "6px var(--mantine-spacing-sm)"; - /** The app's primary color as a filled background. */ export enum PrimaryColor { - /** - * The current library color, e.g., green for FRCDesign. - */ + /** The current library's color, e.g. green for FRCDesign. */ FILLED = "var(--mantine-primary-color-filled)", - /** - * The current library contrast color, typically white. - */ + /** What reads on top of it, typically white. */ CONTRAST = "var(--mantine-primary-color-contrast)" } diff --git a/src/frontend/routes/__root.tsx b/src/frontend/routes/__root.tsx index b40e8cb26..110c7d8d2 100644 --- a/src/frontend/routes/__root.tsx +++ b/src/frontend/routes/__root.tsx @@ -54,9 +54,8 @@ function RootComponent(): ReactNode { position="bottom-center" limit={3} autoClose={4000} - // Mantine pins this at 440px otherwise, wrapping a - // message with an action button even on a wide window. - // Mantine clamps it to the viewport on a narrow one. + // Otherwise pinned at 440px, wrapping a message with + // an action button. Still clamped to a narrow viewport. containerWidth="max-content" /> <Outlet /> diff --git a/src/frontend/routes/app/library/$libraryId/index.tsx b/src/frontend/routes/app/library/$libraryId/index.tsx index 06d8e9db9..9d86172b7 100644 --- a/src/frontend/routes/app/library/$libraryId/index.tsx +++ b/src/frontend/routes/app/library/$libraryId/index.tsx @@ -125,9 +125,9 @@ function HomeList(): ReactNode { // the next one; content closes off an open one. control: { borderBottom: BORDER, - minHeight: SECTION_HEADER_HEIGHT, - paddingBlock: 0 + minHeight: SECTION_HEADER_HEIGHT }, + // Its own padding would outgrow that height. label: { paddingBlock: 0 }, content: { padding: 0, borderBottom: BORDER }, icon: TITLE_ICON_NUDGE From 71a0c435e92a5c80218d67c48ff33a578002ce41 Mon Sep 17 00:00:00 2001 From: Alex Kempen <alex.bookreader@gmail.com> Date: Sat, 22 Aug 2026 17:09:15 -0500 Subject: [PATCH 39/56] Reword quick insert tip --- src/frontend/features/insert/quick-insert-tip.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/features/insert/quick-insert-tip.ts b/src/frontend/features/insert/quick-insert-tip.ts index db0ac99b5..07da8a585 100644 --- a/src/frontend/features/insert/quick-insert-tip.ts +++ b/src/frontend/features/insert/quick-insert-tip.ts @@ -11,7 +11,7 @@ export function showQuickInsertTip(): void { } updateUiState({ hasSeenQuickInsertTip: true }); showInfoToast( - "Tip: right-click an item to insert it without opening this menu.", + "Tip: right-click a part to insert it without opening the insert menu.", { id: "quick-insert-tip", autoClose: 8000 } ); } From 3478a50b1e679f825cfb1f13da42d3ff03ba83fc Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 22 Aug 2026 22:13:21 +0000 Subject: [PATCH 40/56] Let the header's padding set its height Restore minHeight: 0, so a Mantine minimum no longer overrides the inset the header shares with the body and footer: 60px down to 53 for a title with no second line. --- src/frontend/components/open-app-modal.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/frontend/components/open-app-modal.tsx b/src/frontend/components/open-app-modal.tsx index ffed332ec..94430368d 100644 --- a/src/frontend/components/open-app-modal.tsx +++ b/src/frontend/components/open-app-modal.tsx @@ -36,7 +36,9 @@ export function openAppModal(props: OpenAppModalProps): void { header: { background: CHROME_BACKGROUND, borderBottom: BORDER, - padding: "var(--mantine-spacing-sm)" + padding: "var(--mantine-spacing-sm)", + // Otherwise a Mantine minimum, not the padding, sets the height. + minHeight: 0 }, body: { padding: 0 } } From 445f53120681dbae18c1579c51252d9123ea150b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 22 Aug 2026 22:35:47 +0000 Subject: [PATCH 41/56] Link a part number to the vendor's page for it Resolve a part's page once, when its record is built, into a url on the record: a description that is already a url wins, since it names the exact product, over one derived from the vendor and part number. Only McMaster and WCP have derivable urls, so getVendorPartUrl answers for those and nothing for the rest. The menu subtitle shows it: the part number becomes a link with a copy button beside it, and stays plain text when there is no page to point at. A title's second line is laid out as a row now, so it can hold them. Also list Custom last among the vendors, being the absence of one. --- src/backend/features/configurations/models.ts | 2 + .../features/configurations/utils.test.ts | 38 +++++++- src/backend/features/configurations/utils.ts | 12 +++ src/backend/features/library/vendors.test.ts | 45 +++++++++ src/backend/features/library/vendors.ts | 35 ++++++- src/backend/features/search/search-index.ts | 2 + src/frontend/components/app-title.tsx | 97 ++++++++++++++++--- 7 files changed, 215 insertions(+), 16 deletions(-) create mode 100644 src/backend/features/library/vendors.test.ts diff --git a/src/backend/features/configurations/models.ts b/src/backend/features/configurations/models.ts index 55321af2b..394d7758d 100644 --- a/src/backend/features/configurations/models.ts +++ b/src/backend/features/configurations/models.ts @@ -83,6 +83,8 @@ export interface ConfigurationResult { export interface SearchRecord { partNumber?: string; name?: string; + /** The vendor's page for this part, when one can be resolved. */ + url?: string; /** The (enumerated) parameter values that produce it; empty for the default. */ configuration: ParameterValues; } diff --git a/src/backend/features/configurations/utils.test.ts b/src/backend/features/configurations/utils.test.ts index d9cdde5ce..a07012c99 100644 --- a/src/backend/features/configurations/utils.test.ts +++ b/src/backend/features/configurations/utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { findRecordForConfiguration } from "./utils"; -import { SearchRecord } from "./models"; +import { findRecordForConfiguration, getPartUrl } from "./utils"; +import { PartMetadata, SearchRecord } from "./models"; function rec( configuration: Record<string, string>, @@ -48,3 +48,37 @@ describe("findRecordForConfiguration", () => { ).toBeUndefined(); }); }); + +function metadata(fields: Partial<PartMetadata>): PartMetadata { + return { hasMultipleParts: false, isOpenComposite: false, ...fields }; +} + +describe("getPartUrl", () => { + it("prefers a description that is already a url, naming the exact product", () => { + const url = getPartUrl( + metadata({ + vendor: "WCP", + partNumber: "WCP-1025", + description: "https://wcproducts.com/products/something-else" + }) + ); + expect(url).toBe("https://wcproducts.com/products/something-else"); + }); + + it("falls back to the vendor's page when the description is prose", () => { + const url = getPartUrl( + metadata({ + vendor: "WCP", + partNumber: "WCP-1025", + description: "A gearbox" + }) + ); + expect(url).toBe("https://wcproducts.com/products/wcp-1025"); + }); + + it("has none for a vendor whose urls cannot be derived", () => { + expect( + getPartUrl(metadata({ vendor: "AM", partNumber: "am-1234" })) + ).toBeUndefined(); + }); +}); diff --git a/src/backend/features/configurations/utils.ts b/src/backend/features/configurations/utils.ts index c603d0ca0..38025488a 100644 --- a/src/backend/features/configurations/utils.ts +++ b/src/backend/features/configurations/utils.ts @@ -13,6 +13,7 @@ import { VisibilityCondition, VisibilityType } from "./models"; +import { getVendorPartUrl, toVendor } from "../library/vendors"; import { LogicalOp, QuantityType, Unit } from "./enums"; import { type EvaluateOptions, valueWithUnits } from "./input-parser"; @@ -81,6 +82,17 @@ export function evaluateCondition( } return true; } +/** + * The page for a part: a description that is already a url wins, since it names + * the exact product, over one derived from the vendor and part number. + */ +export function getPartUrl(record: PartMetadata): string | undefined { + if (record.description && /^https?:\/\//i.test(record.description)) { + return record.description; + } + return getVendorPartUrl(toVendor(record.vendor), record.partNumber); +} + export function encodeConfigurationForQuery( configuration?: ParameterValues ): string { diff --git a/src/backend/features/library/vendors.test.ts b/src/backend/features/library/vendors.test.ts new file mode 100644 index 000000000..26df851e4 --- /dev/null +++ b/src/backend/features/library/vendors.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { Vendor, getVendorPartUrl, toVendor } from "./vendors"; + +describe("getVendorPartUrl", () => { + it("lowercases the part number for WCP, whose urls are lowercase", () => { + expect(getVendorPartUrl(Vendor.WCP, "WCP-1025")).toBe( + "https://wcproducts.com/products/wcp-1025" + ); + }); + + it("keeps McMaster's part number as it is written", () => { + expect(getVendorPartUrl(Vendor.MCM, "91251A445")).toBe( + "https://www.mcmaster.com/91251A445/" + ); + }); + + it.each([Vendor.AM, Vendor.REV, Vendor.CUSTOM])( + "has no derivable page for %s", + (vendor) => { + expect(getVendorPartUrl(vendor, "12345")).toBeUndefined(); + } + ); + + it("has nothing to build from without a part number", () => { + expect(getVendorPartUrl(Vendor.WCP, undefined)).toBeUndefined(); + }); +}); + +describe("toVendor", () => { + it.each([ + ["WCP", Vendor.WCP], + ["custom", Vendor.CUSTOM], + ["Acme", undefined], + [undefined, undefined] + ])("resolves %s", (text, expected) => { + expect(toVendor(text)).toBe(expected); + }); +}); + +describe("Vendor", () => { + it("lists Custom last, since it is the absence of a vendor", () => { + const vendors = Object.values(Vendor); + expect(vendors[vendors.length - 1]).toBe(Vendor.CUSTOM); + }); +}); diff --git a/src/backend/features/library/vendors.ts b/src/backend/features/library/vendors.ts index 91e509945..e40af3cc8 100644 --- a/src/backend/features/library/vendors.ts +++ b/src/backend/features/library/vendors.ts @@ -1,8 +1,6 @@ /** The vendors an insertable can come from, and how they are displayed. */ export enum Vendor { AM = "AM", - /** Marks a part the team made, so nobody sells it and it has no part number. */ - CUSTOM = "Custom", LAI = "LAI", MCM = "MCM", REDUX = "Redux", @@ -11,7 +9,38 @@ export enum Vendor { SWYFT = "SWYFT", TTB = "TTB", VEX = "VEX", - WCP = "WCP" + WCP = "WCP", + /** Last, being the absence of a vendor: the team made it, so nobody sells + * it and it has no part number. */ + CUSTOM = "Custom" +} + +/** Resolves the free text Onshape carries as a vendor to one we know. */ +export function toVendor(vendor: string | undefined): Vendor | undefined { + return Object.values(Vendor).find( + (known) => known.toUpperCase() === vendor?.toUpperCase() + ); +} + +/** + * The vendor's own page for a part. Most vendors have no url derivable from a + * part number, so this is undefined for all but the few that do. + */ +export function getVendorPartUrl( + vendor: Vendor | undefined, + partNumber: string | undefined +): string | undefined { + if (!partNumber) { + return undefined; + } + switch (vendor) { + case Vendor.MCM: + return `https://www.mcmaster.com/${partNumber}/`; + case Vendor.WCP: + return `https://wcproducts.com/products/${partNumber.toLowerCase()}`; + default: + return undefined; + } } /** Team-made, so it is expected to have no part number. */ diff --git a/src/backend/features/search/search-index.ts b/src/backend/features/search/search-index.ts index 9124f6584..560287bff 100644 --- a/src/backend/features/search/search-index.ts +++ b/src/backend/features/search/search-index.ts @@ -6,6 +6,7 @@ import MiniSearch, { Options } from "minisearch"; import { LibraryOut } from "../library/contract"; import { Vendor } from "../library/vendors"; import { ConfigurationRecord, SearchRecord } from "../configurations/models"; +import { getPartUrl } from "../configurations/utils"; const deliminator = "^"; @@ -145,6 +146,7 @@ export function toSearchRecords( searchRecords.push({ partNumber: record.partNumber, name: record.name, + url: getPartUrl(record), configuration: record.configuration }); } diff --git a/src/frontend/components/app-title.tsx b/src/frontend/components/app-title.tsx index 530c8c014..923559435 100644 --- a/src/frontend/components/app-title.tsx +++ b/src/frontend/components/app-title.tsx @@ -1,13 +1,23 @@ -import { Center, Group, Stack, Text } from "@mantine/core"; +import { + ActionIcon, + Anchor, + Center, + CopyButton, + Group, + Stack, + Text, + Tooltip +} from "@mantine/core"; +import { ArrowSquareOut, Check, Copy } from "@phosphor-icons/react"; import type { ReactNode } from "react"; import type { SearchRecord } from "@backend/features/configurations/models"; -import { FontWeight, TITLE_ICON_NUDGE } from "../lib/style-constants"; +import { FontWeight, IconSize, TITLE_ICON_NUDGE } from "../lib/style-constants"; interface AppTitleProps { title: ReactNode; /** Leading icon, at `IconSize.MEDIUM` to match the title's size. */ icon?: ReactNode; - /** A quieter second line, e.g. what the current configuration resolves to. */ + /** A quieter second line, laid out as a row so it can hold controls. */ subtitle?: ReactNode; /** Trailing content on the title's own line, e.g. a status badge. */ rightSection?: ReactNode; @@ -29,9 +39,9 @@ export function AppTitle(props: AppTitleProps): ReactNode { {rightSection} </Group> {subtitle && ( - <Text size="xs" c="dimmed" truncate> + <Group gap={4} wrap="nowrap" fz="xs" c="dimmed"> {subtitle} - </Text> + </Group> )} </Stack> </Group> @@ -49,16 +59,81 @@ interface MenuTitleProps { * what gets inserted, so both are shown. */ export function MenuTitle(props: MenuTitleProps): ReactNode { const { name, record, icon } = props; - const details = record - ? [record.name, record.partNumber].filter( - (value): value is string => !!value && value !== name - ) - : []; + const partName = record?.name !== name ? record?.name : undefined; + const partNumber = + record?.partNumber !== name ? record?.partNumber : undefined; return ( <AppTitle icon={icon} title={name} - subtitle={details.length > 0 ? details.join(" · ") : undefined} + subtitle={ + (partName || partNumber) && ( + <> + {partName && ( + <Text inherit truncate> + {partName} + </Text> + )} + {partName && partNumber && <Text inherit>·</Text>} + {partNumber && ( + <PartNumber + partNumber={partNumber} + url={record?.url} + /> + )} + </> + ) + } /> ); } + +/** The part number, linked to the vendor's page for it when there is one. */ +function PartNumber({ + partNumber, + url +}: { + partNumber: string; + url?: string; +}): ReactNode { + if (!url) { + return ( + <Text inherit truncate> + {partNumber} + </Text> + ); + } + return ( + <> + <Anchor + href={url} + target="_blank" + inherit + truncate + onClick={(event) => event.stopPropagation()} + > + {partNumber} + </Anchor> + <ArrowSquareOut size={IconSize.TINY} /> + <CopyButton value={url}> + {({ copied, copy }) => ( + <Tooltip label={copied ? "Copied" : "Copy link"} withArrow> + <ActionIcon + variant="subtle" + color={copied ? "teal" : "gray"} + size="xs" + aria-label="Copy link" + onClick={copy} + > + {copied ? ( + <Check size={IconSize.TINY} /> + ) : ( + <Copy size={IconSize.TINY} /> + )} + </ActionIcon> + </Tooltip> + )} + </CopyButton> + </> + ); +} From 6a3d906c4ebda9a548acf5a2e12266c8eab7ae7b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 22 Aug 2026 22:46:01 +0000 Subject: [PATCH 42/56] Resolve a vendor from the insertable, and show the part in every menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onshape's own vendor field is often unset, which left WCP parts with no link. Fall back to the insertable's vendors, the same signal the filters use, when they name exactly one — several cannot say which a record is. Also read that field written as a full name, not only as a code. A part with no parameters mounts no ConfigurationWrapper, so nothing reported its record and the insert menu's title had no part number at all. Query it directly in that case, from a hook the wrapper now shares. --- src/backend/features/configurations/routes.ts | 4 ++- .../features/configurations/utils.test.ts | 25 +++++++++++++++++++ src/backend/features/configurations/utils.ts | 16 +++++++++--- src/backend/features/library/vendors.test.ts | 3 +++ src/backend/features/library/vendors.ts | 13 ++++++++-- src/backend/features/search/search-index.ts | 10 +++++--- .../insert/components/configurations.tsx | 17 ++----------- .../insert/components/insert-menu.tsx | 17 +++++++++++-- src/frontend/features/insert/queries.ts | 24 +++++++++++++++++- 9 files changed, 102 insertions(+), 27 deletions(-) diff --git a/src/backend/features/configurations/routes.ts b/src/backend/features/configurations/routes.ts index 5139309cc..7710dbc4b 100644 --- a/src/backend/features/configurations/routes.ts +++ b/src/backend/features/configurations/routes.ts @@ -35,6 +35,7 @@ configurationRoutes.get( const config = await db .select({ partMetadata: insertables.partMetadata, + vendors: insertables.vendors, parameters: configurations.parameters, records: configurations.records }) @@ -53,7 +54,8 @@ configurationRoutes.get( const result: ConfigurationResult = { parameters: config.parameters ?? [], records: toSearchRecords( - toRecords(config.partMetadata, config.records ?? []) + toRecords(config.partMetadata, config.records ?? []), + config.vendors ) }; return c.json(result); diff --git a/src/backend/features/configurations/utils.test.ts b/src/backend/features/configurations/utils.test.ts index a07012c99..184b08a76 100644 --- a/src/backend/features/configurations/utils.test.ts +++ b/src/backend/features/configurations/utils.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { findRecordForConfiguration, getPartUrl } from "./utils"; import { PartMetadata, SearchRecord } from "./models"; +import { Vendor } from "../library/vendors"; function rec( configuration: Record<string, string>, @@ -81,4 +82,28 @@ describe("getPartUrl", () => { getPartUrl(metadata({ vendor: "AM", partNumber: "am-1234" })) ).toBeUndefined(); }); + + it("falls back to the insertable's vendor when the record names none", () => { + const url = getPartUrl(metadata({ partNumber: "WCP-1025" }), [ + Vendor.WCP + ]); + expect(url).toBe("https://wcproducts.com/products/wcp-1025"); + }); + + it("will not guess between several, which do not say which this is", () => { + expect( + getPartUrl(metadata({ partNumber: "WCP-1025" }), [ + Vendor.WCP, + Vendor.MCM + ]) + ).toBeUndefined(); + }); + + it("prefers the record's own vendor over the insertable's", () => { + const url = getPartUrl( + metadata({ vendor: "McMaster-Carr", partNumber: "91251A445" }), + [Vendor.WCP] + ); + expect(url).toBe("https://www.mcmaster.com/91251A445/"); + }); }); diff --git a/src/backend/features/configurations/utils.ts b/src/backend/features/configurations/utils.ts index 38025488a..f53985d97 100644 --- a/src/backend/features/configurations/utils.ts +++ b/src/backend/features/configurations/utils.ts @@ -13,7 +13,7 @@ import { VisibilityCondition, VisibilityType } from "./models"; -import { getVendorPartUrl, toVendor } from "../library/vendors"; +import { Vendor, getVendorPartUrl, toVendor } from "../library/vendors"; import { LogicalOp, QuantityType, Unit } from "./enums"; import { type EvaluateOptions, valueWithUnits } from "./input-parser"; @@ -85,12 +85,22 @@ export function evaluateCondition( /** * The page for a part: a description that is already a url wins, since it names * the exact product, over one derived from the vendor and part number. + * + * Onshape's vendor field is often unset, so the insertable's own vendors stand + * in — but only when they name one, since a part configurable across several + * does not say which this record is. */ -export function getPartUrl(record: PartMetadata): string | undefined { +export function getPartUrl( + record: PartMetadata, + vendors: Vendor[] = [] +): string | undefined { if (record.description && /^https?:\/\//i.test(record.description)) { return record.description; } - return getVendorPartUrl(toVendor(record.vendor), record.partNumber); + const vendor = + toVendor(record.vendor) ?? + (vendors.length === 1 ? vendors[0] : undefined); + return getVendorPartUrl(vendor, record.partNumber); } export function encodeConfigurationForQuery( diff --git a/src/backend/features/library/vendors.test.ts b/src/backend/features/library/vendors.test.ts index 26df851e4..1de670b98 100644 --- a/src/backend/features/library/vendors.test.ts +++ b/src/backend/features/library/vendors.test.ts @@ -30,7 +30,10 @@ describe("toVendor", () => { it.each([ ["WCP", Vendor.WCP], ["custom", Vendor.CUSTOM], + ["West Coast Products", Vendor.WCP], + [" mcmaster-carr ", Vendor.MCM], ["Acme", undefined], + ["", undefined], [undefined, undefined] ])("resolves %s", (text, expected) => { expect(toVendor(text)).toBe(expected); diff --git a/src/backend/features/library/vendors.ts b/src/backend/features/library/vendors.ts index e40af3cc8..b82b8519e 100644 --- a/src/backend/features/library/vendors.ts +++ b/src/backend/features/library/vendors.ts @@ -15,10 +15,19 @@ export enum Vendor { CUSTOM = "Custom" } -/** Resolves the free text Onshape carries as a vendor to one we know. */ +/** + * Resolves the free text Onshape carries as a vendor to one we know, written + * either as its code or as its full name. + */ export function toVendor(vendor: string | undefined): Vendor | undefined { + const text = vendor?.trim().toUpperCase(); + if (!text) { + return undefined; + } return Object.values(Vendor).find( - (known) => known.toUpperCase() === vendor?.toUpperCase() + (known) => + known.toUpperCase() === text || + getVendorName(known).toUpperCase() === text ); } diff --git a/src/backend/features/search/search-index.ts b/src/backend/features/search/search-index.ts index 560287bff..246b6c72d 100644 --- a/src/backend/features/search/search-index.ts +++ b/src/backend/features/search/search-index.ts @@ -130,7 +130,8 @@ function uniqueJoin(values: (string | undefined)[]): string { * drops records with neither. First-wins is what keeps the latest revision. */ export function toSearchRecords( - records: ConfigurationRecord[] + records: ConfigurationRecord[], + vendors: Vendor[] = [] ): SearchRecord[] { const seen = new Set<string>(); const searchRecords: SearchRecord[] = []; @@ -146,7 +147,7 @@ export function toSearchRecords( searchRecords.push({ partNumber: record.partNumber, name: record.name, - url: getPartUrl(record), + url: getPartUrl(record, vendors), configuration: record.configuration }); } @@ -165,7 +166,10 @@ export function buildSearchDb( .filter((element) => !!element) .map((element) => { const parentGroup = libraryData.groups[element.groupId]; - const records = toSearchRecords(recordsMap[element.id] ?? []); + const records = toSearchRecords( + recordsMap[element.id] ?? [], + element.vendors + ); return { id: element.id, groupId: element.groupId, diff --git a/src/frontend/features/insert/components/configurations.tsx b/src/frontend/features/insert/components/configurations.tsx index d6bb80822..895e6f43d 100644 --- a/src/frontend/features/insert/components/configurations.tsx +++ b/src/frontend/features/insert/components/configurations.tsx @@ -8,7 +8,6 @@ import { Text, TextInput } from "@mantine/core"; -import { useQuery } from "@tanstack/react-query"; import { useSearch } from "@tanstack/react-router"; import { type Dispatch, @@ -19,7 +18,6 @@ import { useRef, useState } from "react"; -import { apiGet } from "../../../lib/api-client"; import { ParameterValues, ConfigurationResult, @@ -47,12 +45,10 @@ import { valueWithUnits, evaluateExpression } from "@backend/features/configurations/input-parser"; -import { useUnitInfoQuery } from "../queries"; -import { configurationQueryKey } from "../../../lib/query-keys"; +import { useConfigurationQuery, useUnitInfoQuery } from "../queries"; import { showErrorToast } from "../../../lib/notifications"; import { SectionError } from "../../../components/app-zero-state"; import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; -import { toInsertablePath } from "../../library/library-path"; interface ConfigurationWrapperProps { insertableId: string; @@ -86,16 +82,7 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { onRecord } = props; - const query = useQuery<ConfigurationResult>({ - queryKey: configurationQueryKey(insertableId, microversionId), - queryFn: async () => { - return apiGet("/configuration" + toInsertablePath(insertableId), { - cacheId: microversionId - }); - }, - // Don't refetch query automatically so we don't reset user inputs - refetchInterval: false - }); + const query = useConfigurationQuery(insertableId, microversionId); const search = useSearch({ from: "/app" }); // Units come from the current document; empty when not connected to one, in diff --git a/src/frontend/features/insert/components/insert-menu.tsx b/src/frontend/features/insert/components/insert-menu.tsx index 1bc7ca2a5..931fb8cfb 100644 --- a/src/frontend/features/insert/components/insert-menu.tsx +++ b/src/frontend/features/insert/components/insert-menu.tsx @@ -19,6 +19,7 @@ import { MenuButton } from "../../../components/app-menu"; import { InsertableMenuItems } from "../../library/components/insertable-card"; import { ConfigurationWrapper } from "./configurations"; import { useInsertMutation } from "../insert-hooks"; +import { useConfigurationQuery } from "../queries"; import { ParameterValues, SearchRecord @@ -64,15 +65,27 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { [] ); const [record, setRecord] = useState<SearchRecord | undefined>(undefined); + // A part with no parameters has one record — the element's own part data — + // which no ConfigurationWrapper is mounted to report, but the title wants. + const soleRecord = useConfigurationQuery( + insertable.id, + insertable.microversionId, + !insertable.isConfigurable + ).data?.records[0]; // The title lives in the modal's chrome, so it's updated rather than // rendered: the header follows the configuration as the user changes it. useEffect(() => { modals.updateModal({ modalId, - title: <MenuTitle name={insertable.name} record={record} /> + title: ( + <MenuTitle + name={insertable.name} + record={record ?? soleRecord} + /> + ) }); - }, [modalId, insertable.name, record]); + }, [modalId, insertable.name, record, soleRecord]); useEffect(() => { if (!isSignedIn) { diff --git a/src/frontend/features/insert/queries.ts b/src/frontend/features/insert/queries.ts index dc803833f..e8bf9f41a 100644 --- a/src/frontend/features/insert/queries.ts +++ b/src/frontend/features/insert/queries.ts @@ -2,10 +2,12 @@ import { useQuery } from "@tanstack/react-query"; import { apiGet } from "../../lib/api-client"; import { EMPTY_UNIT_INFO, + type ConfigurationResult, type UnitInfo } from "@backend/features/configurations/models"; import { InstancePath } from "@backend/lib/onshape/path"; -import { unitInfoQueryKey } from "../../lib/query-keys"; +import { configurationQueryKey, unitInfoQueryKey } from "../../lib/query-keys"; +import { toInsertablePath } from "../library/library-path"; /** * The current document's units. Disabled when not connected to a document, and @@ -26,3 +28,23 @@ export function useUnitInfoQuery(instancePath: InstancePath, enabled = true) { placeholderData: EMPTY_UNIT_INFO }); } + +/** + * An insertable's parameters and the records probed for them. Pinned to the + * microversion, so it is never refetched under a user mid-configuration. + */ +export function useConfigurationQuery( + insertableId: string, + microversionId: string, + enabled = true +) { + return useQuery<ConfigurationResult>({ + queryKey: configurationQueryKey(insertableId, microversionId), + queryFn: () => + apiGet("/configuration" + toInsertablePath(insertableId), { + cacheId: microversionId + }), + enabled, + refetchInterval: false + }); +} From 730508b5bb652b6f433fb95c23117053a7f0da1f Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 22 Aug 2026 22:57:47 +0000 Subject: [PATCH 43/56] Parse a record's vendor at load, where nothing sets one today MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onshape's vendor field is empty on every part, so records carried none and no part number could be linked. Parse one per record while the library loads, from the options its configuration selected and from the part's own name, through the same reader the element's vendors already use. A selection names the vendor more precisely than the part does, so it is read first, and an absent value counts as the parameter's default — which is what the element's own probe, configured with nothing, resolves to. Read before canonicalizing, which drops a selection that is the default. --- src/backend/features/configurations/models.ts | 1 + .../load/parse-configuration-records.test.ts | 38 +++++++++++ .../load/parse-configuration-records.ts | 17 ++++- .../features/load/parse-vendors.test.ts | 63 ++++++++++++++++++- src/backend/features/load/parse-vendors.ts | 43 +++++++++---- 5 files changed, 148 insertions(+), 14 deletions(-) diff --git a/src/backend/features/configurations/models.ts b/src/backend/features/configurations/models.ts index 394d7758d..fd08744b0 100644 --- a/src/backend/features/configurations/models.ts +++ b/src/backend/features/configurations/models.ts @@ -153,6 +153,7 @@ export interface PartMetadata { description?: string; /** Material display name, e.g. "6061 Aluminum". */ material?: string; + /** Onshape's own, or parsed from the part and its options when it has none. */ vendor?: string; /** True when the part studio resolved to more than one part. */ hasMultipleParts: boolean; diff --git a/src/backend/features/load/parse-configuration-records.test.ts b/src/backend/features/load/parse-configuration-records.test.ts index 28132f56f..8a36ab961 100644 --- a/src/backend/features/load/parse-configuration-records.test.ts +++ b/src/backend/features/load/parse-configuration-records.test.ts @@ -15,6 +15,7 @@ import { import { enumParam } from "../../../__test_utils__/configuration-fixtures"; import { ElementType } from "../../lib/onshape/element-type"; import { BuildIssueType } from "../build-checker/issues"; +import { Vendor } from "../library/vendors"; import { decideIndexing, parseAssemblyRecord, @@ -216,6 +217,43 @@ describe("parseConfigurationRecords", () => { expect(result.records.map((r) => r.partNumber)).toEqual(["PN-a2"]); }); + it("fills the vendor Onshape leaves unset, per configuration", async () => { + // Onshape reports no vendor; the option each record selected names it. + mockParts((configuration) => [ + { partId: "p", partNumber: `PN-${configuration.Vendor ?? "wcp"}` } + ]); + const vendorParam = enumParam("Vendor", ["wcp", "am"]); + + const result = await parseConfigurationRecords( + CLIENT, + PATH, + ElementType.PART_STUDIO, + [vendorParam], + countConfigurations([vendorParam]).configurations, + false + ); + + expect(result.partMetadata?.vendor).toBe(Vendor.WCP); + expect(result.records.map((r) => r.vendor)).toEqual([Vendor.AM]); + }); + + it("keeps the vendor Onshape does report", async () => { + mockParts(() => [ + { partId: "p", partNumber: "PN", vendor: "AndyMark", name: "WCP" } + ]); + + const result = await parseConfigurationRecords( + CLIENT, + PATH, + ElementType.PART_STUDIO, + [], + [], + false + ); + + expect(result.partMetadata?.vendor).toBe("AndyMark"); + }); + it("probes every combination when none of them is the default", async () => { mockParts((configuration) => [ { partId: "p", partNumber: `PN-${configuration.A ?? "default"}` } diff --git a/src/backend/features/load/parse-configuration-records.ts b/src/backend/features/load/parse-configuration-records.ts index 847f644c8..df799dc28 100644 --- a/src/backend/features/load/parse-configuration-records.ts +++ b/src/backend/features/load/parse-configuration-records.ts @@ -3,6 +3,7 @@ * is kept: search dedupes itself, and build checks read the ones it drops. */ import { OnshapeApi } from "../../lib/onshape/client"; +import { parseRecordVendor } from "./parse-vendors"; import { ElementPath } from "../../lib/onshape/path"; import { ElementType } from "../../lib/onshape/element-type"; import { @@ -363,6 +364,17 @@ async function fetchBatch( return records; } +/** Onshape's vendor when a part carries one, otherwise the parsed one. */ +function resolveVendor( + record: ConfigurationRecord, + parameters: ConfigurationParameter[] +): string | undefined { + return ( + record.vendor ?? + parseRecordVendor(record.name, record.configuration, parameters) + ); +} + /** Folds the default probe and every batch together, the default first. */ function toResult( defaultRecord: ConfigurationRecord, @@ -376,7 +388,7 @@ function toResult( name: defaultRecord.name, description: defaultRecord.description, material: defaultRecord.material, - vendor: defaultRecord.vendor, + vendor: resolveVendor(defaultRecord, parameters), hasMultipleParts: defaultRecord.hasMultipleParts, isOpenComposite: defaultRecord.isOpenComposite }; @@ -385,6 +397,9 @@ function toResult( // for the same selection. const records = batches.flat().map((record) => ({ ...record, + // Read before canonicalizing, which drops a selection that is the + // default — including a default vendor option. + vendor: resolveVendor(record, parameters), configuration: canonicalizeConfiguration( record.configuration, parameters diff --git a/src/backend/features/load/parse-vendors.test.ts b/src/backend/features/load/parse-vendors.test.ts index d4ff62235..a75efa46b 100644 --- a/src/backend/features/load/parse-vendors.test.ts +++ b/src/backend/features/load/parse-vendors.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest"; import { Vendor } from "../library/vendors"; import { ParameterType } from "../configurations/models"; import { QuantityType, Unit } from "../configurations/enums"; -import { parseNameVendor, parseVendors } from "./parse-vendors"; +import { + parseNameVendor, + parseRecordVendor, + parseVendors +} from "./parse-vendors"; describe("parseNameVendor", () => { it("detects vendor token in element name", () => { @@ -100,3 +104,60 @@ describe("parseVendors", () => { expect(parseVendors("Customizable Spacer", [])).toEqual([]); }); }); + +const vendorParameter = { + id: "vendor", + name: "Vendor", + default: "wcp", + isCosmetic: false, + type: ParameterType.ENUM as const, + options: [ + { id: "wcp", name: "West Coast Products" }, + { id: "am", name: "AndyMark" } + ], + optionConditions: [] +}; + +describe("parseRecordVendor", () => { + it("reads the selected option, by its full name", () => { + expect( + parseRecordVendor("Bearing", { vendor: "am" }, [vendorParameter]) + ).toBe(Vendor.AM); + }); + + it("reads a default selection, which is still a selection", () => { + expect( + parseRecordVendor("Bearing", { vendor: "wcp" }, [vendorParameter]) + ).toBe(Vendor.WCP); + }); + + it("treats an absent value as the parameter's default", () => { + expect(parseRecordVendor("Bearing", {}, [vendorParameter])).toBe( + Vendor.WCP + ); + }); + + it("prefers the selection over the part's own name", () => { + expect( + parseRecordVendor("REV Bearing", { vendor: "am" }, [ + vendorParameter + ]) + ).toBe(Vendor.AM); + }); + + it("falls back to the part name when nothing is selected", () => { + expect(parseRecordVendor("WCP-1025 Gearbox", {}, [])).toBe(Vendor.WCP); + }); + + it("reads a vendor off a part name that is only a part number", () => { + expect(parseRecordVendor("WCP-1025", {}, [])).toBe(Vendor.WCP); + }); + + it("has none when neither names a vendor", () => { + expect(parseRecordVendor("Generic Bearing", {}, [])).toBeUndefined(); + }); + + it("has none without a part name", () => { + expect(parseRecordVendor(undefined, {}, [])).toBeUndefined(); + }); +}); diff --git a/src/backend/features/load/parse-vendors.ts b/src/backend/features/load/parse-vendors.ts index 157e88a20..5c725deaa 100644 --- a/src/backend/features/load/parse-vendors.ts +++ b/src/backend/features/load/parse-vendors.ts @@ -1,7 +1,8 @@ -import { Vendor, getVendorName } from "../library/vendors"; +import { Vendor, toVendor } from "../library/vendors"; import { ParameterType, - type ConfigurationParameter + type ConfigurationParameter, + type ParameterValues } from "../configurations/models"; export function parseNameVendor(name: string): Vendor | undefined { @@ -15,6 +16,11 @@ export function parseNameVendor(name: string): Vendor | undefined { return undefined; } +/** A vendor an option names, as a token within its label or as the whole of it. */ +function parseOptionVendor(optionName: string): Vendor | undefined { + return parseNameVendor(optionName) ?? toVendor(optionName); +} + export function parseVendors( name: string, parameters: ConfigurationParameter[] @@ -26,17 +32,30 @@ export function parseVendors( for (const param of parameters) { if (param.type !== ParameterType.ENUM) continue; for (const option of param.options) { - const vendor = parseNameVendor(option.name); - if (vendor) { - vendors.add(vendor); - continue; - } - const byFullName = Object.values(Vendor).find( - (v) => - getVendorName(v).toUpperCase() === option.name.toUpperCase() - ); - if (byFullName) vendors.add(byFullName); + const vendor = parseOptionVendor(option.name); + if (vendor) vendors.add(vendor); } } return [...vendors]; } + +/** + * The vendor one configuration resolves to. Its selected options name it more + * precisely than the part does, so they are read before the part's own name. + */ +export function parseRecordVendor( + partName: string | undefined, + configuration: ParameterValues, + parameters: ConfigurationParameter[] +): Vendor | undefined { + for (const param of parameters) { + if (param.type !== ParameterType.ENUM) continue; + // An absent value is the parameter's default, which is what the + // element's own probe — configured with nothing — resolves to. + const selected = configuration[param.id] ?? param.default; + const option = param.options.find((o) => o.id === selected); + const vendor = option && parseOptionVendor(option.name); + if (vendor) return vendor; + } + return partName ? parseNameVendor(partName) : undefined; +} From 8226ba32c303d3baa4b4205c09dac8555c3bda71 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 23 Aug 2026 06:10:07 +0000 Subject: [PATCH 44/56] Give the title's second line its own leading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A modal title's line-height is 1, and the second line inherited it, so it had no leading under its baseline while the first has plenty above its caps — the block read low in a header padded evenly. Set a line-height on that row: the caps now sit 16.9px from the top and the last baseline 17.4px from the bottom, against 16.9 and 15.0 before. --- src/frontend/components/app-title.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/frontend/components/app-title.tsx b/src/frontend/components/app-title.tsx index 923559435..055db89f2 100644 --- a/src/frontend/components/app-title.tsx +++ b/src/frontend/components/app-title.tsx @@ -39,7 +39,10 @@ export function AppTitle(props: AppTitleProps): ReactNode { {rightSection} </Group> {subtitle && ( - <Group gap={4} wrap="nowrap" fz="xs" c="dimmed"> + // lh, because the title's own is 1: inheriting that + // leaves no leading under the last line, and the block + // reads low against a header padded evenly. + <Group gap={4} wrap="nowrap" fz="xs" lh="xs" c="dimmed"> {subtitle} </Group> )} From 9c3ffe9bfea045dd07a3a53ee869e339b5b44697 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 23 Aug 2026 06:35:30 +0000 Subject: [PATCH 45/56] Drop the external link icon beside the part number It sat outside the anchor, so it took the row's dimmed color while the link took the accent, and read as disabled next to it. The link and the copy button carry the affordance on their own. --- src/frontend/components/app-title.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/frontend/components/app-title.tsx b/src/frontend/components/app-title.tsx index 055db89f2..4e3f81df5 100644 --- a/src/frontend/components/app-title.tsx +++ b/src/frontend/components/app-title.tsx @@ -8,7 +8,7 @@ import { Text, Tooltip } from "@mantine/core"; -import { ArrowSquareOut, Check, Copy } from "@phosphor-icons/react"; +import { Check, Copy } from "@phosphor-icons/react"; import type { ReactNode } from "react"; import type { SearchRecord } from "@backend/features/configurations/models"; import { FontWeight, IconSize, TITLE_ICON_NUDGE } from "../lib/style-constants"; @@ -117,7 +117,6 @@ function PartNumber({ > {partNumber} </Anchor> - <ArrowSquareOut size={IconSize.TINY} /> <CopyButton value={url}> {({ copied, copy }) => ( <Tooltip label={copied ? "Copied" : "Copy link"} withArrow> From 0a119d3c7f33c38b9e72b15a565fee305471d044 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 23 Aug 2026 13:54:45 +0000 Subject: [PATCH 46/56] Search REV and TTB, and put the launch icon inside the link Neither has a per-part url, so point at their product search instead, with the part number escaped as a query value rather than pasted into a path. The launch icon goes back beside the part number, inside the anchor so it takes the link's color rather than the row's dimmed one. The anchor lays its two children out in a row, which centres the icon on the text instead of dropping it onto the baseline. The copy button now appears only when there is no page to open, and copies the part number, which is what there is left to search with. --- src/backend/features/library/vendors.test.ts | 20 +++++- src/backend/features/library/vendors.ts | 13 ++-- src/frontend/components/app-title.tsx | 75 +++++++++++--------- 3 files changed, 70 insertions(+), 38 deletions(-) diff --git a/src/backend/features/library/vendors.test.ts b/src/backend/features/library/vendors.test.ts index 1de670b98..ef1843420 100644 --- a/src/backend/features/library/vendors.test.ts +++ b/src/backend/features/library/vendors.test.ts @@ -14,7 +14,25 @@ describe("getVendorPartUrl", () => { ); }); - it.each([Vendor.AM, Vendor.REV, Vendor.CUSTOM])( + it("searches REV, which has no per-part url", () => { + expect(getVendorPartUrl(Vendor.REV, "REV-42-1442")).toBe( + "https://www.revrobotics.com/search.php?search_query=REV-42-1442§ion=product" + ); + }); + + it("searches The Thrifty Bot, likewise", () => { + expect(getVendorPartUrl(Vendor.TTB, "TTB-0008")).toBe( + "https://www.thethriftybot.com/search?type=product&q=TTB-0008" + ); + }); + + it("escapes a part number before putting it in a url", () => { + expect(getVendorPartUrl(Vendor.TTB, "TTB 1&2")).toBe( + "https://www.thethriftybot.com/search?type=product&q=TTB%201%262" + ); + }); + + it.each([Vendor.AM, Vendor.SDS, Vendor.CUSTOM])( "has no derivable page for %s", (vendor) => { expect(getVendorPartUrl(vendor, "12345")).toBeUndefined(); diff --git a/src/backend/features/library/vendors.ts b/src/backend/features/library/vendors.ts index b82b8519e..76f5d820a 100644 --- a/src/backend/features/library/vendors.ts +++ b/src/backend/features/library/vendors.ts @@ -32,8 +32,8 @@ export function toVendor(vendor: string | undefined): Vendor | undefined { } /** - * The vendor's own page for a part. Most vendors have no url derivable from a - * part number, so this is undefined for all but the few that do. + * The vendor's page for a part, or its search for one where that is all the + * site offers. Most vendors have no url derivable from a part number at all. */ export function getVendorPartUrl( vendor: Vendor | undefined, @@ -42,11 +42,16 @@ export function getVendorPartUrl( if (!partNumber) { return undefined; } + const query = encodeURIComponent(partNumber); switch (vendor) { case Vendor.MCM: - return `https://www.mcmaster.com/${partNumber}/`; + return `https://www.mcmaster.com/${query}/`; case Vendor.WCP: - return `https://wcproducts.com/products/${partNumber.toLowerCase()}`; + return `https://wcproducts.com/products/${query.toLowerCase()}`; + case Vendor.REV: + return `https://www.revrobotics.com/search.php?search_query=${query}§ion=product`; + case Vendor.TTB: + return `https://www.thethriftybot.com/search?type=product&q=${query}`; default: return undefined; } diff --git a/src/frontend/components/app-title.tsx b/src/frontend/components/app-title.tsx index 4e3f81df5..fbbbbc45c 100644 --- a/src/frontend/components/app-title.tsx +++ b/src/frontend/components/app-title.tsx @@ -8,7 +8,7 @@ import { Text, Tooltip } from "@mantine/core"; -import { Check, Copy } from "@phosphor-icons/react"; +import { ArrowSquareOut, Check, Copy } from "@phosphor-icons/react"; import type { ReactNode } from "react"; import type { SearchRecord } from "@backend/features/configurations/models"; import { FontWeight, IconSize, TITLE_ICON_NUDGE } from "../lib/style-constants"; @@ -99,43 +99,52 @@ function PartNumber({ partNumber: string; url?: string; }): ReactNode { + // Nowhere to send them, so offer the number itself to search with. if (!url) { return ( - <Text inherit truncate> - {partNumber} - </Text> + <> + <Text inherit truncate> + {partNumber} + </Text> + <CopyButton value={partNumber}> + {({ copied, copy }) => ( + <Tooltip + label={copied ? "Copied" : "Copy part number"} + withArrow + > + <ActionIcon + variant="subtle" + color={copied ? "teal" : "gray"} + size="xs" + aria-label="Copy part number" + onClick={copy} + > + {copied ? ( + <Check size={IconSize.TINY} /> + ) : ( + <Copy size={IconSize.TINY} /> + )} + </ActionIcon> + </Tooltip> + )} + </CopyButton> + </> ); } return ( - <> - <Anchor - href={url} - target="_blank" - inherit - truncate - onClick={(event) => event.stopPropagation()} - > + // inline-flex so the icon centres on the text rather than sitting on + // its baseline, and takes the link's color by being inside it. + <Anchor + href={url} + target="_blank" + inherit + onClick={(event) => event.stopPropagation()} + style={{ display: "inline-flex", alignItems: "center", gap: 2 }} + > + <Text component="span" inherit truncate> {partNumber} - </Anchor> - <CopyButton value={url}> - {({ copied, copy }) => ( - <Tooltip label={copied ? "Copied" : "Copy link"} withArrow> - <ActionIcon - variant="subtle" - color={copied ? "teal" : "gray"} - size="xs" - aria-label="Copy link" - onClick={copy} - > - {copied ? ( - <Check size={IconSize.TINY} /> - ) : ( - <Copy size={IconSize.TINY} /> - )} - </ActionIcon> - </Tooltip> - )} - </CopyButton> - </> + </Text> + <ArrowSquareOut size={IconSize.TINY} /> + </Anchor> ); } From 92208ef147ece0596b3455800517b20db5090715 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 23 Aug 2026 15:57:36 +0000 Subject: [PATCH 47/56] Reload thumbnails through the load alone, and read NO_PARTS as an issue Drop the admin reload-thumbnail option and the two routes behind it. The load is the only path now, so widen its polling: cap the doubling at two minutes and raise the limit, turning seventeen minutes of increasingly coarse waits into half an hour that still checks every two. Insertables load in parallel, so a long tail holds nothing else up. Read parts raises NO_PARTS itself rather than reporting a flag the caller turns into one, and is named for what it reads rather than for the one thing it used to derive. Also search AndyMark, which has no per-part url either. --- .../features/build-checker/issues.test.ts | 25 ++++ src/backend/features/build-checker/issues.ts | 8 ++ .../features/configurations/utils.test.ts | 2 +- src/backend/features/library/vendors.test.ts | 8 +- src/backend/features/library/vendors.ts | 2 + src/backend/features/load/load-insertable.ts | 21 ++-- src/backend/features/load/steps.test.ts | 17 +-- src/backend/features/load/steps.ts | 17 ++- src/backend/features/thumbnails/routes.ts | 119 ------------------ src/frontend/features/library/card-hooks.ts | 27 +--- .../library/components/card-components.tsx | 26 ---- .../library/components/group-card.tsx | 4 +- .../library/components/insertable-card.tsx | 7 +- 13 files changed, 82 insertions(+), 201 deletions(-) diff --git a/src/backend/features/build-checker/issues.test.ts b/src/backend/features/build-checker/issues.test.ts index f948d3b78..64ccfc98b 100644 --- a/src/backend/features/build-checker/issues.test.ts +++ b/src/backend/features/build-checker/issues.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { addBuildIssue, + hasBuildIssue, BuildIssue, BuildIssueSeverity, BuildIssueType, @@ -77,6 +78,30 @@ describe("addBuildIssue", () => { }); }); +describe("hasBuildIssue", () => { + const issues = [ + { type: BuildIssueType.NO_PARTS }, + { type: BuildIssueType.NO_VENDORS } + ]; + + it("finds one of the types asked for", () => { + expect(hasBuildIssue(issues, BuildIssueType.NO_PARTS)).toBe(true); + expect( + hasBuildIssue( + issues, + BuildIssueType.LOAD_FAILED, + BuildIssueType.NO_VENDORS + ) + ).toBe(true); + }); + + it("finds none of them", () => { + expect(hasBuildIssue(issues, BuildIssueType.LOAD_FAILED)).toBe(false); + expect(hasBuildIssue([], BuildIssueType.NO_PARTS)).toBe(false); + expect(hasBuildIssue(issues)).toBe(false); + }); +}); + describe("clearBuildIssue", () => { it("removes issues with the given type", () => { const result = clearBuildIssue( diff --git a/src/backend/features/build-checker/issues.ts b/src/backend/features/build-checker/issues.ts index c768054e7..112fac38f 100644 --- a/src/backend/features/build-checker/issues.ts +++ b/src/backend/features/build-checker/issues.ts @@ -121,6 +121,14 @@ export function addBuildIssue( return result; } +/** Whether `issues` holds one of `types`. */ +export function hasBuildIssue( + issues: BuildIssue[], + ...types: BuildIssueType[] +): boolean { + return issues.some((issue) => types.includes(issue.type)); +} + /** * Removes any issue whose type is one of `types`. */ diff --git a/src/backend/features/configurations/utils.test.ts b/src/backend/features/configurations/utils.test.ts index 184b08a76..a24ef30cb 100644 --- a/src/backend/features/configurations/utils.test.ts +++ b/src/backend/features/configurations/utils.test.ts @@ -79,7 +79,7 @@ describe("getPartUrl", () => { it("has none for a vendor whose urls cannot be derived", () => { expect( - getPartUrl(metadata({ vendor: "AM", partNumber: "am-1234" })) + getPartUrl(metadata({ vendor: "SDS", partNumber: "sds-1234" })) ).toBeUndefined(); }); diff --git a/src/backend/features/library/vendors.test.ts b/src/backend/features/library/vendors.test.ts index ef1843420..151aee8a5 100644 --- a/src/backend/features/library/vendors.test.ts +++ b/src/backend/features/library/vendors.test.ts @@ -14,6 +14,12 @@ describe("getVendorPartUrl", () => { ); }); + it("searches AndyMark, whose search is lowercase", () => { + expect(getVendorPartUrl(Vendor.AM, "AM-5833")).toBe( + "https://andymark.com/pages/search-results-page?q=am-5833" + ); + }); + it("searches REV, which has no per-part url", () => { expect(getVendorPartUrl(Vendor.REV, "REV-42-1442")).toBe( "https://www.revrobotics.com/search.php?search_query=REV-42-1442§ion=product" @@ -32,7 +38,7 @@ describe("getVendorPartUrl", () => { ); }); - it.each([Vendor.AM, Vendor.SDS, Vendor.CUSTOM])( + it.each([Vendor.SDS, Vendor.VEX, Vendor.CUSTOM])( "has no derivable page for %s", (vendor) => { expect(getVendorPartUrl(vendor, "12345")).toBeUndefined(); diff --git a/src/backend/features/library/vendors.ts b/src/backend/features/library/vendors.ts index 76f5d820a..f3d59b7f4 100644 --- a/src/backend/features/library/vendors.ts +++ b/src/backend/features/library/vendors.ts @@ -48,6 +48,8 @@ export function getVendorPartUrl( return `https://www.mcmaster.com/${query}/`; case Vendor.WCP: return `https://wcproducts.com/products/${query.toLowerCase()}`; + case Vendor.AM: + return `https://andymark.com/pages/search-results-page?q=${query.toLowerCase()}`; case Vendor.REV: return `https://www.revrobotics.com/search.php?search_query=${query}§ion=product`; case Vendor.TTB: diff --git a/src/backend/features/load/load-insertable.ts b/src/backend/features/load/load-insertable.ts index 6f8da25ad..d7f87634f 100644 --- a/src/backend/features/load/load-insertable.ts +++ b/src/backend/features/load/load-insertable.ts @@ -8,7 +8,8 @@ import type { import { addBuildIssue, type BuildIssue, - BuildIssueType + BuildIssueType, + hasBuildIssue } from "../build-checker/issues"; import { ElementType } from "../../lib/onshape/element-type"; import type { FastenInfo } from "../library/insertables/fasten"; @@ -74,7 +75,11 @@ export async function loadInsertable( ? await parseFastenInfoStep(ctx, target) : null; - const { isOpenComposite, hasParts } = await readPartsStep(ctx, target); + const parts = await readPartsStep(ctx, target); + const { isOpenComposite } = parts; + // An empty studio renders nothing and probes to nothing, so what it raises + // decides how much of the rest of the load is worth running. + const hasParts = !hasBuildIssue(parts.buildIssues, BuildIssueType.NO_PARTS); const indexing = decideIndexing(parameters, flags.indexConfigurations); @@ -113,7 +118,7 @@ export async function loadInsertable( thumbnailUrls, probes: [recordsResult.partMetadata, ...recordsResult.records] }) - : [{ type: BuildIssueType.NO_PARTS }], + : parts.buildIssues, ...recordsResult.buildIssues, ...indexing.buildIssues ); @@ -170,7 +175,8 @@ function parseConfigurationStep( /** What one look at a part studio's default parts tells the rest of the load. */ interface PartsSummary { isOpenComposite: boolean; - hasParts: boolean; + /** NO_PARTS when the studio is empty; the rest of the load reads it. */ + buildIssues: BuildIssue[]; } /** @@ -182,9 +188,9 @@ function readPartsStep( { insertableId, elementPath, elementType }: InsertableTarget ): Promise<PartsSummary> { if (elementType !== ElementType.PART_STUDIO) { - return Promise.resolve({ isOpenComposite: false, hasParts: true }); + return Promise.resolve({ isOpenComposite: false, buildIssues: [] }); } - return ctx.step.do(`open-composite-${insertableId}`, async () => { + return ctx.step.do(`parts-${insertableId}`, async () => { const parts = await getParts( await getOnshapeApiFromContext(ctx), elementPath, @@ -192,7 +198,8 @@ function readPartsStep( ); return { isOpenComposite: computeOpenComposite(parts), - hasParts: parts.length > 0 + buildIssues: + parts.length > 0 ? [] : [{ type: BuildIssueType.NO_PARTS }] }; }); } diff --git a/src/backend/features/load/steps.test.ts b/src/backend/features/load/steps.test.ts index 113cf978e..e1c820978 100644 --- a/src/backend/features/load/steps.test.ts +++ b/src/backend/features/load/steps.test.ts @@ -12,28 +12,29 @@ describe("THUMBNAIL_STEP_RETRIES", () => { // Onshape gives no signal when a render lands, so the step polls. Starting // at four seconds keeps a quick render from waiting on a long first delay. it("doubles from four seconds", () => { - expect([1, 2, 3, 4, 5, 6].map((a) => thumbnailDelay(a))).toEqual([ + expect([1, 2, 3, 4, 5].map((a) => thumbnailDelay(a))).toEqual([ "4 seconds", "8 seconds", "16 seconds", "32 seconds", - "64 seconds", - "128 seconds" + "64 seconds" ]); }); - it("keeps doubling rather than settling on a ceiling", () => { - expect(thumbnailDelay(7)).toEqual("256 seconds"); - expect(thumbnailDelay(8)).toEqual("512 seconds"); + // Uncapped, the last waits outgrow the renders themselves, leaving a + // thumbnail that landed early unnoticed for minutes. + it("stops doubling at two minutes", () => { + expect(thumbnailDelay(6)).toEqual("120 seconds"); + expect(thumbnailDelay(12)).toEqual("120 seconds"); }); - it("polls for about seventeen minutes before giving up", () => { + it("polls for about half an hour before giving up", () => { const total = Array.from( { length: THUMBNAIL_STEP_RETRIES.limit - 1 }, (_, i) => Number.parseInt(thumbnailDelay(i + 1), 10) ).reduce((sum, seconds) => sum + seconds, 0); - expect(total).toBe(1020); + expect(total).toBe(1804); }); // A warm request naming a configuration that matches nothing would diff --git a/src/backend/features/load/steps.ts b/src/backend/features/load/steps.ts index 577ef9f9b..b798ea6dc 100644 --- a/src/backend/features/load/steps.ts +++ b/src/backend/features/load/steps.ts @@ -40,6 +40,12 @@ export const ONSHAPE_STEP_RETRIES = { /** The first wait after a render isn't ready; each attempt doubles it. */ const THUMBNAIL_BASE_DELAY_SECONDS = 4; +/** + * Where the doubling stops. Left uncapped, the last waits grow longer than the + * renders themselves, so a thumbnail that landed early sits unnoticed. + */ +const THUMBNAIL_MAX_DELAY_SECONDS = 120; + /** * Onshape gives no signal when a render lands, so the step polls, doubling from * four seconds. A rate limit overrides the curve. @@ -53,14 +59,17 @@ function thumbnailRetryDelay(input: RetryDelayInput): `${number} seconds` { if (input.error instanceof NoSuchConfigurationError) { return "0 seconds"; } - const seconds = THUMBNAIL_BASE_DELAY_SECONDS * 2 ** (input.ctx.attempt - 1); + const seconds = Math.min( + THUMBNAIL_BASE_DELAY_SECONDS * 2 ** (input.ctx.attempt - 1), + THUMBNAIL_MAX_DELAY_SECONDS + ); return `${seconds} seconds`; } export const THUMBNAIL_STEP_RETRIES = { - // 4s, 8s … 512s: a bit over seventeen minutes of polling, which a slow - // Onshape render is worth waiting out. - limit: 9, + // 4s, 8s … 120s and then every two minutes: half an hour of polling, since + // a reload is the only other way to pick a late render up. + limit: 20, delay: thumbnailRetryDelay }; diff --git a/src/backend/features/thumbnails/routes.ts b/src/backend/features/thumbnails/routes.ts index e8e9cd3dd..727226368 100644 --- a/src/backend/features/thumbnails/routes.ts +++ b/src/backend/features/thumbnails/routes.ts @@ -1,23 +1,8 @@ -import { eq } from "drizzle-orm"; import { z } from "zod"; import { validate } from "../../lib/validate"; import { CachePolicy, cacheMiddleware, setCacheTtl } from "../../lib/cache"; import { getApp } from "../../lib/context"; -import { - getGroupParam, - getInsertableParam, - groupRoute, - insertableRoute -} from "../../lib/route-params"; -import { getInsertableElementPath } from "../library/insertables/routes"; -import { getDb } from "../../db/client"; -import { requireEditorMiddleware } from "../auth/guards"; -import { bumpLibraryVersion } from "../library/db"; -import { type InstancePath } from "../../lib/onshape/path"; -import { group, insertables } from "../../db/schema"; -import { internalError } from "../../lib/api-error"; -import { HttpStatus } from "http-status-ts"; import { ThumbnailSize } from "./types"; import { THUMBNAIL_FALLBACK_CACHE_TTL, @@ -33,8 +18,6 @@ import { import type { AppContext } from "../../lib/context"; import type { ThumbnailWorkflowParams } from "./workflow"; import { getSessionId } from "../auth/session"; -import { BuildIssueType, clearBuildIssue } from "../build-checker/issues"; -import { uploadDocumentThumbnails, uploadThumbnails } from "./store"; export const thumbnailRoutes = getApp(); @@ -134,105 +117,3 @@ async function warmConfigurationThumbnail( // Never fatal: the caller still has the default thumbnail to serve. } } - -/** POST /api/reload-insertable-thumbnail/insertable/:insertableId */ -thumbnailRoutes.post( - "/reload-insertable-thumbnail" + insertableRoute(), - requireEditorMiddleware, - async (c) => { - const onshapeApi = await c.var.getOnshapeApi(); - const insertableId = getInsertableParam(c); - const db = getDb(c.env.DB); - - const elementPath = await getInsertableElementPath(db, insertableId); - - const row = await db - .select({ - microversionId: insertables.microversionId, - libraryId: insertables.libraryId, - buildIssues: insertables.buildIssues - }) - .from(insertables) - .where(eq(insertables.id, insertableId)) - .get(); - - if (!row) { - throw internalError("Insertable not found", HttpStatus.NOT_FOUND); - } - - const thumbnails = await uploadThumbnails( - c.env.BLOB, - onshapeApi, - elementPath, - row.microversionId - ); - - await db - .update(insertables) - .set({ - smallThumbnailUrl: thumbnails.small, - largeThumbnailUrl: thumbnails.large, - buildIssues: clearBuildIssue( - row.buildIssues, - BuildIssueType.THUMBNAIL_FAILED - ) - }) - .where(eq(insertables.id, insertableId)); - - await bumpLibraryVersion(db, row.libraryId); - return c.json({ success: true }); - } -); - -/** POST /api/reload-group-thumbnail/group/:groupId */ -thumbnailRoutes.post( - "/reload-group-thumbnail" + groupRoute(), - requireEditorMiddleware, - async (c) => { - const onshapeApi = await c.var.getOnshapeApi(); - const groupId = getGroupParam(c); - const db = getDb(c.env.DB); - - const row = await db - .select({ - documentId: group.documentId, - versionId: group.versionId, - libraryId: group.libraryId, - buildIssues: group.buildIssues - }) - .from(group) - .where(eq(group.id, groupId)) - .get(); - - if (!row) { - throw internalError("Group not found", HttpStatus.NOT_FOUND); - } - - const instancePath: InstancePath = { - documentId: row.documentId, - instanceId: row.versionId, - instanceType: "v" - }; - - const thumbnails = await uploadDocumentThumbnails( - c.env.BLOB, - onshapeApi, - instancePath - ); - - await db - .update(group) - .set({ - smallThumbnailUrl: thumbnails.small, - largeThumbnailUrl: thumbnails.large, - buildIssues: clearBuildIssue( - row.buildIssues, - BuildIssueType.THUMBNAIL_FAILED - ) - }) - .where(eq(group.id, groupId)); - - await bumpLibraryVersion(db, row.libraryId); - return c.json({ success: true }); - } -); diff --git a/src/frontend/features/library/card-hooks.ts b/src/frontend/features/library/card-hooks.ts index 6d63bf494..1169eea1a 100644 --- a/src/frontend/features/library/card-hooks.ts +++ b/src/frontend/features/library/card-hooks.ts @@ -11,12 +11,7 @@ import { showLoadingToast, showSuccessToast } from "../../lib/notifications"; -import { - toGroupPath, - toInsertablePath, - toLibraryPath, - useLibraryId -} from "./library-path"; +import { toInsertablePath, toLibraryPath, useLibraryId } from "./library-path"; import { getAppErrorHandler } from "../../lib/errors"; import { useCacheVersion } from "./queries"; import { buildStatusQueryKey } from "../../lib/query-keys"; @@ -110,26 +105,6 @@ export function useIsInsertableHidden(insertable: InsertableOut): boolean { }, [insertable.isVisible, currentAccessLevel]); } -export function useReloadThumbnailMutation(id: string, isGroup: boolean) { - const refreshLibrary = useRefreshLibrary(); - - const endpoint = isGroup - ? "/reload-group-thumbnail" + toGroupPath(id) - : "/reload-insertable-thumbnail" + toInsertablePath(id); - - return useMutation({ - mutationKey: ["thumbnail", "reload", id], - mutationFn: async () => { - return apiPost(endpoint); - }, - onError: getAppErrorHandler("Unexpectedly failed to reload thumbnail."), - onSuccess: () => { - showSuccessToast("Successfully reloaded thumbnail."); - }, - onSettled: refreshLibrary - }); -} - /** Toggles an insertable's "insert and fasten" support (a slow Onshape call). */ export function useToggleInsertAndFastenMutation(insertableId: string) { const key = useBuildStatusKey(); diff --git a/src/frontend/features/library/components/card-components.tsx b/src/frontend/features/library/components/card-components.tsx index 2198811ef..9d7722c7e 100644 --- a/src/frontend/features/library/components/card-components.tsx +++ b/src/frontend/features/library/components/card-components.tsx @@ -1,7 +1,6 @@ import { Box, Group, Menu, Stack, Table, Text } from "@mantine/core"; import { ArrowSquareOut, - ArrowsClockwise, EyeSlash, Gear, Link, @@ -32,7 +31,6 @@ import { ElementType } from "@backend/lib/onshape/element-type"; import { ParameterValues } from "@backend/features/configurations/models"; import { useSearch } from "@tanstack/react-router"; import { RequireAccessLevel } from "../../auth/access-level"; -import { useReloadThumbnailMutation } from "../card-hooks"; interface OpenDocumentItemsProps { path: InstancePath | ConfigurablePath; @@ -297,27 +295,3 @@ export function AdminOptionsSubmenu(props: PropsWithChildren): ReactNode { </RequireAccessLevel> ); } - -interface ReloadThumbnailMenuItemProps { - id: string; - isGroup: boolean; -} - -export function ReloadThumbnailMenuItem( - props: ReloadThumbnailMenuItemProps -): ReactNode { - const reloadThumbnailMutation = useReloadThumbnailMutation( - props.id, - props.isGroup - ); - return ( - <Menu.Item - leftSection={<ArrowsClockwise size={IconSize.SMALL} />} - onClick={() => { - reloadThumbnailMutation.mutate(); - }} - > - Reload thumbnail - </Menu.Item> - ); -} diff --git a/src/frontend/features/library/components/group-card.tsx b/src/frontend/features/library/components/group-card.tsx index 226ab66df..230c02321 100644 --- a/src/frontend/features/library/components/group-card.tsx +++ b/src/frontend/features/library/components/group-card.tsx @@ -14,8 +14,7 @@ import { AdminOptionsSubmenu, CardTitle, ItemRow, - OpenDocumentItems, - ReloadThumbnailMenuItem + OpenDocumentItems } from "./card-components"; import { AddGroupItem } from "./add-group-menu"; import { GroupStatusBadge } from "../../build-status/components/build-status"; @@ -112,7 +111,6 @@ export function GroupAdminContextMenu({ <HideAllElementsMenuItem insertableOrder={groupStatus.insertableOrder} /> - <ReloadThumbnailMenuItem id={groupId} isGroup={true} /> {isHome && ( <> <Menu.Divider /> diff --git a/src/frontend/features/library/components/insertable-card.tsx b/src/frontend/features/library/components/insertable-card.tsx index 629998d84..b8600743a 100644 --- a/src/frontend/features/library/components/insertable-card.tsx +++ b/src/frontend/features/library/components/insertable-card.tsx @@ -15,12 +15,10 @@ import { import { useIsInsertableHidden } from "../card-hooks"; import { InsertableStatusBadge } from "../../build-status/components/build-status"; import { - AdminOptionsSubmenu, CardTitle, ItemRow, OpenDocumentItems, - QuickInsertItems, - ReloadThumbnailMenuItem + QuickInsertItems } from "./card-components"; import { openCannotDeriveAssemblyAlert } from "../../../components/alerts"; import { useIsAssemblyInPartStudio } from "../../insert/insert-hooks"; @@ -151,9 +149,6 @@ export function InsertableMenuItems( <Menu.Divider /> </RequireSignIn> <OpenDocumentItems path={{ ...insertable.path, configuration }} /> - <AdminOptionsSubmenu> - <ReloadThumbnailMenuItem id={insertable.id} isGroup={false} /> - </AdminOptionsSubmenu> </> ); } From c862756bea8b6cc054ff834ba41930fbd93bb669 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 23 Aug 2026 16:45:21 +0000 Subject: [PATCH 48/56] Keep the userId on the session and tidy part numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store the caller's Onshape user id on the session record instead of a parallel `user-id:` KV entry, so a sign-in keeps one key and a token refresh carries the resolved id forward. Drop a part number that only repeats the part name: the library uses that for generic parts with no real number, and it identifies nothing, so it should not be shown, searched, or linked to a vendor. Ellipsize the part numbers that remain — some run to 80 characters — by letting the modal title shrink and giving the number the room the part name leaves rather than sharing the overflow between them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- src/backend/features/auth/caller.ts | 31 +++++----- src/backend/features/auth/onshape-oauth.ts | 4 +- src/backend/features/auth/session.ts | 27 ++++++--- .../features/search/search-index.test.ts | 57 +++++++++++++++++++ src/backend/features/search/search-index.ts | 17 +++++- src/frontend/components/app-title.tsx | 33 +++++++++-- src/frontend/components/open-app-modal.tsx | 3 + 7 files changed, 137 insertions(+), 35 deletions(-) create mode 100644 src/backend/features/search/search-index.test.ts diff --git a/src/backend/features/auth/caller.ts b/src/backend/features/auth/caller.ts index 7acc7847f..e65dbb6e2 100644 --- a/src/backend/features/auth/caller.ts +++ b/src/backend/features/auth/caller.ts @@ -18,11 +18,10 @@ import { TOKEN_ENDPOINT } from "./onshape-oauth"; import { - SESSION_TTL, + getSession, getSessionCompanyId, getSessionId, - getTokens, - saveTokens + saveSession } from "./session"; /** How long a resolved access level is cached in KV. */ @@ -35,22 +34,23 @@ export async function getOnshapeApiFromSessionId( kv: KVNamespace, sessionId: string ): Promise<OAuthApi> { - const tokens = await getTokens(kv, sessionId); + const session = await getSession(kv, sessionId); const refreshCallback = async () => { const oauthClient = getOauthClient(); const newTokens = await oauthClient - .refreshAccessToken(TOKEN_ENDPOINT, tokens.refreshToken, []) + .refreshAccessToken(TOKEN_ENDPOINT, session.refreshToken, []) .then((refreshed) => makeAuthTokens(refreshed)); - void saveTokens(kv, sessionId, newTokens); + // Spread, so a refresh keeps the userId the session already resolved. + void saveSession(kv, sessionId, { ...session, ...newTokens }); return newTokens.accessToken; }; - let accessToken = tokens.accessToken; + let accessToken = session.accessToken; // If the token expired in the past, refresh immediately - if (tokens.expiresAt <= Date.now()) { + if (session.expiresAt <= Date.now()) { accessToken = await refreshCallback(); } @@ -70,19 +70,14 @@ export async function getOnshapeApi(c: AppContext): Promise<OAuthApi> { return api; } -function userIdKey(sessionId: string): string { - return `user-id:${sessionId}`; -} - -/** Returns the caller's Onshape user id, memoized in KV by session. */ +/** Returns the caller's Onshape user id, resolved once and kept on the session. */ export async function getCachedUserId(c: AppContext): Promise<string> { - const key = userIdKey(getSessionId(c)); - - const cached = await c.env.KV.get(key); - if (cached) return cached; + const sessionId = getSessionId(c); + const session = await getSession(c.env.KV, sessionId); + if (session.userId) return session.userId; const userId = await getUserId(await getOnshapeApi(c)); - await c.env.KV.put(key, userId, { expirationTtl: SESSION_TTL }); + await saveSession(c.env.KV, sessionId, { ...session, userId }); return userId; } diff --git a/src/backend/features/auth/onshape-oauth.ts b/src/backend/features/auth/onshape-oauth.ts index 891cf2258..3b6a0ca01 100644 --- a/src/backend/features/auth/onshape-oauth.ts +++ b/src/backend/features/auth/onshape-oauth.ts @@ -6,7 +6,7 @@ import { env } from "cloudflare:workers"; import { type AppContext } from "../../lib/context"; import { type AuthTokens, - saveTokens, + saveSession, startLoginSession, takeLoginSession } from "./session"; @@ -86,7 +86,7 @@ export async function doCallback(c: AppContext): Promise<Response> { await oauthClient .validateAuthorizationCode(TOKEN_ENDPOINT, search.code, null) .then((tokens) => makeAuthTokens(tokens)) - .then((tokens) => saveTokens(c.env.KV, session.sessionId, tokens)); + .then((tokens) => saveSession(c.env.KV, session.sessionId, tokens)); return c.redirect(session.redirectUrl); } diff --git a/src/backend/features/auth/session.ts b/src/backend/features/auth/session.ts index b6a2f3e3a..f88f9afed 100644 --- a/src/backend/features/auth/session.ts +++ b/src/backend/features/auth/session.ts @@ -1,4 +1,4 @@ -/** Session cookie plus the KV records it keys: OAuth tokens and login state. */ +/** Session cookie plus the KV records it keys: the session and login state. */ import { HttpStatus } from "http-status-ts"; import { internalError } from "../../lib/api-error"; import { getCookie, setCookie } from "hono/cookie"; @@ -29,28 +29,39 @@ export interface AuthTokens { expiresAt: number; } -export async function saveTokens( +/** A signed-in session: what it takes to call Onshape, and who is calling. */ +export interface Session extends AuthTokens { + /** Resolved on first use, since signing in never needs to ask. */ + userId?: string; +} + +/** Still `tokens:`, so sessions signed in before this held a userId survive. */ +function sessionKey(sessionId: string): string { + return `tokens:${sessionId}`; +} + +export async function saveSession( kv: KVNamespace, sessionId: string, - tokens: AuthTokens + session: Session ) { - await kv.put(`tokens:${sessionId}`, JSON.stringify(tokens), { + await kv.put(sessionKey(sessionId), JSON.stringify(session), { expirationTtl: SESSION_TTL }); } -export async function getTokens( +export async function getSession( kv: KVNamespace, sessionId: string -): Promise<AuthTokens> { - const raw = await kv.get(`tokens:${sessionId}`); +): Promise<Session> { + const raw = await kv.get(sessionKey(sessionId)); if (!raw) { throw internalError( "Failed to find valid auth tokens to use", HttpStatus.UNAUTHORIZED ); } - return JSON.parse(raw) as AuthTokens; + return JSON.parse(raw) as Session; } /** What the callback needs to finish a sign-in it did not start. */ diff --git a/src/backend/features/search/search-index.test.ts b/src/backend/features/search/search-index.test.ts new file mode 100644 index 000000000..2ab00e528 --- /dev/null +++ b/src/backend/features/search/search-index.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { toSearchRecords } from "./search-index"; +import type { ConfigurationRecord } from "../configurations/models"; +import { Vendor } from "../library/vendors"; + +function record(fields: Partial<ConfigurationRecord>): ConfigurationRecord { + return { + configuration: {}, + hasMultipleParts: false, + isOpenComposite: false, + ...fields + }; +} + +describe("toSearchRecords", () => { + it("drops a part number that only repeats the name", () => { + const [result] = toSearchRecords([ + record({ partNumber: "Spacer", name: "Spacer" }) + ]); + expect(result.partNumber).toBeUndefined(); + expect(result.name).toBe("Spacer"); + }); + + it("ignores case and surrounding space when comparing the two", () => { + const [result] = toSearchRecords([ + record({ partNumber: " spacer ", name: "Spacer" }) + ]); + expect(result.partNumber).toBeUndefined(); + }); + + it("will not link a repeated part number to a vendor", () => { + const [result] = toSearchRecords( + [record({ partNumber: "Bearing", name: "Bearing" })], + [Vendor.WCP] + ); + expect(result.url).toBeUndefined(); + }); + + it("keeps a part number that says something the name does not", () => { + const [result] = toSearchRecords( + [record({ partNumber: "WCP-1025", name: "Gearbox" })], + [Vendor.WCP] + ); + expect(result.partNumber).toBe("WCP-1025"); + expect(result.url).toBe("https://wcproducts.com/products/wcp-1025"); + }); + + it("keeps a record that is left with only a name", () => { + expect( + toSearchRecords([record({ partNumber: "Spacer", name: "Spacer" })]) + ).toHaveLength(1); + }); + + it("drops a record with neither", () => { + expect(toSearchRecords([record({})])).toHaveLength(0); + }); +}); diff --git a/src/backend/features/search/search-index.ts b/src/backend/features/search/search-index.ts index 246b6c72d..082a06a92 100644 --- a/src/backend/features/search/search-index.ts +++ b/src/backend/features/search/search-index.ts @@ -125,6 +125,20 @@ function uniqueJoin(values: (string | undefined)[]): string { ).join(" "); } +/** + * A part number that only repeats the name identifies nothing — it is what a + * generic part is given when there is no real number to use — so it is dropped + * rather than shown, searched, or turned into a vendor link. + */ +function withoutRepeatedPartNumber( + record: ConfigurationRecord +): ConfigurationRecord { + const repeated = + record.partNumber?.trim().toLowerCase() === + record.name?.trim().toLowerCase(); + return repeated ? { ...record, partNumber: undefined } : record; +} + /** * Keeps the first of each distinct (part number, name) in enumeration order and * drops records with neither. First-wins is what keeps the latest revision. @@ -135,7 +149,8 @@ export function toSearchRecords( ): SearchRecord[] { const seen = new Set<string>(); const searchRecords: SearchRecord[] = []; - for (const record of records) { + for (const raw of records) { + const record = withoutRepeatedPartNumber(raw); if (!record.partNumber && !record.name) { continue; } diff --git a/src/frontend/components/app-title.tsx b/src/frontend/components/app-title.tsx index fbbbbc45c..aa87dbf11 100644 --- a/src/frontend/components/app-title.tsx +++ b/src/frontend/components/app-title.tsx @@ -33,7 +33,7 @@ export function AppTitle(props: AppTitleProps): ReactNode { {icon && <Center style={TITLE_ICON_NUDGE}>{icon}</Center>} <Stack gap={0} miw={0}> <Group gap="xs" wrap="nowrap" miw={0}> - <Text fw={FontWeight.SEMI_BOLD} truncate> + <Text fw={FontWeight.SEMI_BOLD} truncate miw={0}> {title} </Text> {rightSection} @@ -42,7 +42,16 @@ export function AppTitle(props: AppTitleProps): ReactNode { // lh, because the title's own is 1: inheriting that // leaves no leading under the last line, and the block // reads low against a header padded evenly. - <Group gap={4} wrap="nowrap" fz="xs" lh="xs" c="dimmed"> + <Group + gap={4} + wrap="nowrap" + // Shrinkable, so a part number long enough to overrun + // the header ellipsizes instead. + miw={0} + fz="xs" + lh="xs" + c="dimmed" + > {subtitle} </Group> )} @@ -73,7 +82,7 @@ export function MenuTitle(props: MenuTitleProps): ReactNode { (partName || partNumber) && ( <> {partName && ( - <Text inherit truncate> + <Text inherit truncate miw={0}> {partName} </Text> )} @@ -91,6 +100,12 @@ export function MenuTitle(props: MenuTitleProps): ReactNode { ); } +/** + * The part number takes whatever room the part name leaves, up to what it + * needs — rather than sharing the overflow, which ellipsizes both. + */ +const PART_NUMBER_FLEX = { flexGrow: 1, flexBasis: 0, maxWidth: "max-content" }; + /** The part number, linked to the vendor's page for it when there is one. */ function PartNumber({ partNumber, @@ -103,7 +118,7 @@ function PartNumber({ if (!url) { return ( <> - <Text inherit truncate> + <Text inherit truncate miw={0} style={PART_NUMBER_FLEX}> {partNumber} </Text> <CopyButton value={partNumber}> @@ -139,9 +154,15 @@ function PartNumber({ target="_blank" inherit onClick={(event) => event.stopPropagation()} - style={{ display: "inline-flex", alignItems: "center", gap: 2 }} + style={{ + display: "inline-flex", + alignItems: "center", + gap: 2, + minWidth: 0, + ...PART_NUMBER_FLEX + }} > - <Text component="span" inherit truncate> + <Text component="span" inherit truncate miw={0}> {partNumber} </Text> <ArrowSquareOut size={IconSize.TINY} /> diff --git a/src/frontend/components/open-app-modal.tsx b/src/frontend/components/open-app-modal.tsx index 94430368d..316aa0a3d 100644 --- a/src/frontend/components/open-app-modal.tsx +++ b/src/frontend/components/open-app-modal.tsx @@ -40,6 +40,9 @@ export function openAppModal(props: OpenAppModalProps): void { // Otherwise a Mantine minimum, not the padding, sets the height. minHeight: 0 }, + // Shrinkable, so a long title ellipsizes rather than running under + // the close button. + title: { minWidth: 0 }, body: { padding: 0 } } }); From 54906a678a17fee79b52c527d90b64c964afb8f3 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 23 Aug 2026 17:03:16 +0000 Subject: [PATCH 49/56] Hide placeholder part numbers instead of showing them Some parts carry "N/A" where a real part number would go. Like a part number that only repeats the name, it identifies nothing, so drop it alongside those rather than rendering the placeholder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- .../features/search/search-index.test.ts | 12 ++++++++++ src/backend/features/search/search-index.ts | 23 +++++++++++-------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/backend/features/search/search-index.test.ts b/src/backend/features/search/search-index.test.ts index 2ab00e528..dcaefc842 100644 --- a/src/backend/features/search/search-index.test.ts +++ b/src/backend/features/search/search-index.test.ts @@ -36,6 +36,18 @@ describe("toSearchRecords", () => { expect(result.url).toBeUndefined(); }); + it.each(["N/A", "n/a", " NA ", "none", "-"])( + "drops %s as a placeholder part number", + (partNumber) => { + const [result] = toSearchRecords( + [record({ partNumber, name: "Spacer" })], + [Vendor.WCP] + ); + expect(result.partNumber).toBeUndefined(); + expect(result.url).toBeUndefined(); + } + ); + it("keeps a part number that says something the name does not", () => { const [result] = toSearchRecords( [record({ partNumber: "WCP-1025", name: "Gearbox" })], diff --git a/src/backend/features/search/search-index.ts b/src/backend/features/search/search-index.ts index 082a06a92..f6dc40daf 100644 --- a/src/backend/features/search/search-index.ts +++ b/src/backend/features/search/search-index.ts @@ -125,18 +125,23 @@ function uniqueJoin(values: (string | undefined)[]): string { ).join(" "); } +/** What admins write in for a generic part that has no real number. */ +const PLACEHOLDER_PART_NUMBERS = ["n/a", "na", "none", "-"]; + /** - * A part number that only repeats the name identifies nothing — it is what a - * generic part is given when there is no real number to use — so it is dropped - * rather than shown, searched, or turned into a vendor link. + * A placeholder part number identifies nothing, so it is dropped rather than + * shown, searched, or turned into a vendor link. */ -function withoutRepeatedPartNumber( +function withoutPlaceholderPartNumber( record: ConfigurationRecord ): ConfigurationRecord { - const repeated = - record.partNumber?.trim().toLowerCase() === - record.name?.trim().toLowerCase(); - return repeated ? { ...record, partNumber: undefined } : record; + const partNumber = record.partNumber?.trim().toLowerCase(); + if (!partNumber) return record; + + const placeholder = + partNumber === record.name?.trim().toLowerCase() || + PLACEHOLDER_PART_NUMBERS.includes(partNumber); + return placeholder ? { ...record, partNumber: undefined } : record; } /** @@ -150,7 +155,7 @@ export function toSearchRecords( const seen = new Set<string>(); const searchRecords: SearchRecord[] = []; for (const raw of records) { - const record = withoutRepeatedPartNumber(raw); + const record = withoutPlaceholderPartNumber(raw); if (!record.partNumber && !record.name) { continue; } From 8dc4fe864087c1cdf138a10437c6b0debd3f2852 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 23 Aug 2026 17:18:53 +0000 Subject: [PATCH 50/56] Revert "Hide placeholder part numbers instead of showing them" This reverts commit 54906a6. An "N/A" part number is entered by an admin deliberately, so it should be shown as written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- .../features/search/search-index.test.ts | 12 ---------- src/backend/features/search/search-index.ts | 23 ++++++++----------- 2 files changed, 9 insertions(+), 26 deletions(-) diff --git a/src/backend/features/search/search-index.test.ts b/src/backend/features/search/search-index.test.ts index dcaefc842..2ab00e528 100644 --- a/src/backend/features/search/search-index.test.ts +++ b/src/backend/features/search/search-index.test.ts @@ -36,18 +36,6 @@ describe("toSearchRecords", () => { expect(result.url).toBeUndefined(); }); - it.each(["N/A", "n/a", " NA ", "none", "-"])( - "drops %s as a placeholder part number", - (partNumber) => { - const [result] = toSearchRecords( - [record({ partNumber, name: "Spacer" })], - [Vendor.WCP] - ); - expect(result.partNumber).toBeUndefined(); - expect(result.url).toBeUndefined(); - } - ); - it("keeps a part number that says something the name does not", () => { const [result] = toSearchRecords( [record({ partNumber: "WCP-1025", name: "Gearbox" })], diff --git a/src/backend/features/search/search-index.ts b/src/backend/features/search/search-index.ts index f6dc40daf..082a06a92 100644 --- a/src/backend/features/search/search-index.ts +++ b/src/backend/features/search/search-index.ts @@ -125,23 +125,18 @@ function uniqueJoin(values: (string | undefined)[]): string { ).join(" "); } -/** What admins write in for a generic part that has no real number. */ -const PLACEHOLDER_PART_NUMBERS = ["n/a", "na", "none", "-"]; - /** - * A placeholder part number identifies nothing, so it is dropped rather than - * shown, searched, or turned into a vendor link. + * A part number that only repeats the name identifies nothing — it is what a + * generic part is given when there is no real number to use — so it is dropped + * rather than shown, searched, or turned into a vendor link. */ -function withoutPlaceholderPartNumber( +function withoutRepeatedPartNumber( record: ConfigurationRecord ): ConfigurationRecord { - const partNumber = record.partNumber?.trim().toLowerCase(); - if (!partNumber) return record; - - const placeholder = - partNumber === record.name?.trim().toLowerCase() || - PLACEHOLDER_PART_NUMBERS.includes(partNumber); - return placeholder ? { ...record, partNumber: undefined } : record; + const repeated = + record.partNumber?.trim().toLowerCase() === + record.name?.trim().toLowerCase(); + return repeated ? { ...record, partNumber: undefined } : record; } /** @@ -155,7 +150,7 @@ export function toSearchRecords( const seen = new Set<string>(); const searchRecords: SearchRecord[] = []; for (const raw of records) { - const record = withoutPlaceholderPartNumber(raw); + const record = withoutRepeatedPartNumber(raw); if (!record.partNumber && !record.name) { continue; } From 5d27a1d87d84541a7c60415ba1fb77def51ee737 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 23 Aug 2026 17:18:53 +0000 Subject: [PATCH 51/56] Score search records by term so a query can name a configuration A record was only picked when its part number or name matched the whole query contiguously, so any multi-word query fell through to the default configuration: "maxspline 24t" names a configuration that no record reads as. Score by query term instead, keeping a whole-query match above any number of loose terms so a part number typed in full still wins, and pick the better-scoring of the part number and name rather than always preferring the number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- src/frontend/features/search/search.test.ts | 55 ++++++++++++++ src/frontend/features/search/search.ts | 80 ++++++++++++++++----- 2 files changed, 116 insertions(+), 19 deletions(-) diff --git a/src/frontend/features/search/search.test.ts b/src/frontend/features/search/search.test.ts index 46a7b9557..2ffb4b062 100644 --- a/src/frontend/features/search/search.test.ts +++ b/src/frontend/features/search/search.test.ts @@ -191,6 +191,61 @@ describe("doSearch part-number matching", () => { }); }); +// Production shape: the element's own part data leads the list as the record an +// unset configuration falls back to, followed by one record per configuration. +describe("doSearch configuration matching", () => { + const gears: Record<string, ConfigurationRecord[]> = { + i1: [ + record("WCP-1234", {}, "12T MAXSpline Gear"), + record("WCP-1235", { teeth: "24" }, "24T MAXSpline Gear"), + record("WCP-1236", { teeth: "36" }, "36T MAXSpline Gear") + ] + }; + + it("picks the configuration a term of the query names", () => { + const searchDb = buildSearchDb(library("MAXSpline Gear"), gears); + const { hits } = doSearch( + searchDb, + "maxspline 24t", + undefined, + undefined, + true + ); + expect(hits[0].configuration).toEqual({ teeth: "24" }); + expect(hits[0].partNumber).toBe("WCP-1235"); + }); + + it("picks it from the distinguishing term alone", () => { + const searchDb = buildSearchDb(library("MAXSpline Gear"), gears); + const { hits } = doSearch(searchDb, "36t", undefined, undefined, true); + expect(hits[0].configuration).toEqual({ teeth: "36" }); + }); + + it("keeps the default when no term distinguishes a configuration", () => { + const searchDb = buildSearchDb(library("MAXSpline Gear"), gears); + const { hits } = doSearch( + searchDb, + "maxspline gear", + undefined, + undefined, + true + ); + expect(hits[0].configuration).toEqual({}); + }); + + it("lets a part number typed in full outrank a looser name match", () => { + const searchDb = buildSearchDb(library("MAXSpline Gear"), gears); + const { hits } = doSearch( + searchDb, + "WCP-1236", + undefined, + undefined, + true + ); + expect(hits[0].configuration).toEqual({ teeth: "36" }); + }); +}); + describe("doSearch highlighting", () => { /** The characters `positions` underline, merged the way applyRanges does. */ function highlighted(text: string, positions: Position[]): string { diff --git a/src/frontend/features/search/search.ts b/src/frontend/features/search/search.ts index 62a92d4fc..f9bd806e9 100644 --- a/src/frontend/features/search/search.ts +++ b/src/frontend/features/search/search.ts @@ -2,7 +2,8 @@ import MiniSearch, { SearchResult as MiniSearchResult } from "minisearch"; import { Vendor } from "@backend/features/library/vendors"; import { SearchDocument, - normalizeForMatch + normalizeForMatch, + tokenize } from "@backend/features/search/search-index"; import { ParameterValues, @@ -159,8 +160,9 @@ export function doSearch( } /** - * The single best record for a hit: matched by part number, else by name, else - * the default — so every row can show a part number and name. + * The single best record for a hit, by part number or name — whichever the + * query describes better. Falls back to the default record, so every row can + * show a part number and name even when the title alone matched. */ function matchedRecord( result: MiniSearchResult, @@ -174,35 +176,75 @@ function matchedRecord( const byName = matchedFields.includes("partNames") ? findBestRecord(query, document.records, (r) => r.name) : undefined; - // A multi-term query can match the field without any one record matching the - // whole query, so fall back rather than leaving the row with no record. - return byNumber ?? byName ?? document.records[0]; + + const best = [byNumber, byName] + .filter((match) => match !== undefined) + // Part number first, so it wins a tie: it is the more specific field. + .sort((a, b) => b.score - a.score)[0]; + return best?.record ?? document.records[0]; +} + +interface RecordMatch { + record: SearchRecord; + score: number; +} + +/** Query terms the value covers, prefix-matched the way the index searches. */ +function coveredTerms(value: string, queryTerms: string[]): number { + const valueTerms = tokenize(value).map((term) => term.toLowerCase()); + return queryTerms.filter((queryTerm) => + valueTerms.some((valueTerm) => valueTerm.startsWith(queryTerm)) + ).length; } /** - * Prefers an exact match, then a prefix, then a substring. Ties go first-wins, - * which in enumeration order is the latest option. + * How well a value answers the query: a whole-query match ranks above any + * number of loose terms, so a part number typed out in full still wins. + */ +function matchScore( + value: string, + normalizedQuery: string, + queryTerms: string[] +): number { + let whole = 0; + if (value === normalizedQuery) { + whole = 3; + } else if (value.startsWith(normalizedQuery)) { + whole = 2; + } else if (value.includes(normalizedQuery)) { + whole = 1; + } + return whole * (queryTerms.length + 1) + coveredTerms(value, queryTerms); +} + +/** + * The record the query describes best. Scoring by term, not by the whole query: + * "maxspline 24t" names a configuration even though no record reads that way. + * Ties go first-wins, which in enumeration order is the latest option. */ function findBestRecord( query: string, records: SearchRecord[], selector: (record: SearchRecord) => string | undefined -): SearchRecord | undefined { +): RecordMatch | undefined { + // Canonicalize the same way the index did, so a fraction/decimal query lines + // up with the stored original (e.g. `.5` matches a `"1/2 Bearing"` name). const normalizedQuery = normalizeForMatch(query.trim()); if (records.length === 0 || normalizedQuery === "") { return undefined; } + const queryTerms = tokenize(query).map((term) => term.toLowerCase()); - // Canonicalize the same way the index did, so a fraction/decimal query lines - // up with the stored original (e.g. `.5` matches a `"1/2 Bearing"` name). - const value = (record: SearchRecord) => - normalizeForMatch(selector(record) ?? ""); - - return ( - records.find((r) => value(r) === normalizedQuery) ?? - records.find((r) => value(r).startsWith(normalizedQuery)) ?? - records.find((r) => value(r).includes(normalizedQuery)) - ); + let best: RecordMatch | undefined; + for (const record of records) { + const value = normalizeForMatch(selector(record) ?? ""); + if (!value) continue; + const score = matchScore(value, normalizedQuery, queryTerms); + if (score > (best?.score ?? 0)) { + best = { record, score }; + } + } + return best; } /** Escapes a term so it matches literally (terms can carry `.`, `(`, and friends). */ From d3496d5834504866fb0f94ce77168b9fd48281e8 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 23 Aug 2026 17:37:39 +0000 Subject: [PATCH 52/56] Index part number segments by value, and dress up the filter callout `TTB-0016-5/32` was read as the mixed number 16 5/32, so it indexed as `TTB 16.16` and a `TTB-0016` query underlined nothing after the prefix. A leading zero marks a part number segment rather than a quantity, so split the two and canonicalize each: `TTB 16 0.16`. Leading zeros are spelling rather than value, so either way of writing a segment now finds the part, and a numeric match underlines the zeros it landed behind. The filter callout gains an icon, drops the library accent for a plain informational blue, and uses a neutral button so it stops competing with the results it reports on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- src/backend/features/search/search-index.ts | 37 ++++++++++++--- .../search/components/search-errors.tsx | 47 +++++++++++++------ src/frontend/features/search/search.test.ts | 30 ++++++++++++ src/frontend/features/search/search.ts | 10 +++- 4 files changed, 103 insertions(+), 21 deletions(-) diff --git a/src/backend/features/search/search-index.ts b/src/backend/features/search/search-index.ts index 082a06a92..16a7edd36 100644 --- a/src/backend/features/search/search-index.ts +++ b/src/backend/features/search/search-index.ts @@ -33,9 +33,21 @@ export function processTerm(term: string): string[] { return Array.from(new Set(terms)); } -// A mixed number, simple fraction, or decimal (incl. leading-dot). Alternatives -// are ordered longest-first so `1-1/2` is consumed whole, not as `1` + `1/2`. -const NUMERIC_PATTERN = /(\d+)-(\d+)\/(\d+)|(\d+)\/(\d+)|\d*\.\d+|\d+\.\d*/g; +// A mixed number, simple fraction, decimal (incl. leading-dot), or plain +// integer. Alternatives are ordered longest-first so `1-1/2` is consumed whole, +// not as `1` + `1/2`. +const NUMERIC_PATTERN = + /(\d+)-(\d+)\/(\d+)|(\d+)\/(\d+)|\d*\.\d+|\d+\.\d*|\d+/g; + +/** Leading zeros are spelling, not value: `TTB-0016` and `TTB-16` are one part. */ +function withoutLeadingZeros(digits: string): string { + return digits.replace(/^0+(?=\d)/, ""); +} + +/** One 2-dp decimal, the single form every number is stored and queried as. */ +function toDecimal(value: number): string { + return String(Math.round(value * 100) / 100); +} /** * Rewrites numbers and fractions to one 2-dp decimal, at index and query time @@ -45,10 +57,23 @@ function canonicalizeNumbers(text: string): string { return text.replace( NUMERIC_PATTERN, (match, mixedWhole, mixedNum, mixedDen, fracNum, fracDen) => { + // Left as written, so a long one cannot round-trip through a float. + if (/^\d+$/.test(match)) { + return withoutLeadingZeros(match); + } + let value: number; if (mixedWhole !== undefined) { - value = - Number(mixedWhole) + Number(mixedNum) / Number(mixedDen); + const fraction = Number(mixedNum) / Number(mixedDen); + // A leading zero marks a part number segment rather than a + // quantity, so `TTB-0016-5/32` is part 16 in 5/32", not 16 and + // 5/32. Each half still canonicalizes on its own. + if (mixedWhole.startsWith("0")) { + return Number.isFinite(fraction) + ? `${withoutLeadingZeros(mixedWhole)}-${toDecimal(fraction)}` + : match; + } + value = Number(mixedWhole) + fraction; } else if (fracNum !== undefined) { value = Number(fracNum) / Number(fracDen); } else { @@ -57,7 +82,7 @@ function canonicalizeNumbers(text: string): string { if (!Number.isFinite(value)) { return match; } - return String(Math.round(value * 100) / 100); + return toDecimal(value); } ); } diff --git a/src/frontend/features/search/components/search-errors.tsx b/src/frontend/features/search/components/search-errors.tsx index de522e350..8d82c2f07 100644 --- a/src/frontend/features/search/components/search-errors.tsx +++ b/src/frontend/features/search/components/search-errors.tsx @@ -1,5 +1,5 @@ -import { Alert, Box, Button, Group } from "@mantine/core"; -import { HeartBreak, MagnifyingGlass } from "@phosphor-icons/react"; +import { Alert, Box, Button, Group, Text } from "@mantine/core"; +import { HeartBreak, Info, MagnifyingGlass } from "@phosphor-icons/react"; import { IconSize } from "../../../lib/style-constants"; import { ReactNode } from "react"; import { ClearFiltersButton } from "../../settings/components/vendor-filters"; @@ -31,6 +31,26 @@ interface FilterCalloutProps { filtered: FilterResult; } +/** + * Blue rather than the library accent: the strip reports on the results, so it + * should read as a note beside them rather than as part of the library. + */ +function Callout(props: { text: string; action: ReactNode }): ReactNode { + return ( + <Alert + color="blue" + p="xs" + icon={<Info size={IconSize.MEDIUM} />} + styles={{ body: { minWidth: 0 } }} + > + <Group justify="space-between" wrap="nowrap" gap="sm"> + <Text size="sm">{props.text}</Text> + {props.action} + </Group> + </Alert> + ); +} + /** * A callout which renders whenever there are items hidden by filters. */ @@ -42,21 +62,17 @@ export function SearchCallout(props: FilterCalloutProps): ReactNode { if (filtered.byGroup > 0) { return ( - <Alert p="xs"> - <Group justify="space-between" wrap="nowrap"> - {getGroupString(filtered, objectLabel)} - <SearchAllButton small /> - </Group> - </Alert> + <Callout + text={getGroupString(filtered, objectLabel)} + action={<SearchAllButton small />} + /> ); } return ( - <Alert p="xs"> - <Group justify="space-between" wrap="nowrap"> - {getVendorString(filtered, objectLabel)} - <ClearFiltersButton small /> - </Group> - </Alert> + <Callout + text={getVendorString(filtered, objectLabel)} + action={<ClearFiltersButton small />} + /> ); } @@ -126,6 +142,9 @@ function SearchAllButton(props: SearchAllButtonProps): ReactNode { return ( <Button leftSection={<MagnifyingGlass size={IconSize.SMALL} />} + // Small means inside the callout, where a filled button would + // shout over the note it sits in. + variant={small ? "default" : undefined} size={small ? "xs" : undefined} onClick={() => { void navigate({ diff --git a/src/frontend/features/search/search.test.ts b/src/frontend/features/search/search.test.ts index 2ffb4b062..3c369d84b 100644 --- a/src/frontend/features/search/search.test.ts +++ b/src/frontend/features/search/search.test.ts @@ -71,6 +71,16 @@ describe("tokenize", () => { expect(tokenize("1/3")).toEqual(["0.33"]); }); + it("keeps a leading-zero part segment out of a mixed number", () => { + // "0016" numbers the part; only the "5/32" is a size. + expect(tokenize("TTB-0016-5/32")).toEqual(["TTB", "16", "0.16"]); + }); + + it("drops leading zeros so either spelling of a segment matches", () => { + expect(tokenize("TTB-0016")).toEqual(["TTB", "16"]); + expect(tokenize("TTB-16")).toEqual(["TTB", "16"]); + }); + it("leaves thread specs and part numbers untouched", () => { expect(tokenize("10-32")).toEqual(["10", "32"]); expect(tokenize("217-2600")).toEqual(["217", "2600"]); @@ -317,6 +327,26 @@ describe("doSearch highlighting", () => { ).toBe("217"); }); + // The segment after a leading-zero one used to be folded into a mixed + // number, leaving nothing in the text for the query to underline. + it("underlines a leading-zero segment of the part number", () => { + const { hits } = doSearch( + buildSearchDb(library(), { + i1: [record("TTB-0016-5/32", { size: "small" })] + }), + "TTB-0016", + undefined, + undefined, + true + ); + expect( + highlighted( + hits[0].partNumber!, + hits[0].partNumberPositions ?? [] + ) + ).toBe("TTB0016"); + }); + it("underlines the typed prefix of the part name", () => { const hit = hitFor("bear"); expect( diff --git a/src/frontend/features/search/search.ts b/src/frontend/features/search/search.ts index f9bd806e9..c59487a0d 100644 --- a/src/frontend/features/search/search.ts +++ b/src/frontend/features/search/search.ts @@ -287,7 +287,15 @@ function generateHighlightPositions( new RegExp(escapeRegExp(term), "g") ); for (const match of matchedLocations) { - positions.push({ start: match.index, length }); + // A number is indexed without its leading zeros, so underline the + // digits it landed inside too: `16` should light up all of `0016`. + const leadingZeros = /^\d+$/.test(term) + ? /0*$/.exec(haystack.slice(0, match.index))![0].length + : 0; + positions.push({ + start: match.index - leadingZeros, + length: length + leadingZeros + }); } } From bc952595c4dbd94f1ad72014fb3f711fdd86b2f2 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 23 Aug 2026 18:29:31 +0000 Subject: [PATCH 53/56] Stop the copy button from shifting the menu title An xs ActionIcon is 18px against the subtitle's 16.8px text line, so the row grew whenever the part number was copyable rather than linked and the title above it moved. Size the button to the line instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- src/frontend/components/app-title.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/frontend/components/app-title.tsx b/src/frontend/components/app-title.tsx index aa87dbf11..367ab22bb 100644 --- a/src/frontend/components/app-title.tsx +++ b/src/frontend/components/app-title.tsx @@ -106,6 +106,9 @@ export function MenuTitle(props: MenuTitleProps): ReactNode { */ const PART_NUMBER_FLEX = { flexGrow: 1, flexBasis: 0, maxWidth: "max-content" }; +/** The xs line box the subtitle row is otherwise sized by, floored. */ +const COPY_BUTTON_SIZE = 16; + /** The part number, linked to the vendor's page for it when there is one. */ function PartNumber({ partNumber, @@ -130,7 +133,9 @@ function PartNumber({ <ActionIcon variant="subtle" color={copied ? "teal" : "gray"} - size="xs" + // Sized to the text line: taller, and the row + // grows, shifting the title above it. + size={COPY_BUTTON_SIZE} aria-label="Copy part number" onClick={copy} > From b3bb346cc3915f6ae061642a240d658d3eaaa551 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 23 Aug 2026 23:13:26 +0000 Subject: [PATCH 54/56] Identify a part by its number, in the menu and in search results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The part number names its own vendor most precisely, since a part configurable across several carries a generic vendor while each configuration's number still says who sells that one — so read the vendor off the number first, falling back to the tagging for the numbers that carry no prefix. Search rows now link the number to that vendor page, and the part name gives up every character before the number loses one. The insert menu drops the part name entirely: the number is what identifies what gets inserted. No assembly configures its part properties today, so probing every combination only rediscovers what the default already says; assemblies now read the default alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- .../features/configurations/routes.test.ts | 1 + .../features/configurations/utils.test.ts | 14 ++- src/backend/features/configurations/utils.ts | 16 ++- .../features/library/insertables/routes.ts | 6 +- src/backend/features/library/vendors.ts | 11 ++ src/backend/features/load/load-insertable.ts | 6 +- .../load/parse-configuration-records.test.ts | 9 ++ .../load/parse-configuration-records.ts | 7 ++ src/frontend/components/app-title.tsx | 33 +----- .../library/components/card-components.tsx | 103 +++++++++++++----- src/frontend/features/search/search.ts | 3 + 11 files changed, 150 insertions(+), 59 deletions(-) diff --git a/src/backend/features/configurations/routes.test.ts b/src/backend/features/configurations/routes.test.ts index afcfcfd27..d606233cf 100644 --- a/src/backend/features/configurations/routes.test.ts +++ b/src/backend/features/configurations/routes.test.ts @@ -68,6 +68,7 @@ describe("configuration routes", () => { { partNumber: "WCP-0405", name: "2x1 Tube", + url: "https://wcproducts.com/products/wcp-0405", configuration: {} } ] diff --git a/src/backend/features/configurations/utils.test.ts b/src/backend/features/configurations/utils.test.ts index a24ef30cb..668018524 100644 --- a/src/backend/features/configurations/utils.test.ts +++ b/src/backend/features/configurations/utils.test.ts @@ -92,13 +92,25 @@ describe("getPartUrl", () => { it("will not guess between several, which do not say which this is", () => { expect( - getPartUrl(metadata({ partNumber: "WCP-1025" }), [ + getPartUrl(metadata({ partNumber: "1025" }), [ Vendor.WCP, Vendor.MCM ]) ).toBeUndefined(); }); + // A part configurable across vendors carries a generic vendor, but each + // configuration's number still says who sells that one. + it("reads the vendor out of the part number over a generic tagging", () => { + const url = getPartUrl(metadata({ partNumber: "TTB-0016" }), [ + Vendor.WCP, + Vendor.TTB + ]); + expect(url).toBe( + "https://www.thethriftybot.com/search?type=product&q=TTB-0016" + ); + }); + it("prefers the record's own vendor over the insertable's", () => { const url = getPartUrl( metadata({ vendor: "McMaster-Carr", partNumber: "91251A445" }), diff --git a/src/backend/features/configurations/utils.ts b/src/backend/features/configurations/utils.ts index f53985d97..a78355fb1 100644 --- a/src/backend/features/configurations/utils.ts +++ b/src/backend/features/configurations/utils.ts @@ -13,7 +13,12 @@ import { VisibilityCondition, VisibilityType } from "./models"; -import { Vendor, getVendorPartUrl, toVendor } from "../library/vendors"; +import { + Vendor, + getVendorPartUrl, + parsePartNumberVendor, + toVendor +} from "../library/vendors"; import { LogicalOp, QuantityType, Unit } from "./enums"; import { type EvaluateOptions, valueWithUnits } from "./input-parser"; @@ -86,9 +91,11 @@ export function evaluateCondition( * The page for a part: a description that is already a url wins, since it names * the exact product, over one derived from the vendor and part number. * - * Onshape's vendor field is often unset, so the insertable's own vendors stand - * in — but only when they name one, since a part configurable across several - * does not say which this record is. + * The part number names its own vendor most precisely, since a generic + * insertable still carries a number only one vendor sells. Onshape's vendor + * field and then the insertable's own vendors stand in — the latter only when + * they name one, since a part configurable across several does not say which + * this record is. */ export function getPartUrl( record: PartMetadata, @@ -98,6 +105,7 @@ export function getPartUrl( return record.description; } const vendor = + parsePartNumberVendor(record.partNumber) ?? toVendor(record.vendor) ?? (vendors.length === 1 ? vendors[0] : undefined); return getVendorPartUrl(vendor, record.partNumber); diff --git a/src/backend/features/library/insertables/routes.ts b/src/backend/features/library/insertables/routes.ts index 03ccb1e8d..4eda9937b 100644 --- a/src/backend/features/library/insertables/routes.ts +++ b/src/backend/features/library/insertables/routes.ts @@ -140,7 +140,11 @@ insertableRoutes.post( .where(eq(configurations.id, insertableId)) .get() )?.parameters ?? []; - const indexing = decideIndexing(parameters, body.indexConfigurations); + const indexing = decideIndexing( + row.elementType, + parameters, + body.indexConfigurations + ); // Index before committing anything: if this throws, nothing is written. // The error reaches the client via the app's onError handler. diff --git a/src/backend/features/library/vendors.ts b/src/backend/features/library/vendors.ts index f3d59b7f4..2de654ce0 100644 --- a/src/backend/features/library/vendors.ts +++ b/src/backend/features/library/vendors.ts @@ -31,6 +31,17 @@ export function toVendor(vendor: string | undefined): Vendor | undefined { ); } +/** + * The vendor a part number names itself, e.g. `WCP-1025` or `am-5833`. More + * reliable than the vendor an insertable is tagged with, which is generic + * wherever one part is configurable across several vendors. + */ +export function parsePartNumberVendor( + partNumber: string | undefined +): Vendor | undefined { + return toVendor(/^([A-Za-z]+)-/.exec(partNumber?.trim() ?? "")?.[1]); +} + /** * The vendor's page for a part, or its search for one where that is all the * site offers. Most vendors have no url derivable from a part number at all. diff --git a/src/backend/features/load/load-insertable.ts b/src/backend/features/load/load-insertable.ts index d7f87634f..31b490eda 100644 --- a/src/backend/features/load/load-insertable.ts +++ b/src/backend/features/load/load-insertable.ts @@ -81,7 +81,11 @@ export async function loadInsertable( // decides how much of the rest of the load is worth running. const hasParts = !hasBuildIssue(parts.buildIssues, BuildIssueType.NO_PARTS); - const indexing = decideIndexing(parameters, flags.indexConfigurations); + const indexing = decideIndexing( + target.elementType, + parameters, + flags.indexConfigurations + ); const recordsResult = indexing.shouldIndex ? await loadConfigurationRecords( diff --git a/src/backend/features/load/parse-configuration-records.test.ts b/src/backend/features/load/parse-configuration-records.test.ts index 8a36ab961..2b0c2c9b7 100644 --- a/src/backend/features/load/parse-configuration-records.test.ts +++ b/src/backend/features/load/parse-configuration-records.test.ts @@ -63,6 +63,7 @@ describe("decideIndexing", () => { { configs: 600, force: true, index: false, issues: TOO_MANY } ])("configs=$configs force=$force", ({ configs, force, index, issues }) => { const { shouldIndex, buildIssues } = decideIndexing( + ElementType.PART_STUDIO, paramsWithConfigs(configs), force ); @@ -71,6 +72,14 @@ describe("decideIndexing", () => { buildIssues: issues }); }); + + // No assembly configures its part properties, so the default probe is the + // whole of it however many combinations the count would have enumerated. + it("probes only the default for an assembly", () => { + expect( + decideIndexing(ElementType.ASSEMBLY, paramsWithConfigs(600), false) + ).toEqual({ shouldIndex: true, buildIssues: [], configurations: [] }); + }); }); describe("parsePartStudioRecord", () => { diff --git a/src/backend/features/load/parse-configuration-records.ts b/src/backend/features/load/parse-configuration-records.ts index df799dc28..70eed9c83 100644 --- a/src/backend/features/load/parse-configuration-records.ts +++ b/src/backend/features/load/parse-configuration-records.ts @@ -75,9 +75,16 @@ export interface IndexingDecision { /** Past the hard cap forcing it on cannot help, since enumeration stops there. */ export function decideIndexing( + elementType: ElementType, parameters: ConfigurationParameter[], indexConfigurations: boolean ): IndexingDecision { + // No assembly configures its part properties today, so every combination + // would probe back to what the default already says. + if (elementType === ElementType.ASSEMBLY) { + return { shouldIndex: true, buildIssues: [], configurations: [] }; + } + const { band, configurations } = countConfigurations(parameters); const shouldIndex = isIndexingEnabled(band, indexConfigurations); diff --git a/src/frontend/components/app-title.tsx b/src/frontend/components/app-title.tsx index 367ab22bb..eef0e3aac 100644 --- a/src/frontend/components/app-title.tsx +++ b/src/frontend/components/app-title.tsx @@ -67,11 +67,10 @@ interface MenuTitleProps { icon?: ReactNode; } -/** A menu's header: the element is how the part was found, the record is - * what gets inserted, so both are shown. */ +/** A menu's header: the element name is how the part was found, the part + * number is what identifies what gets inserted. */ export function MenuTitle(props: MenuTitleProps): ReactNode { const { name, record, icon } = props; - const partName = record?.name !== name ? record?.name : undefined; const partNumber = record?.partNumber !== name ? record?.partNumber : undefined; return ( @@ -79,33 +78,14 @@ export function MenuTitle(props: MenuTitleProps): ReactNode { icon={icon} title={name} subtitle={ - (partName || partNumber) && ( - <> - {partName && ( - <Text inherit truncate miw={0}> - {partName} - </Text> - )} - {partName && partNumber && <Text inherit>·</Text>} - {partNumber && ( - <PartNumber - partNumber={partNumber} - url={record?.url} - /> - )} - </> + partNumber && ( + <PartNumber partNumber={partNumber} url={record?.url} /> ) } /> ); } -/** - * The part number takes whatever room the part name leaves, up to what it - * needs — rather than sharing the overflow, which ellipsizes both. - */ -const PART_NUMBER_FLEX = { flexGrow: 1, flexBasis: 0, maxWidth: "max-content" }; - /** The xs line box the subtitle row is otherwise sized by, floored. */ const COPY_BUTTON_SIZE = 16; @@ -121,7 +101,7 @@ function PartNumber({ if (!url) { return ( <> - <Text inherit truncate miw={0} style={PART_NUMBER_FLEX}> + <Text inherit truncate miw={0}> {partNumber} </Text> <CopyButton value={partNumber}> @@ -163,8 +143,7 @@ function PartNumber({ display: "inline-flex", alignItems: "center", gap: 2, - minWidth: 0, - ...PART_NUMBER_FLEX + minWidth: 0 }} > <Text component="span" inherit truncate miw={0}> diff --git a/src/frontend/features/library/components/card-components.tsx b/src/frontend/features/library/components/card-components.tsx index 9d7722c7e..a23b395a3 100644 --- a/src/frontend/features/library/components/card-components.tsx +++ b/src/frontend/features/library/components/card-components.tsx @@ -1,4 +1,4 @@ -import { Box, Group, Menu, Stack, Table, Text } from "@mantine/core"; +import { Anchor, Box, Group, Menu, Stack, Table, Text } from "@mantine/core"; import { ArrowSquareOut, EyeSlash, @@ -8,7 +8,7 @@ import { } from "@phosphor-icons/react"; import { IconSize } from "../../../lib/style-constants"; import { copyUrlToClipboard, makeUrl, openUrlInNewTab } from "../../../lib/url"; -import { Fragment, PropsWithChildren, ReactNode, useCallback } from "react"; +import { PropsWithChildren, ReactNode, useCallback } from "react"; import { AppContextMenu, MenuButton } from "../../../components/app-menu"; import { type Position, SearchHit } from "../../search/search"; import { @@ -160,19 +160,13 @@ export function CardTitle(props: CardTitleProps) { cardTitle = title; } - // The hit's best-matching configuration, minus a name repeating the title. - // Each carries its own positions, so a part-number hit underlines there too. - const details = searchHit - ? ( - [ - [searchHit.partName, searchHit.partNamePositions], - [searchHit.partNumber, searchHit.partNumberPositions] - ] as const - ).filter( - (detail): detail is [string, Position[] | undefined] => - !!detail[0] && detail[0].toLowerCase() !== title.toLowerCase() - ) - : []; + // The hit's best-matching configuration, minus a value repeating the title. + const detail = (value: string | undefined) => + value && value.toLowerCase() !== title.toLowerCase() + ? value + : undefined; + const partName = detail(searchHit?.partName); + const partNumber = detail(searchHit?.partNumber); return ( <Group gap="sm" wrap="nowrap" flex={1} miw={0}> @@ -187,18 +181,32 @@ export function CardTitle(props: CardTitleProps) { <Text size="sm" truncate c={disabled ? "dimmed" : undefined}> {cardTitle} </Text> - {details.length > 0 && ( - <Text size="xs" c="dimmed" truncate> - {details.map(([text, positions], index) => ( - <Fragment key={text}> - {index > 0 && " · "} + {(partName || partNumber) && ( + <Group + gap={4} + wrap="nowrap" + miw={0} + fz="xs" + lh="xs" + c="dimmed" + > + {partName && ( + <Text inherit truncate miw={0}> <HighlightedText - text={text} - positions={positions} + text={partName} + positions={searchHit?.partNamePositions} /> - </Fragment> - ))} - </Text> + </Text> + )} + {partName && partNumber && <Text inherit>·</Text>} + {partNumber && ( + <CardPartNumber + partNumber={partNumber} + positions={searchHit?.partNumberPositions} + url={searchHit?.url} + /> + )} + </Group> )} </Stack> {buildStatusBadge} @@ -216,6 +224,51 @@ export function CardTitle(props: CardTitleProps) { ); } +/** + * The part number never shrinks, so the part name gives up every character + * before it loses one: the number is what identifies the part. Capped at the + * row, past which there is nothing left to take. + */ +const PART_NUMBER_FIXED = { flexShrink: 0, maxWidth: "100%" }; + +/** The part number, linked to the vendor's page for it when there is one. */ +function CardPartNumber(props: { + partNumber: string; + positions?: Position[]; + url?: string; +}): ReactNode { + const { partNumber, positions, url } = props; + const text = <HighlightedText text={partNumber} positions={positions} />; + if (!url) { + return ( + <Text inherit truncate miw={0} style={PART_NUMBER_FIXED}> + {text} + </Text> + ); + } + return ( + <Anchor + href={url} + target="_blank" + inherit + // The row inserts on click, which is not what the link is for. + onClick={(event) => event.stopPropagation()} + style={{ + display: "inline-flex", + alignItems: "center", + gap: 2, + minWidth: 0, + ...PART_NUMBER_FIXED + }} + > + <Text component="span" inherit truncate miw={0}> + {text} + </Text> + <ArrowSquareOut size={IconSize.TINY} /> + </Anchor> + ); +} + /** * Groups `ItemRow`s into a single dense, hoverable table. Loading/empty/error * states should be rendered outside of this. diff --git a/src/frontend/features/search/search.ts b/src/frontend/features/search/search.ts index c59487a0d..b1782aa0b 100644 --- a/src/frontend/features/search/search.ts +++ b/src/frontend/features/search/search.ts @@ -41,6 +41,8 @@ export interface SearchHit { configuration?: ParameterValues; partNumber?: string; partName?: string; + /** The vendor's page for the part number, when one can be derived. */ + url?: string; /** Where the query matched inside `partNumber` / `partName`, for underlining. */ partNumberPositions?: Position[]; partNamePositions?: Position[]; @@ -138,6 +140,7 @@ export function doSearch( configuration: record?.configuration, partNumber, partName, + url: record?.url, partNumberPositions: partNumber ? generateHighlightPositions( miniSearchResult, From 03842fad7089211cb97d6b89db2aea88543dc5bf Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 23 Aug 2026 23:36:53 +0000 Subject: [PATCH 55/56] Hide an N/A part number where it is displayed "N/A" is what an admin writes where a generic part has no real number, so it identifies nothing and is worth no row of its own. Filter it at the two places a part number is shown, alongside the existing rule for one that only repeats the name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- src/frontend/components/app-title.tsx | 4 ++-- .../library/components/card-components.tsx | 10 +++++----- src/frontend/lib/part-number.test.ts | 20 +++++++++++++++++++ src/frontend/lib/part-number.ts | 17 ++++++++++++++++ 4 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 src/frontend/lib/part-number.test.ts create mode 100644 src/frontend/lib/part-number.ts diff --git a/src/frontend/components/app-title.tsx b/src/frontend/components/app-title.tsx index eef0e3aac..337c7a70f 100644 --- a/src/frontend/components/app-title.tsx +++ b/src/frontend/components/app-title.tsx @@ -12,6 +12,7 @@ import { ArrowSquareOut, Check, Copy } from "@phosphor-icons/react"; import type { ReactNode } from "react"; import type { SearchRecord } from "@backend/features/configurations/models"; import { FontWeight, IconSize, TITLE_ICON_NUDGE } from "../lib/style-constants"; +import { displayPartNumber } from "../lib/part-number"; interface AppTitleProps { title: ReactNode; @@ -71,8 +72,7 @@ interface MenuTitleProps { * number is what identifies what gets inserted. */ export function MenuTitle(props: MenuTitleProps): ReactNode { const { name, record, icon } = props; - const partNumber = - record?.partNumber !== name ? record?.partNumber : undefined; + const partNumber = displayPartNumber(record?.partNumber, name); return ( <AppTitle icon={icon} diff --git a/src/frontend/features/library/components/card-components.tsx b/src/frontend/features/library/components/card-components.tsx index a23b395a3..b2009771a 100644 --- a/src/frontend/features/library/components/card-components.tsx +++ b/src/frontend/features/library/components/card-components.tsx @@ -7,6 +7,7 @@ import { Plus } from "@phosphor-icons/react"; import { IconSize } from "../../../lib/style-constants"; +import { displayPartNumber } from "../../../lib/part-number"; import { copyUrlToClipboard, makeUrl, openUrlInNewTab } from "../../../lib/url"; import { PropsWithChildren, ReactNode, useCallback } from "react"; import { AppContextMenu, MenuButton } from "../../../components/app-menu"; @@ -161,12 +162,11 @@ export function CardTitle(props: CardTitleProps) { } // The hit's best-matching configuration, minus a value repeating the title. - const detail = (value: string | undefined) => - value && value.toLowerCase() !== title.toLowerCase() - ? value + const partName = + searchHit?.partName?.toLowerCase() !== title.toLowerCase() + ? searchHit?.partName : undefined; - const partName = detail(searchHit?.partName); - const partNumber = detail(searchHit?.partNumber); + const partNumber = displayPartNumber(searchHit?.partNumber, title); return ( <Group gap="sm" wrap="nowrap" flex={1} miw={0}> diff --git a/src/frontend/lib/part-number.test.ts b/src/frontend/lib/part-number.test.ts new file mode 100644 index 000000000..fe629aa9d --- /dev/null +++ b/src/frontend/lib/part-number.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { displayPartNumber } from "./part-number"; + +describe("displayPartNumber", () => { + it.each(["N/A", "n/a", " N/a "])("hides the placeholder %s", (value) => { + expect(displayPartNumber(value)).toBeUndefined(); + }); + + it("hides a number that only repeats the name it sits under", () => { + expect(displayPartNumber(" spacer ", "Spacer")).toBeUndefined(); + }); + + it("keeps a real number, trimmed", () => { + expect(displayPartNumber(" WCP-1025 ", "Gearbox")).toBe("WCP-1025"); + }); + + it("keeps one that merely contains the placeholder", () => { + expect(displayPartNumber("NA-1234")).toBe("NA-1234"); + }); +}); diff --git a/src/frontend/lib/part-number.ts b/src/frontend/lib/part-number.ts new file mode 100644 index 000000000..e0987111d --- /dev/null +++ b/src/frontend/lib/part-number.ts @@ -0,0 +1,17 @@ +/** Admins write this in where a generic part has no real number to give. */ +const PLACEHOLDER_PART_NUMBER = /^n\/a$/i; + +/** + * The part number to show, or nothing when it identifies nothing — a + * placeholder, or a repeat of the name it sits under. + */ +export function displayPartNumber( + partNumber: string | undefined, + name?: string +): string | undefined { + const text = partNumber?.trim(); + if (!text || PLACEHOLDER_PART_NUMBER.test(text)) { + return undefined; + } + return text.toLowerCase() === name?.trim().toLowerCase() ? undefined : text; +} From c1800e7eb0a41f8ab7f62af8be1cea5a37fa6c13 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 24 Aug 2026 00:41:07 +0000 Subject: [PATCH 56/56] Trim the comments and tests that had grown past their worth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comments: cut the two dozen that ran past the two lines AGENTS.md allows, and the ones that opened by restating a signature before saying anything. Tests: one shared `configurationRecord` fixture replaces four local `record` builders that had drifted to four different signatures, and `paramsWithConfigs` and `quantityParam` stop being redefined beside the copies in `__test_utils__`. Cases that only varied by an argument become tables — `evaluateCondition` was rebuilding the same condition object in each of eleven tests, and `evaluateExpression` repeated one shape twenty-two times. Helpers absorb the argument lists that were noise at the call site: search's three trailing `undefined, undefined, true`, and the six-argument probe in the configuration-record tests. Same coverage, 400 fewer lines of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UDJxumrXdB2NqtfyfsH2H --- src/__test_utils__/configuration-fixtures.ts | 23 ++ src/backend/features/auth/caller.ts | 5 +- src/backend/features/auth/guards.ts | 5 +- .../features/build-checker/checks.test.ts | 13 +- .../features/build-checker/issues.test.ts | 30 +-- .../configurations/combinations.test.ts | 42 +--- .../configurations/input-parser.test.ts | 179 ++++------------ src/backend/features/configurations/models.ts | 7 +- src/backend/features/configurations/utils.ts | 10 +- src/backend/features/library/db.ts | 11 +- src/backend/features/library/vendors.test.ts | 53 ++--- src/backend/features/library/vendors.ts | 5 +- .../features/load/load-insertable.test.ts | 21 +- .../load/parse-configuration-records.test.ts | 110 +++------- .../features/load/parse-configuration.test.ts | 200 ++++-------------- .../features/search/search-index.test.ts | 11 +- src/backend/features/search/search-index.ts | 5 +- src/backend/lib/onshape/api-path.ts | 5 +- .../lib/onshape/endpoints/documents.ts | 6 +- .../lib/onshape/endpoints/permissions.ts | 6 +- .../lib/onshape/endpoints/thumbnails.ts | 12 +- src/frontend/features/auth/access-level.tsx | 5 +- src/frontend/features/library/card-hooks.ts | 5 +- .../library/components/card-components.tsx | 5 +- src/frontend/features/search/filter.ts | 6 +- src/frontend/features/search/search.test.ts | 149 +++---------- src/frontend/features/search/search.ts | 10 +- src/frontend/lib/errors.ts | 5 +- src/frontend/lib/onshape-params.ts | 5 +- 29 files changed, 255 insertions(+), 694 deletions(-) diff --git a/src/__test_utils__/configuration-fixtures.ts b/src/__test_utils__/configuration-fixtures.ts index cc4c551e2..ae9be4b51 100644 --- a/src/__test_utils__/configuration-fixtures.ts +++ b/src/__test_utils__/configuration-fixtures.ts @@ -5,6 +5,7 @@ import { ParameterType, type BooleanParameter, + type ConfigurationRecord, type EnumParameter, type QuantityParameter, type UnitInfo @@ -32,6 +33,16 @@ export function enumParam( }; } +/** A single enum whose N options enumerate to N configurations. */ +export function paramsWithConfigs(count: number): EnumParameter[] { + return [ + enumParam( + "A", + Array.from({ length: count }, (_, i) => `o${i}`) + ) + ]; +} + export function boolParam(id: string): BooleanParameter { return { id, @@ -70,3 +81,15 @@ export const TEST_UNIT_INFO: UnitInfo = { anglePrecision: 3, realPrecision: 3 }; + +/** A probe of one configuration; override whichever fields a test is about. */ +export function configurationRecord( + overrides: Partial<ConfigurationRecord> = {} +): ConfigurationRecord { + return { + configuration: {}, + hasMultipleParts: false, + isOpenComposite: false, + ...overrides + }; +} diff --git a/src/backend/features/auth/caller.ts b/src/backend/features/auth/caller.ts index e65dbb6e2..1de611342 100644 --- a/src/backend/features/auth/caller.ts +++ b/src/backend/features/auth/caller.ts @@ -1,7 +1,6 @@ /** - * Resolves who is calling from their session, memoizing the Onshape lookups in - * KV. `productionCaller` is what `createApp` binds onto every request; the - * guards and routes read the results through `c.var`. + * Resolves who is calling from their session, memoized in KV. `createApp` binds + * `productionCaller` onto every request; guards and routes read it via `c.var`. */ import { env as processEnv } from "process"; import { OAuthApi } from "../../lib/onshape/client"; diff --git a/src/backend/features/auth/guards.ts b/src/backend/features/auth/guards.ts index bf7c25a92..afe15042c 100644 --- a/src/backend/features/auth/guards.ts +++ b/src/backend/features/auth/guards.ts @@ -24,9 +24,8 @@ export const requireSignInMiddleware: MiddlewareHandler<AppContextEnv> = async ( }; /** - * Editing implies a session: access level alone would let a signed-out caller - * through wherever it is granted without one (a dev `ACCESS_LEVEL_OVERRIDE`), - * and would answer 403 rather than 401 for everyone else. + * 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. */ export const requireEditorMiddleware: MiddlewareHandler<AppContextEnv> = async ( c, diff --git a/src/backend/features/build-checker/checks.test.ts b/src/backend/features/build-checker/checks.test.ts index a6f3753c8..6cdb91440 100644 --- a/src/backend/features/build-checker/checks.test.ts +++ b/src/backend/features/build-checker/checks.test.ts @@ -5,7 +5,7 @@ import { BuildIssueType } from "./issues"; import { DEFAULT_CANONICAL_CONFIGURATION } from "../configurations/canonical"; import { thumbnailUrl } from "../thumbnails/keys"; import { checkGroup, checkInsertable } from "./checks"; -import type { ConfigurationRecord } from "../configurations/models"; +import { configurationRecord } from "../../../__test_utils__/configuration-fixtures"; /** What uploadThumbnails returns: the element's default configuration. */ const THUMBNAILS: ThumbnailUrls = { @@ -56,15 +56,8 @@ describe("checkGroup", () => { }); }); -/** An indexed record; only its part number matters to these checks. */ -function record(partNumber?: string): ConfigurationRecord { - return { - configuration: {}, - partNumber, - hasMultipleParts: false, - isOpenComposite: false - }; -} +/** Only the part number matters to these checks. */ +const record = (partNumber?: string) => configurationRecord({ partNumber }); describe("checkInsertable", () => { const HEALTHY_INSERTABLE = { diff --git a/src/backend/features/build-checker/issues.test.ts b/src/backend/features/build-checker/issues.test.ts index 64ccfc98b..97943c21d 100644 --- a/src/backend/features/build-checker/issues.test.ts +++ b/src/backend/features/build-checker/issues.test.ts @@ -25,29 +25,13 @@ describe("getMaxSeverity", () => { expect(getMaxSeverity([])).toBeNull(); }); - it("returns the only severity present", () => { - expect(getMaxSeverity([issue(BuildIssueSeverity.INFO)])).toBe( - BuildIssueSeverity.INFO - ); - }); - - it("returns the worst severity for a mix", () => { - expect( - getMaxSeverity([ - issue(BuildIssueSeverity.INFO), - issue(BuildIssueSeverity.ERROR), - issue(BuildIssueSeverity.WARNING) - ]) - ).toBe(BuildIssueSeverity.ERROR); - }); - - it("ranks warning above info", () => { - expect( - getMaxSeverity([ - issue(BuildIssueSeverity.INFO), - issue(BuildIssueSeverity.WARNING) - ]) - ).toBe(BuildIssueSeverity.WARNING); + const { INFO, WARNING, ERROR } = BuildIssueSeverity; + it.each([ + [[INFO], INFO], + [[INFO, WARNING], WARNING], + [[INFO, ERROR, WARNING], ERROR] + ])("takes the worst of %s", (severities, worst) => { + expect(getMaxSeverity(severities.map(issue))).toBe(worst); }); }); diff --git a/src/backend/features/configurations/combinations.test.ts b/src/backend/features/configurations/combinations.test.ts index d44b250e7..1b68ed1b7 100644 --- a/src/backend/features/configurations/combinations.test.ts +++ b/src/backend/features/configurations/combinations.test.ts @@ -12,31 +12,16 @@ import { OptionVisibilityType, ConfigurationParameter, ParameterType, - QuantityParameter, StringParameter, VisibilityCondition, VisibilityType } from "./models"; import { boolParam, - enumParam + enumParam, + paramsWithConfigs, + quantityParam } from "../../../__test_utils__/configuration-fixtures"; -import { QuantityType, Unit } from "./enums"; - -function quantityParam(id: string): QuantityParameter { - return { - id, - name: id, - default: "0", - isCosmetic: false, - type: ParameterType.QUANTITY, - quantityType: QuantityType.LENGTH, - defaultValue: 0, - min: 0, - max: 10, - unit: Unit.MILLIMETER - }; -} function stringParam(id: string): StringParameter { return { @@ -148,16 +133,6 @@ describe("enumerateConfigurations", () => { }); describe("countConfigurations", () => { - /** A single enum whose N options enumerate to N configurations. */ - function paramsWithConfigs(count: number): ConfigurationParameter[] { - return [ - enumParam( - "A", - Array.from({ length: count }, (_, i) => `o${i}`) - ) - ]; - } - it("counts an insertable with nothing to vary as having none", () => { expect(countConfigurations([])).toMatchObject({ count: 0, @@ -168,7 +143,7 @@ describe("countConfigurations", () => { // Cosmetic and quantity parameters ride their defaults rather than // multiplying the count, so they leave nothing to vary either. it("ignores parameters that don't vary the build", () => { - const cosmetic = { ...enumParam("A", ["x", "y"]), isCosmetic: true }; + const cosmetic = enumParam("A", ["x", "y"], { isCosmetic: true }); expect(countConfigurations([cosmetic])).toMatchObject({ count: 0, band: IndexingBand.AUTOMATIC @@ -228,14 +203,7 @@ describe("isIndexedParameter", () => { it("never varies quantity or text parameters", () => { expect(isIndexedParameter(quantityParam("q"))).toBe(false); - const text: StringParameter = { - id: "s", - name: "s", - default: "", - isCosmetic: false, - type: ParameterType.STRING - }; - expect(isIndexedParameter(text)).toBe(false); + expect(isIndexedParameter(stringParam("s"))).toBe(false); }); it("does not vary a parameter excluded from properties", () => { diff --git a/src/backend/features/configurations/input-parser.test.ts b/src/backend/features/configurations/input-parser.test.ts index 2e6d820b2..fd0ee4215 100644 --- a/src/backend/features/configurations/input-parser.test.ts +++ b/src/backend/features/configurations/input-parser.test.ts @@ -18,143 +18,52 @@ const defaultOptions = ( max: valueWithUnits(100, displayUnit) }); -describe("evaluateExpression - valid expressions", () => { - it("parses simple number (unitless)", () => { - const res = evaluateExpression( - "42", - defaultOptions(QuantityType.REAL, Unit.UNITLESS) - ); - expect(res.hasError).toBe(false); - expect((res as Result).displayExpression).toBe("42"); - }); - - it("parses number with unit", () => { - const res = evaluateExpression( - "10 mm", - defaultOptions(QuantityType.LENGTH) - ); - expect(res.hasError).toBe(false); - expect((res as Result).displayExpression).toBe("10 mm"); - }); - - it("parses addition with units", () => { - const res = evaluateExpression("5 mm + 5 mm", defaultOptions()); - expect(res.hasError).toBe(false); - expect((res as Result).displayExpression).toBe("10 mm"); - }); - - it("parses subtraction with units", () => { - const res = evaluateExpression("15 mm - 5 mm", defaultOptions()); - expect(res.hasError).toBe(false); - expect((res as Result).displayExpression).toBe("10 mm"); - }); - - it("parses multiplication with unit and number", () => { - const res = evaluateExpression("2 * 5 mm", defaultOptions()); - expect(res.hasError).toBe(false); - expect((res as Result).displayExpression).toBe("10 mm"); - }); - - it("parses division with unit and number", () => { - const res = evaluateExpression("10 / 2 mm", defaultOptions()); - expect(res.hasError).toBe(false); - expect((res as Result).displayExpression).toBe("5 mm"); - }); - - it("parses parenthesis with unit", () => { - const res = evaluateExpression("(2 + 3) mm", defaultOptions()); - expect(res.hasError).toBe(false); - expect((res as Result).displayExpression).toBe("5 mm"); - }); - - it("parses negative numbers", () => { - const res = evaluateExpression("-5 mm", defaultOptions()); - expect(res.hasError).toBe(false); - expect((res as Result).displayExpression).toBe("-5 mm"); - }); - - it("parses angles in degrees", () => { - const res = evaluateExpression( - "90 deg", - defaultOptions(QuantityType.ANGLE, Unit.DEGREE) - ); - expect(res.hasError).toBe(false); - expect((res as Result).displayExpression).toBe("90 deg"); - }); - - it("parses angles in radians", () => { - const res = evaluateExpression( +/** Every valid case reads the same way: an expression in, a display string out. */ +describe("evaluateExpression", () => { + const REAL = defaultOptions(QuantityType.REAL, Unit.UNITLESS); + const LENGTH = defaultOptions(); + const DEGREES = defaultOptions(QuantityType.ANGLE, Unit.DEGREE); + + it.each([ + ["42", REAL, "42"], + ["10 mm", LENGTH, "10 mm"], + ["5 mm + 5 mm", LENGTH, "10 mm"], + ["15 mm - 5 mm", LENGTH, "10 mm"], + ["2 * 5 mm", LENGTH, "10 mm"], + ["10 / 2 mm", LENGTH, "5 mm"], + ["(2 + 3) mm", LENGTH, "5 mm"], + ["-5 mm", LENGTH, "-5 mm"], + ["90 deg", DEGREES, "90 deg"], + // The display unit fills in for an expression that names none. + ["5", LENGTH, "5 mm"], + [" 7 mm + 3 mm ", LENGTH, "10 mm"] + ])("evaluates %s", (expression, options, display) => { + const result = evaluateExpression(expression, options); + expect(result.hasError).toBe(false); + expect((result as Result).displayExpression).toBe(display); + }); + + it("evaluates an angle in radians", () => { + const result = evaluateExpression( "3.14159265359 rad", defaultOptions(QuantityType.ANGLE, Unit.RADIAN) ); - expect(res.hasError).toBe(false); - expect((res as Result).displayExpression).toContain("rad"); - }); - - it("applies default unit for unitless input", () => { - const res = evaluateExpression("5", defaultOptions()); - expect(res.hasError).toBe(false); - expect((res as Result).displayExpression).toBe("5 mm"); - }); - - it("handles whitespace and spacing", () => { - const res = evaluateExpression( - " 7 mm + 3 mm ", - defaultOptions() - ); - expect(res.hasError).toBe(false); - expect((res as Result).displayExpression).toBe("10 mm"); - }); -}); - -describe("evaluateExpression - failure cases", () => { - it("fails on empty input", () => { - const res = evaluateExpression("", defaultOptions()); - expect(res.hasError).toBe(true); - }); - - it("fails on invalid unit", () => { - const res = evaluateExpression("5 bananas", defaultOptions()); - expect(res.hasError).toBe(true); - }); - - it("fails on mismatched units in addition", () => { - const res = evaluateExpression("5 mm + 2 deg", defaultOptions()); - expect(res.hasError).toBe(true); - }); - - it("fails on division by zero", () => { - const res = evaluateExpression("10 mm / 0", defaultOptions()); - expect(res.hasError).toBe(true); - }); - - it("fails on invalid syntax", () => { - const res = evaluateExpression("5 +", defaultOptions()); - expect(res.hasError).toBe(true); - }); - - it("fails on unit applied to non-number", () => { - const res = evaluateExpression("(5 mm) mm", defaultOptions()); - expect(res.hasError).toBe(true); - }); - - it("fails on multiplication of two units", () => { - const res = evaluateExpression("5 mm * 2 mm", defaultOptions()); - expect(res.hasError).toBe(true); - }); - - it("fails on division of two units", () => { - const res = evaluateExpression("5 mm / 2 mm", defaultOptions()); - expect(res.hasError).toBe(true); - }); - - it("fails if value is less than min", () => { - const res = evaluateExpression("-100.001 mm", defaultOptions()); - expect(res.hasError).toBe(true); - }); - - it("fails if value is greater than max", () => { - const res = evaluateExpression("100.001 mm", defaultOptions()); - expect(res.hasError).toBe(true); + expect(result.hasError).toBe(false); + expect((result as Result).displayExpression).toContain("rad"); + }); + + it.each([ + ["", "there is nothing to evaluate"], + ["5 bananas", "the unit is not one we know"], + ["5 mm + 2 deg", "the units do not match"], + ["10 mm / 0", "it divides by zero"], + ["5 +", "the syntax is incomplete"], + ["(5 mm) mm", "a unit is applied to something already dimensioned"], + ["5 mm * 2 mm", "two units are multiplied"], + ["5 mm / 2 mm", "two units are divided"], + ["-100.001 mm", "it falls below the minimum"], + ["100.001 mm", "it rises above the maximum"] + ])("rejects %s, since %s", (expression) => { + expect(evaluateExpression(expression, LENGTH).hasError).toBe(true); }); }); diff --git a/src/backend/features/configurations/models.ts b/src/backend/features/configurations/models.ts index fd08744b0..e4b9e612a 100644 --- a/src/backend/features/configurations/models.ts +++ b/src/backend/features/configurations/models.ts @@ -142,10 +142,9 @@ export type ParameterValues = Record<string, string>; * UI can read it back without re-querying Onshape. */ /** - * The part an element resolves to, as one probe of Onshape read it. Probed from - * the element's own defaults this describes the element; probed from a specific - * configuration it is a {@link ConfigurationRecord}. A field is absent when the - * probe found no value for it. + * The part an element resolves to, as one probe read it: from the element's own + * defaults it describes the element, from one configuration a + * {@link ConfigurationRecord}. */ export interface PartMetadata { partNumber?: string; diff --git a/src/backend/features/configurations/utils.ts b/src/backend/features/configurations/utils.ts index a78355fb1..74a788148 100644 --- a/src/backend/features/configurations/utils.ts +++ b/src/backend/features/configurations/utils.ts @@ -88,14 +88,8 @@ export function evaluateCondition( return true; } /** - * The page for a part: a description that is already a url wins, since it names - * the exact product, over one derived from the vendor and part number. - * - * The part number names its own vendor most precisely, since a generic - * insertable still carries a number only one vendor sells. Onshape's vendor - * field and then the insertable's own vendors stand in — the latter only when - * they name one, since a part configurable across several does not say which - * this record is. + * The page for a part, in descending precision: a description that is already a + * url, then the vendor the part number names, then the taggings standing in. */ export function getPartUrl( record: PartMetadata, diff --git a/src/backend/features/library/db.ts b/src/backend/features/library/db.ts index d3c2758ad..28659ac57 100644 --- a/src/backend/features/library/db.ts +++ b/src/backend/features/library/db.ts @@ -35,9 +35,8 @@ export async function getLibraryOut( .where(eq(insertables.libraryId, libraryId)) .orderBy(asc(insertables.sortOrder)) .all(), - // Which insertables are configurable: a configurations row exists - // exactly when there are parameters. Only the ids — the payload stays - // in D1 and is fetched per insertable when one is actually opened. + // Ids only: a configurations row exists exactly when there are + // parameters, and its payload is fetched when one is opened. db .select({ id: configurations.id }) .from(configurations) @@ -179,10 +178,8 @@ export async function rebuildSearchDb( } /** - * Assembles the per-insertable records `buildSearchDb` dedupes into the - * part-number search map: the element's own part data, plus one per indexed - * configuration. A left join, since an unconfigurable element still has both a - * part number and no configurations row. + * The records `buildSearchDb` dedupes: an element's own part data plus one per + * indexed configuration. Left joined — an unconfigurable element has no row. */ async function getRecordsMap( db: Db, diff --git a/src/backend/features/library/vendors.test.ts b/src/backend/features/library/vendors.test.ts index 151aee8a5..6d07e7f4e 100644 --- a/src/backend/features/library/vendors.test.ts +++ b/src/backend/features/library/vendors.test.ts @@ -2,40 +2,33 @@ import { describe, expect, it } from "vitest"; import { Vendor, getVendorPartUrl, toVendor } from "./vendors"; describe("getVendorPartUrl", () => { - it("lowercases the part number for WCP, whose urls are lowercase", () => { - expect(getVendorPartUrl(Vendor.WCP, "WCP-1025")).toBe( - "https://wcproducts.com/products/wcp-1025" - ); - }); - - it("keeps McMaster's part number as it is written", () => { - expect(getVendorPartUrl(Vendor.MCM, "91251A445")).toBe( - "https://www.mcmaster.com/91251A445/" - ); - }); - - it("searches AndyMark, whose search is lowercase", () => { - expect(getVendorPartUrl(Vendor.AM, "AM-5833")).toBe( + // Each vendor writes its own casing, and only some have a per-part page. + it.each([ + [Vendor.WCP, "WCP-1025", "https://wcproducts.com/products/wcp-1025"], + [Vendor.MCM, "91251A445", "https://www.mcmaster.com/91251A445/"], + [ + Vendor.AM, + "AM-5833", "https://andymark.com/pages/search-results-page?q=am-5833" - ); - }); - - it("searches REV, which has no per-part url", () => { - expect(getVendorPartUrl(Vendor.REV, "REV-42-1442")).toBe( + ], + [ + Vendor.REV, + "REV-42-1442", "https://www.revrobotics.com/search.php?search_query=REV-42-1442§ion=product" - ); - }); - - it("searches The Thrifty Bot, likewise", () => { - expect(getVendorPartUrl(Vendor.TTB, "TTB-0008")).toBe( + ], + [ + Vendor.TTB, + "TTB-0008", "https://www.thethriftybot.com/search?type=product&q=TTB-0008" - ); - }); - - it("escapes a part number before putting it in a url", () => { - expect(getVendorPartUrl(Vendor.TTB, "TTB 1&2")).toBe( + ], + // Escaped, since a part number can carry url syntax. + [ + Vendor.TTB, + "TTB 1&2", "https://www.thethriftybot.com/search?type=product&q=TTB%201%262" - ); + ] + ])("builds %s's url for %s", (vendor, partNumber, url) => { + expect(getVendorPartUrl(vendor, partNumber)).toBe(url); }); it.each([Vendor.SDS, Vendor.VEX, Vendor.CUSTOM])( diff --git a/src/backend/features/library/vendors.ts b/src/backend/features/library/vendors.ts index 2de654ce0..29563eed4 100644 --- a/src/backend/features/library/vendors.ts +++ b/src/backend/features/library/vendors.ts @@ -32,9 +32,8 @@ export function toVendor(vendor: string | undefined): Vendor | undefined { } /** - * The vendor a part number names itself, e.g. `WCP-1025` or `am-5833`. More - * reliable than the vendor an insertable is tagged with, which is generic - * wherever one part is configurable across several vendors. + * The vendor a part number names itself, e.g. `WCP-1025` — more precise than an + * insertable's tagging, which is generic wherever one part spans vendors. */ export function parsePartNumberVendor( partNumber: string | undefined diff --git a/src/backend/features/load/load-insertable.test.ts b/src/backend/features/load/load-insertable.test.ts index 30e19a303..7d7db0c36 100644 --- a/src/backend/features/load/load-insertable.test.ts +++ b/src/backend/features/load/load-insertable.test.ts @@ -3,10 +3,8 @@ import { eq } from "drizzle-orm"; import { beforeEach, describe, expect, it } from "vitest"; import { getDb } from "../../db/client"; import { configurations, insertables } from "../../db/schema"; -import type { - ConfigurationRecord, - PartMetadata -} from "../configurations/models"; +import type { ParameterValues, PartMetadata } from "../configurations/models"; +import { configurationRecord } from "../../../__test_utils__/configuration-fixtures"; import { TEST_PARAMETERS, TEST_PART_STUDIO_ID, @@ -29,18 +27,11 @@ function readInsertable() { .get(); } -/** The element's own metadata, with the given part number and defaults. */ -function partMetadata(partNumber?: string): PartMetadata { - return { partNumber, hasMultipleParts: false, isOpenComposite: false }; -} +const partMetadata = (partNumber?: string): PartMetadata => + configurationRecord({ partNumber }); -/** Builds a configuration record with the given part number and defaults. */ -function record( - partNumber?: string, - configuration: Record<string, string> = {} -): ConfigurationRecord { - return { ...partMetadata(partNumber), configuration }; -} +const record = (partNumber?: string, configuration: ParameterValues = {}) => + configurationRecord({ partNumber, configuration }); describe("saveInsertable", () => { beforeEach(async () => { diff --git a/src/backend/features/load/parse-configuration-records.test.ts b/src/backend/features/load/parse-configuration-records.test.ts index 2b0c2c9b7..a4b02e473 100644 --- a/src/backend/features/load/parse-configuration-records.test.ts +++ b/src/backend/features/load/parse-configuration-records.test.ts @@ -12,7 +12,10 @@ import { ParameterValues, ConfigurationParameter } from "../configurations/models"; -import { enumParam } from "../../../__test_utils__/configuration-fixtures"; +import { + enumParam, + paramsWithConfigs +} from "../../../__test_utils__/configuration-fixtures"; import { ElementType } from "../../lib/onshape/element-type"; import { BuildIssueType } from "../build-checker/issues"; import { Vendor } from "../library/vendors"; @@ -35,16 +38,6 @@ const CLIENT = {} as OnshapeApi; afterEach(() => vi.restoreAllMocks()); -/** A single enum whose N options make the element enumerate to N configurations. */ -function paramsWithConfigs(count: number): ConfigurationParameter[] { - return [ - enumParam( - "A", - Array.from({ length: count }, (_, i) => `o${i}`) - ) - ]; -} - const MANY = [{ type: BuildIssueType.MANUAL_INDEXING_REQUIRED }]; const TOO_MANY = [{ type: BuildIssueType.CONFIGURATION_LIMIT_EXCEEDED }]; @@ -156,11 +149,6 @@ describe("parsePartStudioRecord", () => { it("returns an all-null record for an empty response", () => { expect(parsePartStudioRecord([], { A: "a1" }, false)).toEqual({ configuration: { A: "a1" }, - partNumber: undefined, - name: undefined, - description: undefined, - material: undefined, - vendor: undefined, hasMultipleParts: false, isOpenComposite: false }); @@ -204,20 +192,28 @@ function mockParts( ); } +/** Probes an element the way the load does: its own combinations, in full. */ +function probeRecords( + parameters: ConfigurationParameter[], + options: { elementType?: ElementType; isOpenComposite?: boolean } = {} +) { + return parseConfigurationRecords( + CLIENT, + PATH, + options.elementType ?? ElementType.PART_STUDIO, + parameters, + countConfigurations(parameters).configurations, + options.isOpenComposite ?? false + ); +} + describe("parseConfigurationRecords", () => { it("returns the element's part data plus a record per configuration", async () => { mockParts((configuration) => [ { partId: "p", partNumber: `PN-${configuration.A ?? "default"}` } ]); - const result = await parseConfigurationRecords( - CLIENT, - PATH, - ElementType.PART_STUDIO, - [enumParam("A", ["a1", "a2"])], - countConfigurations([enumParam("A", ["a1", "a2"])]).configurations, - false - ); + const result = await probeRecords([enumParam("A", ["a1", "a2"])]); expect(result.buildIssues).toEqual([]); // "a1" is A's default, so that combination is the element's own probe @@ -233,14 +229,7 @@ describe("parseConfigurationRecords", () => { ]); const vendorParam = enumParam("Vendor", ["wcp", "am"]); - const result = await parseConfigurationRecords( - CLIENT, - PATH, - ElementType.PART_STUDIO, - [vendorParam], - countConfigurations([vendorParam]).configurations, - false - ); + const result = await probeRecords([vendorParam]); expect(result.partMetadata?.vendor).toBe(Vendor.WCP); expect(result.records.map((r) => r.vendor)).toEqual([Vendor.AM]); @@ -251,14 +240,7 @@ describe("parseConfigurationRecords", () => { { partId: "p", partNumber: "PN", vendor: "AndyMark", name: "WCP" } ]); - const result = await parseConfigurationRecords( - CLIENT, - PATH, - ElementType.PART_STUDIO, - [], - [], - false - ); + const result = await probeRecords([]); expect(result.partMetadata?.vendor).toBe("AndyMark"); }); @@ -268,16 +250,9 @@ describe("parseConfigurationRecords", () => { { partId: "p", partNumber: `PN-${configuration.A ?? "default"}` } ]); - const result = await parseConfigurationRecords( - CLIENT, - PATH, - ElementType.PART_STUDIO, - [{ ...enumParam("A", ["a1", "a2"]), default: "a2" }], - countConfigurations([ - { ...enumParam("A", ["a1", "a2"]), default: "a2" } - ]).configurations, - false - ); + const result = await probeRecords([ + { ...enumParam("A", ["a1", "a2"]), default: "a2" } + ]); expect(result.partMetadata?.partNumber).toBe("PN-default"); expect(result.records.map((r) => r.partNumber)).toEqual(["PN-a1"]); @@ -321,14 +296,9 @@ describe("parseConfigurationRecords", () => { ] ); - const result = await parseConfigurationRecords( - CLIENT, - PATH, - ElementType.PART_STUDIO, - [enumParam("A", ["a1", "a2"])], - countConfigurations([enumParam("A", ["a1", "a2"])]).configurations, - true - ); + const result = await probeRecords([enumParam("A", ["a1", "a2"])], { + isOpenComposite: true + }); expect(result.buildIssues).toEqual([ { type: BuildIssueType.UNSTABLE_COMPOSITE } @@ -342,14 +312,7 @@ describe("parseConfigurationRecords", () => { { partId: "p", partNumber: "PN-default" } ]); - const result = await parseConfigurationRecords( - CLIENT, - PATH, - ElementType.PART_STUDIO, - paramsWithConfigs(600), - countConfigurations(paramsWithConfigs(600)).configurations, - false - ); + const result = await probeRecords(paramsWithConfigs(600)); expect(result.buildIssues).toEqual([]); expect(result.records).toHaveLength(0); @@ -364,22 +327,13 @@ describe("parseConfigurationRecords", () => { properties: [{ name: "Part number", value: "AM-1" }] }); - const result = await parseConfigurationRecords( - CLIENT, - PATH, - ElementType.ASSEMBLY, - [], - countConfigurations([]).configurations, - false - ); + const result = await probeRecords([], { + elementType: ElementType.ASSEMBLY + }); expect(result.records).toEqual([]); expect(result.partMetadata).toEqual({ partNumber: "AM-1", - name: undefined, - description: undefined, - material: undefined, - vendor: undefined, hasMultipleParts: false, isOpenComposite: false }); diff --git a/src/backend/features/load/parse-configuration.test.ts b/src/backend/features/load/parse-configuration.test.ts index 17c4176a4..393cfdfb2 100644 --- a/src/backend/features/load/parse-configuration.test.ts +++ b/src/backend/features/load/parse-configuration.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from "vitest"; import { OptionVisibilityType, - ConfigurationParameter, ParameterType, + ParameterValues, VisibilityCondition, VisibilityType } from "../configurations/models"; +import { enumParam } from "../../../__test_utils__/configuration-fixtures"; import { LogicalOp, QuantityType, Unit } from "../configurations/enums"; import { evaluateCondition } from "../configurations/utils"; import { parseOnshapeConfiguration } from "./parse-configuration"; @@ -226,169 +227,52 @@ describe("parseOnshapeConfiguration", () => { }); describe("evaluateCondition", () => { - const makeEnumParam = ( - id: string, - options: string[] - ): ConfigurationParameter => ({ - type: ParameterType.ENUM, + const sizes = enumParam("size", ["xs", "sm", "md", "lg", "xl"]); + const equals = (id: string, value: string): VisibilityCondition => ({ + type: VisibilityType.EQUAL, id, - name: id, - isCosmetic: false, - default: options[0], - condition: undefined, - optionConditions: [], - options: options.map((option) => ({ id: option, name: option })) + value }); - - it("returns true when condition is undefined", () => { - expect(evaluateCondition(undefined, {}, [])).toBe(true); - }); - - it("ALWAYS_SHOWN: returns true", () => { - const condition: VisibilityCondition = { - type: VisibilityType.ALWAYS_SHOWN - }; - expect(evaluateCondition(condition, {}, [])).toBe(true); - }); - - it("EQUAL: returns true when value matches", () => { - const condition = { - type: VisibilityType.EQUAL as const, - id: "size", - value: "large" - }; - expect(evaluateCondition(condition, { size: "large" }, [])).toBe(true); - }); - - it("EQUAL: returns false when value does not match", () => { - const condition = { - type: VisibilityType.EQUAL as const, - id: "size", - value: "large" - }; - expect(evaluateCondition(condition, { size: "small" }, [])).toBe(false); - }); - - it("RANGE: returns true when value is in range", () => { - const param = makeEnumParam("size", ["xs", "sm", "md", "lg", "xl"]); - const condition: VisibilityCondition = { - type: VisibilityType.RANGE, - id: "size", - start: "sm", - end: "lg" - }; - expect(evaluateCondition(condition, { size: "md" }, [param])).toBe( - true - ); - }); - - it("RANGE: returns false when value is below start", () => { - const param = makeEnumParam("size", ["xs", "sm", "md", "lg", "xl"]); - const condition: VisibilityCondition = { - type: VisibilityType.RANGE, - id: "size", - start: "sm", - end: "lg" - }; - expect(evaluateCondition(condition, { size: "xs" }, [param])).toBe( - false - ); + /** `a=1` and `b=2`, joined by the operation under test. */ + const bothOf = (operation: LogicalOp): VisibilityCondition => ({ + type: VisibilityType.LOGICAL, + operation, + children: [equals("a", "1"), equals("b", "2")] }); + const smToLg: VisibilityCondition = { + type: VisibilityType.RANGE, + id: "size", + start: "sm", + end: "lg" + }; - it("RANGE: returns false when value is above end", () => { - const param = makeEnumParam("size", ["xs", "sm", "md", "lg", "xl"]); - const condition: VisibilityCondition = { - type: VisibilityType.RANGE, - id: "size", - start: "sm", - end: "lg" - }; - expect(evaluateCondition(condition, { size: "xl" }, [param])).toBe( + const cases: [ + string, + VisibilityCondition | undefined, + ParameterValues, + boolean + ][] = [ + ["no condition", undefined, {}, true], + ["always shown", { type: VisibilityType.ALWAYS_SHOWN }, {}, true], + ["equal, matching", equals("size", "lg"), { size: "lg" }, true], + ["equal, differing", equals("size", "lg"), { size: "sm" }, false], + ["range, inside", smToLg, { size: "md" }, true], + ["range, below start", smToLg, { size: "xs" }, false], + ["range, above end", smToLg, { size: "xl" }, false], + ["and, both matching", bothOf(LogicalOp.AND), { a: "1", b: "2" }, true], + [ + "and, one differing", + bothOf(LogicalOp.AND), + { a: "1", b: "9" }, false - ); - }); + ], + ["or, one matching", bothOf(LogicalOp.OR), { a: "1", b: "9" }, true], + ["or, none matching", bothOf(LogicalOp.OR), { a: "9", b: "9" }, false] + ]; - it("LOGICAL AND: true when all children match", () => { - const condition: VisibilityCondition = { - type: VisibilityType.LOGICAL, - operation: LogicalOp.AND, - children: [ - { - type: VisibilityType.EQUAL, - id: "a", - value: "1" - }, - { - type: VisibilityType.EQUAL, - id: "b", - value: "2" - } - ] - }; - expect(evaluateCondition(condition, { a: "1", b: "2" }, [])).toBe(true); - }); - - it("LOGICAL AND: false when one child does not match", () => { - const condition: VisibilityCondition = { - type: VisibilityType.LOGICAL, - operation: LogicalOp.AND, - children: [ - { - type: VisibilityType.EQUAL, - id: "a", - value: "1" - }, - { - type: VisibilityType.EQUAL, - id: "b", - value: "2" - } - ] - }; - expect(evaluateCondition(condition, { a: "1", b: "9" }, [])).toBe( - false - ); - }); - - it("LOGICAL OR: true when one child matches", () => { - const condition: VisibilityCondition = { - type: VisibilityType.LOGICAL, - operation: LogicalOp.OR, - children: [ - { - type: VisibilityType.EQUAL, - id: "a", - value: "1" - }, - { - type: VisibilityType.EQUAL, - id: "b", - value: "2" - } - ] - }; - expect(evaluateCondition(condition, { a: "1", b: "9" }, [])).toBe(true); - }); - - it("LOGICAL OR: false when no children match", () => { - const condition: VisibilityCondition = { - type: VisibilityType.LOGICAL, - operation: LogicalOp.OR, - children: [ - { - type: VisibilityType.EQUAL, - id: "a", - value: "1" - }, - { - type: VisibilityType.EQUAL, - id: "b", - value: "2" - } - ] - }; - expect(evaluateCondition(condition, { a: "9", b: "9" }, [])).toBe( - false + it.each(cases)("%s", (_name, condition, configuration, expected) => { + expect(evaluateCondition(condition, configuration, [sizes])).toBe( + expected ); }); }); diff --git a/src/backend/features/search/search-index.test.ts b/src/backend/features/search/search-index.test.ts index 2ab00e528..7a82df118 100644 --- a/src/backend/features/search/search-index.test.ts +++ b/src/backend/features/search/search-index.test.ts @@ -1,16 +1,7 @@ import { describe, expect, it } from "vitest"; import { toSearchRecords } from "./search-index"; -import type { ConfigurationRecord } from "../configurations/models"; import { Vendor } from "../library/vendors"; - -function record(fields: Partial<ConfigurationRecord>): ConfigurationRecord { - return { - configuration: {}, - hasMultipleParts: false, - isOpenComposite: false, - ...fields - }; -} +import { configurationRecord as record } from "../../../__test_utils__/configuration-fixtures"; describe("toSearchRecords", () => { it("drops a part number that only repeats the name", () => { diff --git a/src/backend/features/search/search-index.ts b/src/backend/features/search/search-index.ts index 16a7edd36..3d7593dba 100644 --- a/src/backend/features/search/search-index.ts +++ b/src/backend/features/search/search-index.ts @@ -151,9 +151,8 @@ function uniqueJoin(values: (string | undefined)[]): string { } /** - * A part number that only repeats the name identifies nothing — it is what a - * generic part is given when there is no real number to use — so it is dropped - * rather than shown, searched, or turned into a vendor link. + * A part number repeating the name identifies nothing — it is what a generic + * part is given for want of a real one — so it is neither shown nor searched. */ function withoutRepeatedPartNumber( record: ConfigurationRecord diff --git a/src/backend/lib/onshape/api-path.ts b/src/backend/lib/onshape/api-path.ts index dbb89947d..f2ce936ab 100644 --- a/src/backend/lib/onshape/api-path.ts +++ b/src/backend/lib/onshape/api-path.ts @@ -4,10 +4,7 @@ export interface ApiPathOptions { endRoute?: string; endId?: string; featureId?: string; - /** - * When true and the path is a DocumentPath, emits `/{documentId}` instead of `/d/{documentId}`. - * Used for document-level Onshape endpoints that don't use the `/d/` prefix. - */ + /** For the document-level endpoints that take a bare id, without `/d/`. */ skipDocumentD?: boolean; } diff --git a/src/backend/lib/onshape/endpoints/documents.ts b/src/backend/lib/onshape/endpoints/documents.ts index b091339e5..0d150bcd3 100644 --- a/src/backend/lib/onshape/endpoints/documents.ts +++ b/src/backend/lib/onshape/endpoints/documents.ts @@ -177,11 +177,7 @@ export function getWorkspaceMicroversionId( .then((r: any) => r.microversion); } -/** - * An undocumented OAuth-only endpoint which returns all external references in a document. - * - * Generally speaking, this returns a list of the external workspaces referenced by each tab in the instance. - */ +/** Undocumented and OAuth-only: the external workspaces each tab references. */ export function getExternalReferences( client: OAuthApi, instancePath: InstancePath diff --git a/src/backend/lib/onshape/endpoints/permissions.ts b/src/backend/lib/onshape/endpoints/permissions.ts index dec75d989..9e55fa871 100644 --- a/src/backend/lib/onshape/endpoints/permissions.ts +++ b/src/backend/lib/onshape/endpoints/permissions.ts @@ -15,11 +15,7 @@ export enum Permission { OWNER = "OWNER" } -/** - * Returns the permissions the authenticated user has on a document. - * - * Returns an empty array if the document is not shared with the user (Onshape returns 403 in that case). - */ +/** Empty when the document is not shared with the caller, which Onshape 403s. */ export async function getPermissions( client: OnshapeApi, documentPath: DocumentPath diff --git a/src/backend/lib/onshape/endpoints/thumbnails.ts b/src/backend/lib/onshape/endpoints/thumbnails.ts index 93a018fae..00e31ed9c 100644 --- a/src/backend/lib/onshape/endpoints/thumbnails.ts +++ b/src/backend/lib/onshape/endpoints/thumbnails.ts @@ -33,11 +33,7 @@ export function getElementThumbnail( return client.getImage(path); } -/** - * Returns the thumbnail of a given element in a workspace, optionally with a specific configuration. - * - * Compared to `getElementThumbnail`, this endpoint supports configurations but is limited to workspaces only. - */ +/** Unlike `getElementThumbnail` this takes a configuration, but only in a workspace. */ export function getThumbnailFromWorkspace( client: OnshapeApi, elementPath: ElementPath, @@ -85,11 +81,7 @@ export async function getThumbnailId( return thumbnailId; } -/** - * Returns the thumbnail for a given thumbnail ID. - * - * WARNING: This endpoint is very buggy and can fail repeatedly while Onshape generates the thumbnail in the background. - */ +/** Fails repeatedly while Onshape renders the thumbnail in the background. */ export function getThumbnailFromId( client: OnshapeApi, thumbnailId: string, diff --git a/src/frontend/features/auth/access-level.tsx b/src/frontend/features/auth/access-level.tsx index b9999fc61..109de45f6 100644 --- a/src/frontend/features/auth/access-level.tsx +++ b/src/frontend/features/auth/access-level.tsx @@ -32,9 +32,8 @@ export function getAccessDataQuery() { export interface ResolvedAccessData extends AccessData { currentAccessLevel: AccessLevel; /** - * The query's own pending flag. While set, the rest of these are the - * placeholder, so anything that renders for a *signed-out* caller has to - * wait or it flashes; positive gates can just read `signedIn`. + * While set, the rest are the placeholder — so anything rendered for a + * *signed-out* caller must wait or it flashes. Positive gates need not. */ isPending: boolean; } diff --git a/src/frontend/features/library/card-hooks.ts b/src/frontend/features/library/card-hooks.ts index 1169eea1a..05d855ba0 100644 --- a/src/frontend/features/library/card-hooks.ts +++ b/src/frontend/features/library/card-hooks.ts @@ -94,10 +94,7 @@ export function useSetVisibilityMutation( return { mutate, isPending: mutation.isPending }; } -/** - * Returns true if the insertable should be hidden from the current user. - * Note this is different from whether the insertable is visible since admins can always see hidden insertables. - */ +/** Narrower than `isVisible`: an admin still sees what is hidden. */ export function useIsInsertableHidden(insertable: InsertableOut): boolean { const { currentAccessLevel } = useAccessData(); return useMemo(() => { diff --git a/src/frontend/features/library/components/card-components.tsx b/src/frontend/features/library/components/card-components.tsx index b2009771a..3c8c64b5f 100644 --- a/src/frontend/features/library/components/card-components.tsx +++ b/src/frontend/features/library/components/card-components.tsx @@ -225,9 +225,8 @@ export function CardTitle(props: CardTitleProps) { } /** - * The part number never shrinks, so the part name gives up every character - * before it loses one: the number is what identifies the part. Capped at the - * row, past which there is nothing left to take. + * Never shrinks, so the part name gives up every character before the number + * loses one. Capped at the row, past which there is nothing left to take. */ const PART_NUMBER_FIXED = { flexShrink: 0, maxWidth: "100%" }; diff --git a/src/frontend/features/search/filter.ts b/src/frontend/features/search/filter.ts index 6086966b6..cba00973b 100644 --- a/src/frontend/features/search/filter.ts +++ b/src/frontend/features/search/filter.ts @@ -25,10 +25,8 @@ export interface FilteredInsertables { filtered: VendorFilterResult; } -/** - * Returns an ordered list of insertables in a document and tracks how many were filtered by vendors. - * Does not include handling for being in a document since this should only be used when search is not active. - */ +/** Ordered insertables plus the vendor-filtered count. Browsing only: an + * active search filters through `doSearch` instead. */ export function filterInsertables( insertables: InsertableOut[], args: FilterArgs diff --git a/src/frontend/features/search/search.test.ts b/src/frontend/features/search/search.test.ts index 3c369d84b..82bef4d2d 100644 --- a/src/frontend/features/search/search.test.ts +++ b/src/frontend/features/search/search.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; +import MiniSearch from "minisearch"; import { buildSearchDb, processTerm, - tokenize + tokenize, + type SearchDocument } from "@backend/features/search/search-index"; import { doSearch, type Position } from "./search"; import { LibraryOut } from "@backend/features/library/contract"; @@ -11,47 +13,30 @@ import { ConfigurationRecord, ParameterValues } from "@backend/features/configurations/models"; +import { configurationRecord } from "../../../__test_utils__/configuration-fixtures"; -/** Builds a configuration record carrying a part number, name, + configuration. */ -function record( +const record = ( partNumber: string | undefined, configuration: ParameterValues, name?: string -): ConfigurationRecord { - return { - configuration, - partNumber, - name, - hasMultipleParts: false, - isOpenComposite: false - }; -} +) => configurationRecord({ partNumber, configuration, name }); -describe("processTerm", () => { - it("should process camelCase", () => { - const result = processTerm("MAXSpline"); - expect(result).toEqual( - expect.arrayContaining(["max", "spline", "maxspline"]) - ); - }); +/** Hidden insertables shown: these tests are about matching, not visibility. */ +const search = (searchDb: MiniSearch<SearchDocument>, query: string) => + doSearch(searchDb, query, undefined, undefined, true); - it("should process CapitalCase", () => { - const result = processTerm("MaxSpline"); - expect(result).toEqual( +describe("processTerm", () => { + it.each(["MAXSpline", "MaxSpline"])("splits %s into its words", (term) => { + expect(processTerm(term)).toEqual( expect.arrayContaining(["max", "spline", "maxspline"]) ); }); }); describe("tokenize", () => { - it("should keep quotes", () => { - const result = tokenize('1" Linear (REV)'); - expect(result).toEqual(["1", "Linear", "REV"]); - }); - - it("should strip punctuation", () => { - const result = tokenize("10-32 Bearings & Bushings #X-Contact"); - expect(result).toEqual([ + it("splits on punctuation, keeping the words whole", () => { + expect(tokenize('1" Linear (REV)')).toEqual(["1", "Linear", "REV"]); + expect(tokenize("10-32 Bearings & Bushings #X-Contact")).toEqual([ "10", "32", "Bearings", @@ -138,13 +123,7 @@ describe("doSearch part-number matching", () => { it("matches a part number and returns its configuration", () => { const searchDb = buildSearchDb(library(), recordsMap); - const { hits } = doSearch( - searchDb, - "217-2601", - undefined, - undefined, - true - ); + const { hits } = search(searchDb, "217-2601"); expect(hits).toHaveLength(1); expect(hits[0].id).toBe("i1"); expect(hits[0].configuration).toEqual({ length: "long" }); @@ -154,26 +133,14 @@ describe("doSearch part-number matching", () => { // matches the whole query — the row must still show a part number. it("falls back to the default record when no one record matches the query", () => { const searchDb = buildSearchDb(library(), recordsMap); - const { hits } = doSearch( - searchDb, - "Bracket 217", - undefined, - undefined, - true - ); + const { hits } = search(searchDb, "Bracket 217"); expect(hits).toHaveLength(1); expect(hits[0].partNumber).toBe("217-2600"); }); it("attaches the default (first) record for a title match", () => { const searchDb = buildSearchDb(library(), recordsMap); - const { hits } = doSearch( - searchDb, - "Bracket", - undefined, - undefined, - true - ); + const { hits } = search(searchDb, "Bracket"); expect(hits).toHaveLength(1); // The insertable's own name matched, so the row shows its default config. expect(hits[0].configuration).toEqual({ length: "short" }); @@ -189,13 +156,7 @@ describe("doSearch part-number matching", () => { record("217-2600", { version: "older" }) ] }); - const { hits } = doSearch( - searchDb, - "217-2600", - undefined, - undefined, - true - ); + const { hits } = search(searchDb, "217-2600"); expect(hits).toHaveLength(1); expect(hits[0].configuration).toEqual({ version: "latest" }); }); @@ -204,54 +165,32 @@ describe("doSearch part-number matching", () => { // Production shape: the element's own part data leads the list as the record an // unset configuration falls back to, followed by one record per configuration. describe("doSearch configuration matching", () => { - const gears: Record<string, ConfigurationRecord[]> = { + const searchDb = buildSearchDb(library("MAXSpline Gear"), { i1: [ record("WCP-1234", {}, "12T MAXSpline Gear"), record("WCP-1235", { teeth: "24" }, "24T MAXSpline Gear"), record("WCP-1236", { teeth: "36" }, "36T MAXSpline Gear") ] - }; + }); it("picks the configuration a term of the query names", () => { - const searchDb = buildSearchDb(library("MAXSpline Gear"), gears); - const { hits } = doSearch( - searchDb, - "maxspline 24t", - undefined, - undefined, - true - ); + const { hits } = search(searchDb, "maxspline 24t"); expect(hits[0].configuration).toEqual({ teeth: "24" }); expect(hits[0].partNumber).toBe("WCP-1235"); }); it("picks it from the distinguishing term alone", () => { - const searchDb = buildSearchDb(library("MAXSpline Gear"), gears); - const { hits } = doSearch(searchDb, "36t", undefined, undefined, true); + const { hits } = search(searchDb, "36t"); expect(hits[0].configuration).toEqual({ teeth: "36" }); }); it("keeps the default when no term distinguishes a configuration", () => { - const searchDb = buildSearchDb(library("MAXSpline Gear"), gears); - const { hits } = doSearch( - searchDb, - "maxspline gear", - undefined, - undefined, - true - ); + const { hits } = search(searchDb, "maxspline gear"); expect(hits[0].configuration).toEqual({}); }); it("lets a part number typed in full outrank a looser name match", () => { - const searchDb = buildSearchDb(library("MAXSpline Gear"), gears); - const { hits } = doSearch( - searchDb, - "WCP-1236", - undefined, - undefined, - true - ); + const { hits } = search(searchDb, "WCP-1236"); expect(hits[0].configuration).toEqual({ teeth: "36" }); }); }); @@ -269,13 +208,7 @@ describe("doSearch highlighting", () => { } function highlightFor(name: string, query: string): string { - const { hits } = doSearch( - buildSearchDb(library(name)), - query, - undefined, - undefined, - true - ); + const { hits } = search(buildSearchDb(library(name)), query); expect(hits).toHaveLength(1); return highlighted(name, hits[0].positions); } @@ -309,12 +242,9 @@ describe("doSearch highlighting", () => { }; function hitFor(query: string) { - const { hits } = doSearch( + const { hits } = search( buildSearchDb(library(), recordsMap), - query, - undefined, - undefined, - true + query ); expect(hits).toHaveLength(1); return hits[0]; @@ -330,14 +260,11 @@ describe("doSearch highlighting", () => { // The segment after a leading-zero one used to be folded into a mixed // number, leaving nothing in the text for the query to underline. it("underlines a leading-zero segment of the part number", () => { - const { hits } = doSearch( + const { hits } = search( buildSearchDb(library(), { i1: [record("TTB-0016-5/32", { size: "small" })] }), - "TTB-0016", - undefined, - undefined, - true + "TTB-0016" ); expect( highlighted( @@ -377,13 +304,7 @@ describe("doSearch name matching", () => { it("matches a part name, returning its number, name, and configuration", () => { const searchDb = buildSearchDb(library(), recordsMap); - const { hits } = doSearch( - searchDb, - "3/4 bearing", - undefined, - undefined, - true - ); + const { hits } = search(searchDb, "3/4 bearing"); expect(hits).toHaveLength(1); expect(hits[0].partName).toBe("3/4 Bearing"); expect(hits[0].partNumber).toBe("217-2601"); @@ -393,13 +314,7 @@ describe("doSearch name matching", () => { it("finds a fractional name by its decimal forms (.5, 0.5, 1/2)", () => { const searchDb = buildSearchDb(library(), recordsMap); for (const query of [".5", "0.5", "1/2"]) { - const { hits } = doSearch( - searchDb, - query, - undefined, - undefined, - true - ); + const { hits } = search(searchDb, query); const hit = hits.find((h) => h.id === "i1"); expect(hit?.partName).toBe("1/2 Bearing"); } diff --git a/src/frontend/features/search/search.ts b/src/frontend/features/search/search.ts index b1782aa0b..bad40aeac 100644 --- a/src/frontend/features/search/search.ts +++ b/src/frontend/features/search/search.ts @@ -163,9 +163,8 @@ export function doSearch( } /** - * The single best record for a hit, by part number or name — whichever the - * query describes better. Falls back to the default record, so every row can - * show a part number and name even when the title alone matched. + * The best record by part number or name, whichever the query describes better, + * else the default — so a row shows one even when only the title matched. */ function matchedRecord( result: MiniSearchResult, @@ -221,9 +220,8 @@ function matchScore( } /** - * The record the query describes best. Scoring by term, not by the whole query: - * "maxspline 24t" names a configuration even though no record reads that way. - * Ties go first-wins, which in enumeration order is the latest option. + * Scored by term rather than by the whole query, which "maxspline 24t" matches + * no record as. Ties go first-wins: the latest option, in enumeration order. */ function findBestRecord( query: string, diff --git a/src/frontend/lib/errors.ts b/src/frontend/lib/errors.ts index 0ab726d36..cbff181b8 100644 --- a/src/frontend/lib/errors.ts +++ b/src/frontend/lib/errors.ts @@ -46,9 +46,8 @@ export function getAppErrorHandler(defaultMessage: string, toastId?: string) { } /** - * Shows an error. Only an error carrying wording meant for the user shows its - * own message; anything else gets `defaultMessage`, which the caller writes for - * its own context. + * Only an error carrying wording meant for the user shows its own message; + * anything else gets `defaultMessage`, written for the caller's context. */ export function handleAppError( error: Error, diff --git a/src/frontend/lib/onshape-params.ts b/src/frontend/lib/onshape-params.ts index 39e71a8ac..ea986e353 100644 --- a/src/frontend/lib/onshape-params.ts +++ b/src/frontend/lib/onshape-params.ts @@ -25,9 +25,8 @@ export interface OnshapeParams extends ElementPath { export type ColorTheme = "light" | "dark"; /** - * Resolves the theme to an actual color scheme. `systemTheme` is Onshape's, - * forwarded by the entry redirect; standalone there is none, so the caller - * passes the OS preference instead. + * `systemTheme` is Onshape's, forwarded by the entry redirect; standalone there + * is none, so the caller passes the OS preference instead. */ export function getColorTheme( theme: Theme,