diff --git a/drizzle/0003_favorites_add_name.sql b/drizzle/0003_favorites_add_name.sql new file mode 100644 index 000000000..b7915df13 --- /dev/null +++ b/drizzle/0003_favorites_add_name.sql @@ -0,0 +1 @@ +ALTER TABLE `favorites` ADD `name` text NOT NULL DEFAULT ''; diff --git a/package-lock.json b/package-lock.json index 8fc6abacd..6ae0ed262 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,7 @@ "typescript": "~6.0.2", "typescript-eslint": "^8.58.2", "vite": "^8.0.10", - "vitest": "^4.1.9", + "vitest": "^4.1.10", "wrangler": "^4.93.0" } }, @@ -4239,16 +4239,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -4257,13 +4257,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.9", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4284,9 +4284,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4297,13 +4297,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.9", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -4311,14 +4311,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4327,9 +4327,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -4337,13 +4337,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", + "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -8001,19 +8001,19 @@ } }, "node_modules/vitest": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", - "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.9", - "@vitest/mocker": "4.1.9", - "@vitest/pretty-format": "4.1.9", - "@vitest/runner": "4.1.9", - "@vitest/snapshot": "4.1.9", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -8041,12 +8041,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/browser-preview": "4.1.9", - "@vitest/browser-webdriverio": "4.1.9", - "@vitest/coverage-istanbul": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index df35fb423..6c7240320 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,16 @@ "typescript": "~6.0.2", "typescript-eslint": "^8.58.2", "vite": "^8.0.10", - "vitest": "^4.1.9", + "vitest": "^4.1.10", "wrangler": "^4.93.0" + }, + "allowScripts": { + "esbuild@0.28.1": true, + "esbuild@0.25.12": true, + "esbuild@0.27.3": true, + "esbuild@0.18.20": true, + "sharp@0.34.5": true, + "workerd@1.20260616.1": true, + "@parcel/watcher@2.5.6": true } } diff --git a/src/__test_utils__/seed.ts b/src/__test_utils__/seed.ts index d024acfeb..75855b15f 100644 --- a/src/__test_utils__/seed.ts +++ b/src/__test_utils__/seed.ts @@ -150,7 +150,8 @@ export async function seedFavorite( db: Db, insertableId: string, userId: string = TEST_USER_ID, - sortOrder = 0 + sortOrder = 0, + name = "test-favorite" ): Promise { const id = crypto.randomUUID(); await seedUser(db, userId); @@ -161,6 +162,7 @@ export async function seedFavorite( userId, libraryId: TEST_LIBRARY_ID, insertableId, + name, sortOrder }) .onConflictDoNothing(); diff --git a/src/backend/library-data.ts b/src/backend/library-data.ts index 1d1982728..3d7e7fe1a 100644 --- a/src/backend/library-data.ts +++ b/src/backend/library-data.ts @@ -13,7 +13,7 @@ import { Insertables, Groups } from "../shared/api-models"; -import { buildSearchDb } from "../shared/search"; +import { buildSearchDb } from "../shared/build-insertable-search"; /** * Assembles the full `LibraryOut` (groups + insertables, in sort order) for a diff --git a/src/backend/routes/favorites.test.ts b/src/backend/routes/favorites.test.ts index 01c398ef0..2bd241ac5 100644 --- a/src/backend/routes/favorites.test.ts +++ b/src/backend/routes/favorites.test.ts @@ -74,7 +74,7 @@ describe("favorites routes", () => { const app = createTestApp(); const res = await app.request( - `${favoritesUrl}?insertableId=${TEST_ASSEMBLY_ID}&id=fav-new`, + `${favoritesUrl}?insertableId=${TEST_ASSEMBLY_ID}&id=fav-new&name=fav-new`, jsonRequest("POST"), env ); @@ -110,7 +110,7 @@ describe("favorites routes", () => { it("is idempotent on a conflicting id", async () => { await seedPartStudio(db); const app = createTestApp(); - const url = `${favoritesUrl}?insertableId=${TEST_PART_STUDIO_ID}&id=dup`; + const url = `${favoritesUrl}?insertableId=${TEST_PART_STUDIO_ID}&id=dup&name=dup`; await app.request(url, jsonRequest("POST"), env); await app.request(url, jsonRequest("POST"), env); @@ -196,7 +196,7 @@ describe("favorites routes", () => { }); }); - describe("POST /default-configuration/:favoriteId", () => { + describe("POST /favorite/:favoriteId", () => { it("persists the default configuration", async () => { await seedPartStudio(db); const favoriteId = await seedFavorite(db, TEST_PART_STUDIO_ID); @@ -204,7 +204,7 @@ describe("favorites routes", () => { const defaultConfiguration = { "param-id": "value" }; const res = await app.request( - `/api/default-configuration/${favoriteId}`, + `/api/favorites/${favoriteId}`, jsonRequest("POST", { defaultConfiguration }), env ); diff --git a/src/backend/routes/favorites.ts b/src/backend/routes/favorites.ts index 95e9f9045..4a6b2aff1 100644 --- a/src/backend/routes/favorites.ts +++ b/src/backend/routes/favorites.ts @@ -32,7 +32,8 @@ async function getFavorites( id: row.id, insertableId: row.insertableId, libraryId, - defaultConfiguration: row.defaultConfiguration ?? undefined + defaultConfiguration: row.defaultConfiguration ?? undefined, + name: row.name ?? undefined }; favoritesOut[row.id] = fav; favoriteOrder.push(row.id); @@ -58,23 +59,18 @@ favoriteRoutes.post("/favorites" + libraryRoute(), async (c) => { const userId = await c.var.getUserId(); const insertableId = c.req.query("insertableId"); const favoriteId = c.req.query("id"); + const name = c.req.query("name"); if (!insertableId) return c.json({ error: "insertableId required" }, 400); if (!favoriteId) return c.json({ error: "id required" }, 400); - + if (!name) return c.json({ error: "name required" }, 400); const db = getDb(c.env.DB); await db.insert(users).values({ id: userId }).onConflictDoNothing(); - const existingCount = await db - .select({ sortOrder: favorites.sortOrder }) - .from(favorites) - .where( - and( - eq(favorites.userId, userId), - eq(favorites.libraryId, libraryId) - ) - ) - .all(); + const existingFavorites = await db.$count( + favorites, + and(eq(favorites.userId, userId), eq(favorites.libraryId, libraryId)) + ); await db .insert(favorites) @@ -83,7 +79,8 @@ favoriteRoutes.post("/favorites" + libraryRoute(), async (c) => { userId, libraryId, insertableId, - sortOrder: existingCount.length + name, + sortOrder: existingFavorites }) .onConflictDoNothing(); @@ -126,18 +123,33 @@ favoriteRoutes.post("/favorite-order" + libraryRoute(), async (c) => { return c.json({ success: true }); }); -/** POST /api/default-configuration/:favoriteId */ -favoriteRoutes.post("/default-configuration/:favoriteId", async (c) => { +/** POST /api/favorites/:favoriteId */ +favoriteRoutes.post("/favorites/:favoriteId", async (c) => { const favoriteId = c.req.param("favoriteId"); const body = await c.req.json<{ - defaultConfiguration: Configuration; + defaultConfiguration?: Configuration | null; + name?: string; }>(); + if (!favoriteId) { + return c.json({ error: "favoriteId is required" }, 400); + } + + if (Object.keys(body).length === 0) { + return c.json( + { error: "defaultConfiguration or name is required" }, + 400 + ); + } + const db = getDb(c.env.DB); - await db - .update(favorites) - .set({ defaultConfiguration: body.defaultConfiguration }) - .where(eq(favorites.id, favoriteId)); + + if (Object.keys(body).length > 0) { + await db + .update(favorites) + .set(body) + .where(eq(favorites.id, favoriteId)); + } return c.json({ success: true }); }); diff --git a/src/frontend/cards/card-components.tsx b/src/frontend/cards/card-components.tsx index a773b4842..2bdae9203 100644 --- a/src/frontend/cards/card-components.tsx +++ b/src/frontend/cards/card-components.tsx @@ -11,7 +11,7 @@ import { IconColor, IconSize } from "../common/style-constants"; import { copyUrlToClipboard, makeUrl, openUrlInNewTab } from "../common/url"; import { PropsWithChildren, ReactNode, useCallback } from "react"; import { AppContextMenu, MenuButton } from "../app-common/app-menu"; -import { SearchHit } from "../search/search"; +import { SearchHit } from "../search/insertable-search"; import { SearchHitTitle } from "../search/search-results"; import { CardThumbnail } from "../insert/thumbnail"; import { ConfigurablePath, InstancePath } from "../../shared/onshape-path"; @@ -112,6 +112,15 @@ export function QuickInsertItems(props: QuickInsertItemProps) { ); } +interface CardTitleVariants { + /** When a `titleComponent` is supplied, this is the override node. */ + titleOverride: ReactNode; + /** When `searchHit` is supplied, this is the highlighted title node. */ + highlightedTitle: ReactNode | null; + /** The plain fallback title node. */ + defaultTitle: ReactNode; +} + interface CardTitleProps { /** * True to use disabled text styles. @@ -125,33 +134,54 @@ interface CardTitleProps { showHiddenTag?: boolean; /** * The title to display. - * Ignored if SearchHit is provided. */ title: string; searchHit?: SearchHit; thumbnailUrls: ThumbnailUrls; + /** Optional title override rendered instead of the default title text. */ + titleComponent?: ReactNode; /** Optional build-status badge rendered after the title. */ buildStatusBadge?: ReactNode; + /** + * Optional render prop that receives all title variants and returns the + * one that should be displayed. + */ + children?: (titles: CardTitleVariants) => ReactNode; } -export function CardTitle(props: CardTitleProps) { - const { searchHit, title, thumbnailUrls, buildStatusBadge } = props; +export function CardTitleGroup(props: CardTitleProps) { + const { + searchHit, + title, + titleComponent, + thumbnailUrls, + buildStatusBadge + } = props; const disabled = props.disabled ?? false; const isHidden = props.showHiddenTag ?? false; - let cardTitle: ReactNode; - if (searchHit) { - cardTitle = ; - } else { - cardTitle = title; - } + const titleOverride = titleComponent; + const highlightedTitle = searchHit ? ( + + ) : null; + const defaultTitle = ( + + {title} + + ); + + const cardTitle: ReactNode = props.children + ? props.children({ + titleOverride, + highlightedTitle, + defaultTitle + }) + : (titleOverride ?? highlightedTitle ?? defaultTitle); return ( - - {cardTitle} - + {cardTitle} {isHidden && ( { + const trimmedName = favoriteName.trim(); + if (trimmedName.length === 0) { + setFavoriteName(defaultTitle); + return; + } + if (trimmedName === defaultTitle) return; + + setFavoriteName(trimmedName); + updateFavoriteNameMutation.mutate({ + favoriteId: favorite.id, + name: trimmedName + }); + }; + + const isSearchActive = uiState.searchQuery && searchHit; + const titleContent = isSearchActive ? ( + favoriteName + ) : ( + setFavoriteName(event.currentTarget.value)} + onBlur={saveFavoriteName} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + }} + onClick={(event) => event.stopPropagation()} + onFocus={(event) => event.stopPropagation()} + styles={{ input: { minWidth: 0 } }} + style={{ + width: "50%", + maxWidth: "100%", + minWidth: 0 + }} + /> + ); + return ( { @@ -66,12 +118,19 @@ export function FavoriteCard(props: FavoriteCardProps): ReactNode { }); }} left={ - + > + {({ defaultTitle }) => { + if (isSearchActive) { + return defaultTitle; + } + return titleContent; + }} + } rightSection={ @@ -117,7 +176,8 @@ function FavoriteMenuItems(props: FavoriteMenuItemsProps): ReactNode { openFavoriteMenu({ favoriteId: favorite.id, insertableName: insertable.name, - defaultConfiguration: favorite.defaultConfiguration + defaultConfiguration: favorite.defaultConfiguration, + favorite: favorite }); }} > @@ -179,3 +239,39 @@ function useSetFavoriteOrderMutation() { } }); } + +function useUpdateFavoriteNameMutation() { + const libraryId = useLibraryId(); + const router = useRouter(); + + const queryKey = favoritesQueryKey(libraryId); + + return useMutation({ + mutationKey: ["update-favorite-name"], + mutationFn: async (args: { favoriteId: string; name: string }) => { + return apiPost("/favorites/" + args.favoriteId, { + body: { name: args.name } + }); + }, + onMutate: async (args) => { + await queryClient.cancelQueries({ queryKey }); + queryClient.setQueryData( + queryKey, + produce((data?: FavoritesData) => { + if (!data) return undefined; + const fav = data.favorites[args.favoriteId]; + if (fav) { + fav.name = args.name; + } + return data; + }) + ); + void router.invalidate(); + }, + onError: getAppErrorHandler("Unexpectedly failed to rename favorite."), + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey }); + void router.invalidate(); + } + }); +} diff --git a/src/frontend/favorites/favorite-menu.tsx b/src/frontend/favorites/favorite-menu.tsx index 2a021f5cc..3437a8c2b 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 { Favorite, type FavoritesData } from "../../shared/api-models"; import { HeartIcon } from "./favorite-button"; import { queryClient } from "../query-client"; import { Configuration } from "../../shared/configuration-models"; @@ -26,15 +26,17 @@ interface OpenFavoriteMenuProps { favoriteId: string; insertableName: string; defaultConfiguration?: Configuration; + favorite: Favorite; } export function openFavoriteMenu(props: OpenFavoriteMenuProps) { - const { favoriteId, insertableName, defaultConfiguration } = props; + const { favoriteId, insertableName, favorite, defaultConfiguration } = + props; modals.open({ title: ( - {insertableName} + {favorite.name ?? insertableName} ), size: 500, @@ -68,7 +70,7 @@ function FavoriteMenuContent(props: FavoriteMenuContentProps): ReactNode { const setDefaultConfigurationMutation = useMutation({ mutationKey: ["set-default-configuration"], mutationFn: async () => { - return apiPost("/default-configuration/" + favoriteId, { + return apiPost("/favorites/" + favoriteId, { body: { defaultConfiguration: configuration } }); }, diff --git a/src/frontend/favorites/favorite-search.test.ts b/src/frontend/favorites/favorite-search.test.ts new file mode 100644 index 000000000..96f5479cb --- /dev/null +++ b/src/frontend/favorites/favorite-search.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { LibraryId, Vendor } from "../../shared/types"; +import type { Favorite, InsertableOut } from "../../shared/api-models"; +import { filterFavoritesForSearch } from "./favorite-search"; + +describe("filterFavoritesForSearch", () => { + it("matches favorites by their own name and id instead of the insertable name or id", () => { + const favorites: Favorite[] = [ + { + id: "fav-1", + insertableId: "insertable-1", + libraryId: LibraryId.FRC_DESIGN_LIB, + name: "My Favorite Wheel" + }, + { + id: "custom-favorite-id", + insertableId: "insertable-2", + libraryId: LibraryId.FRC_DESIGN_LIB, + name: "Unrelated" + } + ]; + + expect( + filterFavoritesForSearch(favorites, "wheel").map( + (result) => result.favorite.id + ) + ).toEqual(["fav-1"]); + + expect( + filterFavoritesForSearch( + [ + { + id: "fav-2", + insertableId: "insertable-3", + libraryId: LibraryId.FRC_DESIGN_LIB, + name: "Linear (REV)" + } + ], + "linear rev" + ).map((result) => result.favorite.id) + ).toEqual(["fav-2"]); + + expect( + filterFavoritesForSearch(favorites, "custom").map( + (result) => result.favorite.id + ) + ).toEqual(["custom-favorite-id"]); + + expect( + filterFavoritesForSearch(favorites, "different-insertable") + ).toEqual([]); + }); + + it("falls back to the insertable name when the favorite has no custom name", () => { + const favorites: Favorite[] = [ + { + id: "fav-3", + insertableId: "insertable-3", + libraryId: LibraryId.FRC_DESIGN_LIB + } + ]; + + const insertables = { + "insertable-3": { + name: "Wheel Assembly", + vendors: [Vendor.REV] + } as InsertableOut + }; + + expect( + filterFavoritesForSearch(favorites, "assembly", insertables).map( + (result) => result.favorite.id + ) + ).toEqual(["fav-3"]); + }); + + it("filters favorites by vendor when a vendor filter is active", () => { + const favorites: Favorite[] = [ + { + id: "fav-rev", + insertableId: "insertable-rev", + libraryId: LibraryId.FRC_DESIGN_LIB + }, + { + id: "fav-vex", + insertableId: "insertable-vex", + libraryId: LibraryId.FRC_DESIGN_LIB + } + ]; + + const insertables = { + "insertable-rev": { + name: "REV Wheel", + vendors: [Vendor.REV] + } as InsertableOut, + "insertable-vex": { + name: "VEX Wheel", + vendors: [Vendor.VEX] + } as InsertableOut + }; + + expect( + filterFavoritesForSearch(favorites, "wheel", insertables, [ + Vendor.REV + ]).map((result) => result.favorite.id) + ).toEqual(["fav-rev"]); + }); +}); diff --git a/src/frontend/favorites/favorite-search.ts b/src/frontend/favorites/favorite-search.ts new file mode 100644 index 000000000..01ba42730 --- /dev/null +++ b/src/frontend/favorites/favorite-search.ts @@ -0,0 +1,160 @@ +import MiniSearch, { Options } from "minisearch"; +import { Favorite, InsertableOut } from "../../shared/api-models"; +import { Vendor } from "../../shared/types"; +import { tokenize, TypedMiniSearchResult } from "../../shared/search-utils"; +import { processTerm } from "../../shared/search-utils"; +import { Position, SearchHit } from "../search/insertable-search"; + +interface FavoriteSearchDocument { + id: string; + favoriteId: string; + name: string; + vendors: Vendor[]; +} + +type FavoriteSearchResult = TypedMiniSearchResult; + +const FAVORITE_SEARCH_OPTIONS: Options = { + fields: ["name"], + storeFields: ["id", "name", "vendors"], + searchOptions: { + prefix: true + }, + tokenize, + processTerm +}; + +function getFavoriteSearchName( + favorite: Favorite, + insertables?: Record +): string { + if (favorite.name) { + return favorite.name; + } + + const insertable = insertables?.[favorite.insertableId]; + return insertable?.name ?? ""; +} + +function buildFavoriteSearchDb( + favorites: Favorite[], + insertables?: Record +): MiniSearch { + const searchDb = new MiniSearch( + FAVORITE_SEARCH_OPTIONS + ); + + searchDb.addAll( + favorites.map((favorite) => ({ + id: favorite.id, + favoriteId: favorite.id, + name: getFavoriteSearchName(favorite, insertables), + vendors: insertables?.[favorite.insertableId]?.vendors ?? [] + })) + ); + + return searchDb; +} + +function generateHighlightPositions(result: FavoriteSearchResult): Position[] { + const name = result.name.toLowerCase(); + const positions: Position[] = []; + + for (const [term, matchedFields] of Object.entries(result.match)) { + if (!matchedFields.includes("name")) { + continue; + } + const matchedLocations = name.matchAll(new RegExp(`(${term})`, "gi")); + for (const match of matchedLocations) { + positions.push({ + start: match.index ?? 0, + length: term.length + }); + } + } + + return positions; +} + +export interface FilteredFavoritesResult { + favorite: Favorite; + searchHit: SearchHit | undefined; + vendors: Vendor[]; +} + +export function filterFavoritesForSearch( + favorites: Favorite[], + query?: string, + insertables?: Record, + vendors?: Vendor[] +): FilteredFavoritesResult[] { + if (!query || query.trim() === "") { + return favorites.map((favorite) => ({ + favorite, + searchHit: undefined, + vendors: insertables?.[favorite.insertableId]?.vendors ?? [] + })); + } + + const searchDb = buildFavoriteSearchDb(favorites, insertables); + const miniSearchResults = searchDb.search(query, { + filter: (result) => { + const searchResult = result as FavoriteSearchResult; + if (!vendors || vendors.length === 0) { + return true; + } + const vendorSet = new Set(vendors); + return searchResult.vendors.some((vendor) => vendorSet.has(vendor)); + } + }) as FavoriteSearchResult[]; + + const resultsById = new Map(); + + for (const miniSearchResult of miniSearchResults) { + const favorite = favorites.find( + (item) => item.id === miniSearchResult.id + ); + if (!favorite) { + continue; + } + + resultsById.set(favorite.id, { + favorite, + searchHit: { + id: favorite.id, + positions: generateHighlightPositions(miniSearchResult) + }, + vendors: miniSearchResult.vendors + }); + } + + const queryText = query.trim().toLowerCase(); + for (const favorite of favorites) { + const matchesId = favorite.id.toLowerCase().includes(queryText); + if (!matchesId) { + continue; + } + + const insertable = insertables?.[favorite.insertableId]; + const vendorList = insertable?.vendors ?? []; + if (vendors && vendors.length > 0) { + const vendorSet = new Set(vendors); + if (!vendorList.some((vendor) => vendorSet.has(vendor))) { + continue; + } + } + + if (!resultsById.has(favorite.id)) { + resultsById.set(favorite.id, { + favorite, + searchHit: { + id: favorite.id, + positions: [] + }, + vendors: vendorList + }); + } + } + + return Array.from(resultsById.values()); +} diff --git a/src/frontend/favorites/favorites-list.tsx b/src/frontend/favorites/favorites-list.tsx index f6553e012..8a463c955 100644 --- a/src/frontend/favorites/favorites-list.tsx +++ b/src/frontend/favorites/favorites-list.tsx @@ -1,7 +1,6 @@ import { IconHeartBroken } from "@tabler/icons-react"; import { HeartIconColor, IconSize } from "../common/style-constants"; import { ReactNode } from "react"; -import { useLoaderData } from "@tanstack/react-router"; import { filterInsertables } from "../search/filter"; import { getFavoriteForInsertable, @@ -12,13 +11,12 @@ 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 } from "../queries"; +import { FilterResult, SearchHit } from "../search/insertable-search"; import { - useFavoritesQuery, - useLibraryQuery, - useSearchDbQuery -} from "../queries"; -import { doSearch, FilterResult, SearchHit } from "../search/search"; -import { hasEditorAccess } from "../../shared/types"; + FilteredFavoritesResult, + filterFavoritesForSearch +} from "./favorite-search"; /** * A list of current favorite cards. @@ -26,23 +24,13 @@ import { hasEditorAccess } from "../../shared/types"; */ export function FavoritesList(): ReactNode { const uiState = useUiState()[0]; - const loaderData = useLoaderData({ from: "/app" }); const favoritesQuery = useFavoritesQuery(); const libraryQuery = useLibraryQuery(); - const searchDbQuery = useSearchDbQuery(); - if ( - libraryQuery.isPending || - searchDbQuery.isPending || - favoritesQuery.isPending - ) { + if (libraryQuery.isPending || favoritesQuery.isPending) { return ; - } else if ( - libraryQuery.isError || - searchDbQuery.isError || - favoritesQuery.isError - ) { + } else if (libraryQuery.isError || favoritesQuery.isError) { return ( = {}; if (uiState.searchQuery) { - if (!searchDbQuery.data) { - return ; - } - const favoriteInsertableIds = new Set( - Object.values(favoritesQuery.data.favorites).map( - (f) => f.insertableId - ) - ); - const searchResults = doSearch( - searchDbQuery.data, + const matchedFavorites = filterFavoritesForSearch( + orderedFavorites, uiState.searchQuery, - { - vendors: uiState.vendorFilters, - isFavorite: true - }, - favoriteInsertableIds, - hasEditorAccess(loaderData.accessData.currentAccessLevel) + insertables, + uiState.vendorFilters ); - filteredInsertables = searchResults.hits - .map((hit) => insertables[hit.id]) - .filter((insertable) => !!insertable); + const matchedInsertables = matchedFavorites + .map((result: FilteredFavoritesResult) => { + const insertable = insertables[result.favorite.insertableId]; + return insertable ? { insertable, result } : undefined; + }) + .filter( + ( + item + ): item is { + insertable: InsertableOut; + result: FilteredFavoritesResult; + } => !!item + ); - filterResult = searchResults.filtered; + const filterResult2 = filterInsertables( + matchedInsertables.map((item) => item.insertable), + { + vendors: uiState.vendorFilters, + isVisible: true + } + ); - searchHits = searchResults.hits.reduce( - (acc, hit) => { - acc[hit.id] = hit; - return acc; - }, - {} as Record + filteredInsertables = filterResult2.insertables; + filterResult = filterResult2.filtered; + searchHits = Object.fromEntries( + matchedInsertables + .filter((item) => + filteredInsertables.some( + (insertable) => insertable.id === item.insertable.id + ) + ) + .flatMap((item) => { + const searchHit = item.result.searchHit; + return searchHit ? [[item.insertable.id, searchHit]] : []; + }) ); } else { const filterResult2 = filterInsertables(favoriteInsertables, { diff --git a/src/frontend/groups/group-card.tsx b/src/frontend/groups/group-card.tsx index 7f8fb0ec1..4d2b658a2 100644 --- a/src/frontend/groups/group-card.tsx +++ b/src/frontend/groups/group-card.tsx @@ -19,7 +19,7 @@ import { ChangeOrderItems } from "../common/change-order"; import { useSetVisibilityMutation } from "../cards/card-hooks"; import { AdminOptionsSubmenu, - CardTitle, + CardTitleGroup, ItemRow, OpenDocumentItems, ReloadThumbnailMenuItem @@ -55,7 +55,7 @@ export function GroupCard(props: GroupCardProps): ReactNode { }); }} left={ - } diff --git a/src/frontend/queries.ts b/src/frontend/queries.ts index 5178151f3..062a40bda 100644 --- a/src/frontend/queries.ts +++ b/src/frontend/queries.ts @@ -14,7 +14,7 @@ import { ContextData } from "../shared/types"; import { useLibraryId } from "./api-utils/library"; import { type UnitInfo } from "../shared/configuration-models"; import MiniSearch from "minisearch"; -import { SEARCH_OPTIONS } from "../shared/search"; +import { SEARCH_OPTIONS } from "../shared/build-insertable-search"; import { InstancePath } from "../shared/onshape-path"; export function getConfigurationMatchKey() { diff --git a/src/frontend/search/filter.ts b/src/frontend/search/filter.ts index 01a658d31..72b8626b4 100644 --- a/src/frontend/search/filter.ts +++ b/src/frontend/search/filter.ts @@ -1,6 +1,6 @@ import { InsertableOut } from "../../shared/api-models"; import { Vendor } from "../../shared/types"; -import { FilterResult } from "./search"; +import { FilterResult } from "./insertable-search"; export interface FilterArgs { /** diff --git a/src/frontend/search/search.ts b/src/frontend/search/insertable-search.ts similarity index 68% rename from src/frontend/search/search.ts rename to src/frontend/search/insertable-search.ts index 0d9ed5797..1a5555431 100644 --- a/src/frontend/search/search.ts +++ b/src/frontend/search/insertable-search.ts @@ -1,23 +1,13 @@ -import MiniSearch, { SearchResult as MiniSearchResult } from "minisearch"; +import MiniSearch from "minisearch"; import { Vendor } from "../../shared/types"; -import { SearchDocument } from "../../shared/search"; +import { SearchDocument } from "../../shared/build-insertable-search"; +import { TypedMiniSearchResult } from "../../shared/search-utils"; -/** - * A user facing name to use for elements currently being filtered/searched on. - */ -export type ObjectLabel = "element" | "favorite" | "search result"; - -/** - * Returns the plural form of an object label. - */ -export function plural(objectLabel: ObjectLabel): string { - return objectLabel + "s"; -} +type SearchDocumentResult = TypedMiniSearchResult; export interface SearchFilters { groupId?: string; vendors?: Vendor[]; - isFavorite?: boolean; } // Range is already defined by TypeScript @@ -52,7 +42,6 @@ export function doSearch( searchDb: MiniSearch, query?: string, filters?: SearchFilters, - favoritedInsertableIds?: Set, showHidden?: boolean ): SearchResult { const filtered: FilterResult = { byVendor: 0, byGroup: 0 }; @@ -61,18 +50,14 @@ export function doSearch( return { hits: [], filtered }; } - const miniSearchResults: MiniSearchResult[] = searchDb.search(query, { - filter: (searchResult) => { + const miniSearchResults = searchDb.search(query, { + filter: (result) => { + // Stored fields are merged onto each result, so it doubles as the document + const searchResult = result as SearchDocumentResult; if (!showHidden && !searchResult.isVisible) { return false; } - if (filters?.isFavorite) { - if (!favoritedInsertableIds?.has(searchResult.id)) { - return false; - } - } - let filteredByGroup = false; let filteredByVendor = false; if (filters?.groupId && searchResult.groupId !== filters.groupId) { @@ -101,25 +86,14 @@ export function doSearch( return true; } - }); + }) as SearchDocumentResult[]; // Add highlighting const hits: SearchHit[] = miniSearchResults - .map((miniSearchResult) => { - // Stored fields should be the same as SearchDocument - const document = searchDb.getStoredFields( - miniSearchResult.id - ) as unknown as SearchDocument; - const positions = generateHighlightPositions( - miniSearchResult, - document - ); - - return { - id: document.id, - positions - }; - }) + .map((miniSearchResult) => ({ + id: miniSearchResult.id, + positions: generateHighlightPositions(miniSearchResult) + })) .slice(0, 50); // Limit to 50 results return { hits, filtered }; @@ -129,14 +103,11 @@ export function doSearch( * Generate highlight positions for matched terms in the document. * Based on approach from https://github.com/lucaong/minisearch/issues/37 */ -function generateHighlightPositions( - result: MiniSearchResult, - document: SearchDocument -): Position[] { +function generateHighlightPositions(result: SearchDocumentResult): Position[] { // Terms is an array of values in name (or spacedName) which matched // e.g., if search is "mot w", then terms could be ["motor", "WCP"] - const name = document.name.toLowerCase(); + const name = result.name.toLowerCase(); const positions: Position[] = []; diff --git a/src/frontend/search/search-errors.tsx b/src/frontend/search/search-errors.tsx index 66d87a07f..c3fbf22f8 100644 --- a/src/frontend/search/search-errors.tsx +++ b/src/frontend/search/search-errors.tsx @@ -3,10 +3,22 @@ import { IconHeartBroken, IconSearch } from "@tabler/icons-react"; import { HeartIconColor, IconColor, IconSize } from "../common/style-constants"; import { ReactNode } from "react"; import { ClearFiltersButton } from "../settings/vendor-filters"; -import { FilterResult, ObjectLabel, plural } from "./search"; +import { FilterResult } from "./insertable-search"; import { useNavigate } from "@tanstack/react-router"; import { SectionError } from "../app-common/app-zero-state"; +/** + * A user facing name to use for elements currently being filtered/searched on. + */ +type ObjectLabel = "element" | "favorite" | "search result"; + +/** + * Returns the plural form of an object label. + */ +function plural(objectLabel: ObjectLabel): string { + return objectLabel + "s"; +} + function getGroupString(filtered: FilterResult, objectLabel: ObjectLabel) { if (filtered.byGroup > 1) { return `${filtered.byGroup} ${plural( diff --git a/src/frontend/search/search-results.tsx b/src/frontend/search/search-results.tsx index a1fcc7e42..00ca6c4cd 100644 --- a/src/frontend/search/search-results.tsx +++ b/src/frontend/search/search-results.tsx @@ -1,6 +1,11 @@ import { ReactNode } from "react"; import { useLoaderData } from "@tanstack/react-router"; -import { Position, SearchFilters, SearchHit, doSearch } from "./search"; +import { + Position, + SearchFilters, + SearchHit, + doSearch +} from "./insertable-search"; import { InsertableCard } from "../cards/insertable-card"; import { ItemTable } from "../cards/card-components"; import { SectionError, SectionLoading } from "../app-common/app-zero-state"; @@ -37,7 +42,6 @@ export function SearchResults(props: SearchResultsProps): ReactNode { searchDbQuery.data, query, filters, - undefined, hasEditorAccess(loaderData.accessData.currentAccessLevel) ); diff --git a/src/shared/api-models.ts b/src/shared/api-models.ts index 2235854dc..77285f32d 100644 --- a/src/shared/api-models.ts +++ b/src/shared/api-models.ts @@ -68,6 +68,7 @@ export interface Favorite { insertableId: string; libraryId: LibraryId; defaultConfiguration?: Configuration; + name?: string; } export interface FavoritesData { diff --git a/src/shared/search.ts b/src/shared/build-insertable-search.ts similarity index 62% rename from src/shared/search.ts rename to src/shared/build-insertable-search.ts index 538cdf38e..fba52644d 100644 --- a/src/shared/search.ts +++ b/src/shared/build-insertable-search.ts @@ -6,37 +6,7 @@ import MiniSearch, { Options } from "minisearch"; import { LibraryOut } from "./api-models"; import { Vendor } from "./types"; - -const deliminator = "^"; - -/** - * Adds spaces to a given string so prefix matching is more efficient. - */ -export function processTerm(term: string): string[] { - // Split between lowercase-to-uppercase (camelCase -> camel case) - const camelSplit = term - .replace(/([a-z])([A-Z])/g, `$1${deliminator}$2`) - .split(deliminator); - - // Insert spaces to handle MAXTube->MAX Tube, VEXpro->VEX pro - const pascalSplit = term - .replace(/([A-Z])([A-Z][a-z])/g, `$1${deliminator}$2`) - .split(deliminator); - - const base = term.toLowerCase(); - - const terms = [...camelSplit, ...pascalSplit, base].map((t) => - t.toLowerCase() - ); - // Deduplicate - return Array.from(new Set(terms)); -} - -export function tokenize(text: string): string[] { - // Don't lowercase so we can use casing for term splitting - // Remove -, (, ), ", ', #, &, /, and whitespace - return text.split(/[-()"'#&\s^/]+/).filter(Boolean); -} +import { processTerm, tokenize } from "./search-utils"; export interface SearchDocument { id: string; diff --git a/src/shared/schema.ts b/src/shared/schema.ts index 02afb7338..c2b95de39 100644 --- a/src/shared/schema.ts +++ b/src/shared/schema.ts @@ -143,9 +143,11 @@ export const favorites = sqliteTable( insertableId: text("insertable_id") .notNull() .references(() => insertables.id, { onDelete: "cascade" }), + defaultConfiguration: text("default_configuration", { mode: "json" }).$type(), + name: text("name").$type(), sortOrder: integer("sort_order").notNull().default(0) }, (t) => [unique().on(t.userId, t.libraryId, t.insertableId)] diff --git a/src/frontend/search/search.test.ts b/src/shared/search-utils.test.ts similarity index 91% rename from src/frontend/search/search.test.ts rename to src/shared/search-utils.test.ts index 47307928c..0d7d11178 100644 --- a/src/frontend/search/search.test.ts +++ b/src/shared/search-utils.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { processTerm, tokenize } from "../../shared/search"; +import { tokenize } from "./search-utils"; +import { processTerm } from "./search-utils"; describe("processTerm", () => { it("should process camelCase", () => { diff --git a/src/shared/search-utils.ts b/src/shared/search-utils.ts new file mode 100644 index 000000000..bb853e05c --- /dev/null +++ b/src/shared/search-utils.ts @@ -0,0 +1,42 @@ +import { SearchResult as MiniSearchResult } from "minisearch"; + +/** + * Global deliminator character used when splitting terms. + * + * Intended to be a unique character that doesn't otherwise get used. + */ +export const deliminator = "^"; + +/** + * Adds spaces to a given string so prefix matching is more efficient. + */ +export function processTerm(term: string): string[] { + // Split between lowercase-to-uppercase (camelCase -> camel case) + const camelSplit = term + .replace(/([a-z])([A-Z])/g, `$1${deliminator}$2`) + .split(deliminator); + + // Insert spaces to handle MAXTube->MAX Tube, VEXpro->VEX pro + const pascalSplit = term + .replace(/([A-Z])([A-Z][a-z])/g, `$1${deliminator}$2`) + .split(deliminator); + + const base = term.toLowerCase(); + + const terms = [...camelSplit, ...pascalSplit, base].map((t) => + t.toLowerCase() + ); + // Deduplicate + return Array.from(new Set(terms)); +} +export function tokenize(text: string): string[] { + // Don't lowercase so we can use casing for term splitting + // Remove -, (, ), ", ', #, &, /, and whitespace + return text.split(/[-()"'#&\s^/]+/).filter(Boolean); +} + +/** + * A MiniSearch result with its stored fields typed as `Document`. + */ +export type TypedMiniSearchResult = SearchDocument & + Pick;