diff --git a/README.md b/README.md index e9f1a4cd..b48c41e2 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,15 @@ This repo hosts the code for the FRCDesign Onshape App. ## Overview -The app code lives under `src/`. The app is written entirely in TypeScript and uses Hono for the backend and Vite and React for the frontend. -The app is deployed using Cloudflare Workers and uses the various Cloudflare products for the database and other aspects of the app. - This repo is intended to be run with VSCode on Linux using WSL Ubuntu. -While it should be possible to use other technologies, they aren't tested and may require additional work to get running. + +The local dev environment typically needs either Google Chrome or Firefox to work correctly with Onshape. + +_Other browsers, such as Brave, can have default security policies that prevent the dev environment from working with Onshape._ # Local Development Setup -First, create a new file in the root of this project named `.env` and add the following contents: +Create a new file in the root of this project named `.env` and add the following contents: ``` # Server config @@ -44,7 +44,7 @@ To test Onshape app changes, you will need to create an OAuth application in the - OAuth URL: `https://localhost:3000/auth/sign-in` - Check the permissions `can read your profile information`, `can read your documents`, `can write to your documents`, and `can delete your documents and workspaces`. -Click Create application, then copy your OAuth app's OAuth client secret (in the popup) and OAuth client identifier into your `.env` file. +Click Create application, then copy your OAuth app's OAuth client secret (from the popup) and OAuth client identifier into your `.env` file. Next, add the necessary Extensions to your OAuth application so you can see it in documents you open: @@ -76,7 +76,7 @@ Note that Onshape has an annual limit of 2,500 API calls per Onshape account. Th In particular, avoid loading large documents into your local environment and only force reload the database when necessary. -## Flask Credentials Setup +## HTTPS Setup Onshape requires all apps, even temporary test apps, to use https. This creates a big headache for local development. @@ -107,18 +107,19 @@ If it doesn't, you'll need to add the Certificate Authority manually. In Firefox 1. Open Firefox and go to `Settings > Certificates > View Certificates... > Authorities > Import...` 1. Navigate to the `CAROOT` path and select `rootCA.pem`. -## Frontend Setup +## VSCode Setup -First, install npm in your WSL container and add the dependencies: +Install nvm (node version manager) in your WSL container, install npm, and then install the dependencies: ``` -sudo apt install npm -npm install +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.5/install.sh | bash +nvm i node +npm i ``` ## Development Servers -You should now be able to run the `Launch servers` VSCode task to launch Vite. +You should now be able to run the `Launch dev` VSCode task to launch Vite. You should then be able to launch the FRC Design App from the right panel of any Onshape Part Studio or Assembly and see the FRC Design App UI appear. To see documents, add one or more documents and push a new app version to rebuild the search database. @@ -127,6 +128,10 @@ To view the state of Cloudflare, type `e` in Vite to launch the local Cloudflare # Troubleshooting +## Onshape fails to load + +Double check your Action URLs configured in Onshape. You can also open Browser Dev Tools (usually F12), then open the app in Onshape and see if any errors or warnings appear in the Console or the Network tab. + ## Port Taken/Not Available Occasionally, a process will fail to fully shut down, causing problems when you next attempt to `Launch servers` since the port is already taken. diff --git a/drizzle/0001_tricky_rhodey.sql b/drizzle/0001_tricky_rhodey.sql new file mode 100644 index 00000000..5eee189e --- /dev/null +++ b/drizzle/0001_tricky_rhodey.sql @@ -0,0 +1,2 @@ +ALTER TABLE `groups` ADD `build_issues` text DEFAULT '[]' NOT NULL;--> statement-breakpoint +ALTER TABLE `insertables` ADD `build_issues` text DEFAULT '[]' NOT NULL; \ No newline at end of file diff --git a/drizzle/0002_configurations_build_issues.sql b/drizzle/0002_configurations_build_issues.sql new file mode 100644 index 00000000..7693e235 --- /dev/null +++ b/drizzle/0002_configurations_build_issues.sql @@ -0,0 +1 @@ +ALTER TABLE `configurations` ADD `build_issues` text NOT NULL DEFAULT '[]'; diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 00000000..f6fb4b9e --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,471 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "683b9303-d2b2-4024-91e3-2e79eb926bbf", + "prevId": "0b4892ae-775f-4c38-8175-ec7f48cbc81c", + "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": "'[]'" + } + }, + "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 + }, + "instance_id": { + "name": "instance_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 + }, + "thumbnail_urls": { + "name": "thumbnail_urls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "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 + }, + "version_name": { + "name": "version_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_visible": { + "name": "is_visible", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "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 + }, + "instance_id": { + "name": "instance_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": "'[]'" + }, + "thumbnail_urls": { + "name": "thumbnail_urls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fasten_info": { + "name": "fasten_info", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": { + "insertables_element_id_group_id_unique": { + "name": "insertables_element_id_group_id_unique", + "columns": ["element_id", "group_id"], + "isUnique": true + } + }, + "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 + }, + "search_db": { + "name": "search_db", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "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": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 189fc3e2..cfa80a48 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1781278535134, "tag": "0000_mature_eddie_brock", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1782065112583, + "tag": "0001_tricky_rhodey", + "breakpoints": true } ] } diff --git a/src/backend/access-level-utils.ts b/src/backend/access-level-utils.ts index 6a39aaf2..f20a6b8c 100644 --- a/src/backend/access-level-utils.ts +++ b/src/backend/access-level-utils.ts @@ -3,7 +3,7 @@ import { HTTPException } from "hono/http-exception"; import { type AppContext, type AppContextEnv } from "./app"; import { hasEditorAccess } from "../shared/types"; -export async function requireEditorAccess(c: AppContext): Promise { +async function requireEditorAccess(c: AppContext): Promise { const level = await c.var.getAccessLevel(); if (!hasEditorAccess(level)) { throw new HTTPException(403, { @@ -12,7 +12,10 @@ export async function requireEditorAccess(c: AppContext): Promise { } } -export const requireAdminMiddleware: MiddlewareHandler = async ( +/** + * Middleware which requires users to be an editor or an admin. + */ +export const requireEditorMiddleware: MiddlewareHandler = async ( c, next ) => { diff --git a/src/backend/create-app.ts b/src/backend/create-app.ts index 3051f7c0..a524b085 100644 --- a/src/backend/create-app.ts +++ b/src/backend/create-app.ts @@ -7,6 +7,7 @@ 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"; /** * Composition root for the Hono app. The injected `makeServices` factory is @@ -32,6 +33,7 @@ export function createApp(makeServices: AppServicesFactory) { app.route("/api", groupRoutes); app.route("/api", insertableRoutes); app.route("/api", configurationRoutes); + app.route("/api", buildStatusRoutes); app.route("/auth", authRoutes); // `/init` is the auth-gated entry point diff --git a/src/backend/library-data.ts b/src/backend/library-data.ts index 72d68da9..1d198272 100644 --- a/src/backend/library-data.ts +++ b/src/backend/library-data.ts @@ -66,7 +66,6 @@ export async function getLibraryOut( instanceType: "v" }, name: group.name, - sortAlphabetically: group.sortAlphabetically, thumbnailUrls: group.thumbnailUrls!, insertableOrder }; @@ -88,10 +87,7 @@ export async function getLibraryOut( }, name: ins.name, microversionId: ins.microversionId, - versionName: ins.versionName, - versionCreatedAt: ins.versionCreatedAt, isVisible: ins.isVisible, - isOpenComposite: ins.isOpenComposite, supportsFasten: ins.supportsFasten, elementType: ins.elementType, thumbnailUrls: ins.thumbnailUrls!, diff --git a/src/backend/parse/build-checks.test.ts b/src/backend/parse/build-checks.test.ts new file mode 100644 index 00000000..d8affbaf --- /dev/null +++ b/src/backend/parse/build-checks.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { ThumbnailSize, ThumbnailUrls, Vendor } from "../../shared/types"; +import { + BuildIssueSeverity, + BuildIssueType, + getIssueSeverity +} from "../../shared/build-checker"; +import { checkGroup, checkInsertable } from "./build-checks"; + +const THUMBNAILS: ThumbnailUrls = { + [ThumbnailSize.TINY]: "/api/thumbnail/tiny/x", + [ThumbnailSize.STANDARD]: "/api/thumbnail/standard/x" +}; + +describe("checkGroup", () => { + it("returns no issues when a thumbnail tab is set and thumbnails generated", () => { + expect( + checkGroup({ hasThumbnailTab: true, thumbnailUrls: THUMBNAILS }) + ).toEqual([]); + }); + + it("warns when no thumbnail tab is set", () => { + const issues = checkGroup({ + hasThumbnailTab: false, + thumbnailUrls: THUMBNAILS + }); + expect(issues).toHaveLength(1); + expect(getIssueSeverity(issues[0])).toBe(BuildIssueSeverity.WARNING); + expect(issues[0].type).toBe(BuildIssueType.NO_THUMBNAIL_TAB); + }); + + it("errors when the thumbnail failed to generate", () => { + const issues = checkGroup({ + hasThumbnailTab: true, + thumbnailUrls: null + }); + expect(issues).toHaveLength(1); + expect(getIssueSeverity(issues[0])).toBe(BuildIssueSeverity.ERROR); + expect(issues[0].type).toBe(BuildIssueType.THUMBNAIL_FAILED); + }); + + it("prefers the thumbnail error over the missing-tab warning", () => { + const issues = checkGroup({ + hasThumbnailTab: false, + thumbnailUrls: null + }); + expect(issues).toHaveLength(1); + expect(issues[0].type).toBe(BuildIssueType.THUMBNAIL_FAILED); + }); +}); + +describe("checkInsertable", () => { + it("returns no issues when vendors are parsed and thumbnails generated", () => { + expect( + checkInsertable({ + vendors: [Vendor.REV], + thumbnailUrls: THUMBNAILS + }) + ).toEqual([]); + }); + + it("infos when no vendors are parsed", () => { + const issues = checkInsertable({ + vendors: [], + thumbnailUrls: THUMBNAILS + }); + expect(issues).toHaveLength(1); + expect(getIssueSeverity(issues[0])).toBe(BuildIssueSeverity.INFO); + expect(issues[0].type).toBe(BuildIssueType.NO_VENDORS); + }); + + it("errors when the thumbnail failed to generate", () => { + const issues = checkInsertable({ + vendors: [Vendor.REV], + thumbnailUrls: null + }); + expect(issues).toHaveLength(1); + expect(getIssueSeverity(issues[0])).toBe(BuildIssueSeverity.ERROR); + expect(issues[0].type).toBe(BuildIssueType.THUMBNAIL_FAILED); + }); + + it("reports both the thumbnail error and the no-vendors info", () => { + const issues = checkInsertable({ vendors: [], thumbnailUrls: null }); + expect(issues.map((i) => i.type)).toEqual([ + BuildIssueType.THUMBNAIL_FAILED, + BuildIssueType.NO_VENDORS + ]); + }); +}); diff --git a/src/backend/parse/build-checks.ts b/src/backend/parse/build-checks.ts new file mode 100644 index 00000000..50239722 --- /dev/null +++ b/src/backend/parse/build-checks.ts @@ -0,0 +1,59 @@ +import { ThumbnailUrls, Vendor } from "../../shared/types"; +import { + addBuildIssue, + BuildIssue, + BuildIssueType +} from "../../shared/build-checker"; + +interface GroupCheckInput { + /** Whether the Onshape document has a designated thumbnail tab/element. */ + hasThumbnailTab: boolean; + /** The uploaded thumbnail URLs, or `null` when generation failed. */ + thumbnailUrls: ThumbnailUrls | null; +} + +/** + * Computes build-time issues for a group. Pure: takes already-resolved signals + * from the load-document workflow rather than fetching anything itself. + */ +export function checkGroup(input: GroupCheckInput): BuildIssue[] { + let issues: BuildIssue[] = []; + + if (input.thumbnailUrls === null) { + issues = addBuildIssue(issues, { + type: BuildIssueType.THUMBNAIL_FAILED + }); + } else if (!input.hasThumbnailTab) { + issues = addBuildIssue(issues, { + type: BuildIssueType.NO_THUMBNAIL_TAB + }); + } + + return issues; +} + +interface InsertableCheckInput { + vendors: Vendor[]; + /** The uploaded thumbnail URLs, or `null` when generation failed. */ + thumbnailUrls: ThumbnailUrls | null; +} + +/** + * Computes build-time issues for an insertable. Pure: takes already-resolved + * signals from the load-document workflow rather than fetching anything itself. + */ +export function checkInsertable(input: InsertableCheckInput): BuildIssue[] { + let issues: BuildIssue[] = []; + + if (input.thumbnailUrls === null) { + issues = addBuildIssue(issues, { + type: BuildIssueType.THUMBNAIL_FAILED + }); + } + + if (input.vendors.length === 0) { + issues = addBuildIssue(issues, { type: BuildIssueType.NO_VENDORS }); + } + + return issues; +} diff --git a/src/backend/parse/load-document.ts b/src/backend/parse/load-document.ts index dbfc73f5..ed7d013c 100644 --- a/src/backend/parse/load-document.ts +++ b/src/backend/parse/load-document.ts @@ -41,6 +41,7 @@ import { } from "../routes/thumbnails"; import { parseOnshapeConfiguration } from "./parse-configuration"; import { parseVendors } from "./parse-vendors"; +import { checkGroup, checkInsertable } from "./build-checks"; import { bumpLibraryVersion, rebuildSearchDb } from "../library-data"; export interface LoadDocumentParams { @@ -389,7 +390,11 @@ export class LoadDocumentWorkflow extends WorkflowEntrypoint< vendors: r.vendors, thumbnailUrls: r.thumbnailUrls, fastenInfo: r.fastenInfo, - supportsFasten: r.supportsFasten + supportsFasten: r.supportsFasten, + buildIssues: checkInsertable({ + vendors: r.vendors, + thumbnailUrls: r.thumbnailUrls + }) }) .onConflictDoUpdate({ target: [insertables.elementId, insertables.groupId], @@ -429,7 +434,11 @@ export class LoadDocumentWorkflow extends WorkflowEntrypoint< libraryId, name: contentsInfo.docName, instanceId: versionInfo.instanceId, - thumbnailUrls: docThumbnailUrls + thumbnailUrls: docThumbnailUrls, + buildIssues: checkGroup({ + hasThumbnailTab: !!contentsInfo.thumbnailElementId, + thumbnailUrls: docThumbnailUrls + }) }) .onConflictDoUpdate({ target: [groups.documentId, groups.libraryId], diff --git a/src/backend/routes/build-status.ts b/src/backend/routes/build-status.ts new file mode 100644 index 00000000..b2ca9cf5 --- /dev/null +++ b/src/backend/routes/build-status.ts @@ -0,0 +1,99 @@ +import { asc, eq, inArray } from "drizzle-orm"; +import { getApp, getLibraryParam, libraryRoute } from "../app"; +import { getDb } from "../db"; +import { requireEditorMiddleware } from "../access-level-utils"; +import { groups, insertables, configurations } from "../../shared/schema"; +import { + type LibraryBuildStatus, + type GroupBuildStatus, + type InsertableBuildStatus +} from "../../shared/api-models"; + +export const buildStatusRoutes = getApp(); + +/** GET /api/build-status/library/:libraryId */ +buildStatusRoutes.get( + "/build-status" + libraryRoute(), + requireEditorMiddleware, + async (c) => { + const libraryId = getLibraryParam(c); + const db = getDb(c.env.DB); + + const [allGroups, allInsertables] = await Promise.all([ + db + .select({ + id: groups.id, + buildIssues: groups.buildIssues, + sortAlphabetically: groups.sortAlphabetically, + sortOrder: groups.sortOrder + }) + .from(groups) + .where(eq(groups.libraryId, libraryId)) + .orderBy(asc(groups.sortOrder)) + .all(), + db + .select({ + id: insertables.id, + groupId: insertables.groupId, + buildIssues: insertables.buildIssues, + isVisible: insertables.isVisible, + isOpenComposite: insertables.isOpenComposite, + supportsFasten: insertables.supportsFasten, + vendors: insertables.vendors, + sortOrder: insertables.sortOrder + }) + .from(insertables) + .where(eq(insertables.libraryId, libraryId)) + .orderBy(asc(insertables.sortOrder)) + .all() + ]); + + const insertableIds = allInsertables.map((ins) => ins.id); + const allConfigurations = await db + .select({ + id: configurations.id, + buildIssues: configurations.buildIssues, + parameters: configurations.parameters + }) + .from(configurations) + .where(inArray(configurations.id, insertableIds)) + .all(); + + const configMap = new Map(allConfigurations.map((c) => [c.id, c])); + + const groupsOut: Record = {}; + for (const group of allGroups) { + const groupInsertables = allInsertables + .filter((ins) => ins.groupId === group.id) + .sort((a, b) => a.sortOrder - b.sortOrder); + groupsOut[group.id] = { + buildIssues: group.buildIssues, + sortAlphabetically: group.sortAlphabetically, + insertableOrder: groupInsertables.map((ins) => ins.id) + }; + } + + const insertablesOut: Record = {}; + for (const ins of allInsertables) { + const config = configMap.get(ins.id); + insertablesOut[ins.id] = { + buildIssues: ins.buildIssues, + isVisible: ins.isVisible, + isOpenComposite: ins.isOpenComposite, + supportsFasten: ins.supportsFasten, + vendors: ins.vendors, + configuration: config + ? { + buildIssues: config.buildIssues, + parameters: config.parameters + } + : undefined + }; + } + + return c.json({ + groups: groupsOut, + insertables: insertablesOut + } satisfies LibraryBuildStatus); + } +); diff --git a/src/backend/routes/groups.ts b/src/backend/routes/groups.ts index 8b31b8e7..d2e25076 100644 --- a/src/backend/routes/groups.ts +++ b/src/backend/routes/groups.ts @@ -4,7 +4,7 @@ import { getDb } from "../db"; import { getSessionId } from "../auth"; import { getLatestVersion } from "../onshape-api/endpoints/versions"; import { getDocument } from "../onshape-api/endpoints/documents"; -import { requireEditorAccess } from "../access-level-utils"; +import { requireEditorMiddleware } from "../access-level-utils"; import { type DocumentPath } from "../../shared/onshape-path"; import { libraries, groups, insertables, favorites } from "../../shared/schema"; import type { LoadDocumentParams } from "../parse/load-document"; @@ -13,209 +13,240 @@ import { bumpLibraryVersion, rebuildSearchDb } from "../library-data"; export const groupRoutes = getApp(); /** POST /api/reload-groups/library/:libraryId?forceReload=true */ -groupRoutes.post("/reload-groups" + libraryRoute(), async (c) => { - await requireEditorAccess(c); - const libraryId = getLibraryParam(c); - const forceReload = c.req.query("forceReload") === "true"; - - const sessionId = getSessionId(c); - - const db = getDb(c.env.DB); - await db.insert(libraries).values({ id: libraryId }).onConflictDoNothing(); - - // Each group re-syncs from its Onshape document. - const groupRows = await db - .select({ documentId: groups.documentId }) - .from(groups) - .where(eq(groups.libraryId, libraryId)) - .orderBy(asc(groups.sortOrder)) - .all(); - - const instances = await Promise.all( - groupRows.map(({ documentId }) => { - const params: LoadDocumentParams = { - documentId, - libraryId, - sessionId, - forceReload - }; - return c.env.LOAD_DOCUMENT_WORKFLOW.create({ params }); - }) - ); - - return c.json({ status: "triggered", count: instances.length }); -}); +groupRoutes.post( + "/reload-groups" + libraryRoute(), + requireEditorMiddleware, + async (c) => { + const libraryId = getLibraryParam(c); + const forceReload = c.req.query("forceReload") === "true"; -/** POST /api/set-element-visibility/library/:libraryId */ -groupRoutes.post("/set-element-visibility" + libraryRoute(), async (c) => { - await requireEditorAccess(c); - const libraryId = getLibraryParam(c); - const body = await c.req.json<{ - insertableIds: string[]; - isVisible: boolean; - }>(); + const sessionId = getSessionId(c); + + const db = getDb(c.env.DB); + await db + .insert(libraries) + .values({ id: libraryId }) + .onConflictDoNothing(); + + // Each group re-syncs from its Onshape document. + const groupRows = await db + .select({ documentId: groups.documentId }) + .from(groups) + .where(eq(groups.libraryId, libraryId)) + .orderBy(asc(groups.sortOrder)) + .all(); + + const instances = await Promise.all( + groupRows.map(({ documentId }) => { + const params: LoadDocumentParams = { + documentId, + libraryId, + sessionId, + forceReload + }; + return c.env.LOAD_DOCUMENT_WORKFLOW.create({ params }); + }) + ); - const db = getDb(c.env.DB); + return c.json({ status: "triggered", count: instances.length }); + } +); + +/** POST /api/set-element-visibility/library/:libraryId */ +groupRoutes.post( + "/set-element-visibility" + libraryRoute(), + requireEditorMiddleware, + async (c) => { + const libraryId = getLibraryParam(c); + const body = await c.req.json<{ + insertableIds: string[]; + isVisible: boolean; + }>(); + + const db = getDb(c.env.DB); + + if (!body.isVisible) { + await db + .delete(favorites) + .where( + and( + eq(favorites.libraryId, libraryId), + inArray(favorites.insertableId, body.insertableIds) + ) + ); + } - if (!body.isVisible) { await db - .delete(favorites) + .update(insertables) + .set({ isVisible: body.isVisible }) .where( and( - eq(favorites.libraryId, libraryId), - inArray(favorites.insertableId, body.insertableIds) + eq(insertables.libraryId, libraryId), + inArray(insertables.id, body.insertableIds) ) ); - } - - await db - .update(insertables) - .set({ isVisible: body.isVisible }) - .where( - and( - eq(insertables.libraryId, libraryId), - inArray(insertables.id, body.insertableIds) - ) - ); - await bumpLibraryVersion(db, libraryId); - return c.json({ success: true }); -}); + await bumpLibraryVersion(db, libraryId); + return c.json({ success: true }); + } +); /** POST /api/sort-group-alphabetically/library/:libraryId */ -groupRoutes.post("/sort-group-alphabetically" + libraryRoute(), async (c) => { - await requireEditorAccess(c); - const libraryId = getLibraryParam(c); - const body = await c.req.json<{ - groupId: string; - sortAlphabetically: boolean; - }>(); - - const db = getDb(c.env.DB); - await db - .update(groups) - .set({ sortAlphabetically: body.sortAlphabetically }) - .where( - and(eq(groups.id, body.groupId), eq(groups.libraryId, libraryId)) - ); +groupRoutes.post( + "/sort-group-alphabetically" + libraryRoute(), + requireEditorMiddleware, + async (c) => { + const libraryId = getLibraryParam(c); + const body = await c.req.json<{ + groupId: string; + sortAlphabetically: boolean; + }>(); + + const db = getDb(c.env.DB); + await db + .update(groups) + .set({ sortAlphabetically: body.sortAlphabetically }) + .where( + and( + eq(groups.id, body.groupId), + eq(groups.libraryId, libraryId) + ) + ); - await bumpLibraryVersion(db, libraryId); - return c.json({ success: true }); -}); + await bumpLibraryVersion(db, libraryId); + return c.json({ success: true }); + } +); /** POST /api/group-order/library/:libraryId */ -groupRoutes.post("/group-order" + libraryRoute(), async (c) => { - await requireEditorAccess(c); - const libraryId = getLibraryParam(c); - const body = await c.req.json<{ groupOrder: string[] }>(); - - const db = getDb(c.env.DB); - await Promise.all( - body.groupOrder.map((id, i) => - db - .update(groups) - .set({ sortOrder: i }) - .where(and(eq(groups.id, id), eq(groups.libraryId, libraryId))) - ) - ); - - await bumpLibraryVersion(db, libraryId); - return c.json({ success: true }); -}); - -/** POST /api/group/library/:libraryId — add a new group from an Onshape document */ -groupRoutes.post("/group" + libraryRoute(), async (c) => { - await requireEditorAccess(c); - const onshapeApi = await c.var.getOnshapeApi(); - const libraryId = getLibraryParam(c); - const body = await c.req.json<{ - newDocumentId: string; - selectedGroupId?: string; - }>(); - const sessionId = getSessionId(c); - - const documentPath: DocumentPath = { documentId: body.newDocumentId }; - - let documentName: string; - try { - const doc = await getDocument(onshapeApi, documentPath); - documentName = doc.name; - } catch { - return c.json( - { - type: "handled", - message: "Failed to find the specified document.", - isError: true - }, - 422 +groupRoutes.post( + "/group-order" + libraryRoute(), + requireEditorMiddleware, + async (c) => { + const libraryId = getLibraryParam(c); + const body = await c.req.json<{ groupOrder: string[] }>(); + + const db = getDb(c.env.DB); + await Promise.all( + body.groupOrder.map((id, i) => + db + .update(groups) + .set({ sortOrder: i }) + .where( + and(eq(groups.id, id), eq(groups.libraryId, libraryId)) + ) + ) ); - } - try { - await getLatestVersion(onshapeApi, documentPath); - } catch { - return c.json( - { - type: "handled", - message: "Failed to find a document version to use.", - isError: true - }, - 422 - ); + await bumpLibraryVersion(db, libraryId); + return c.json({ success: true }); } +); - const db = getDb(c.env.DB); +/** POST /api/group/library/:libraryId — add a new group from an Onshape document */ +groupRoutes.post( + "/group" + libraryRoute(), + requireEditorMiddleware, + async (c) => { + const onshapeApi = await c.var.getOnshapeApi(); + const libraryId = getLibraryParam(c); + const body = await c.req.json<{ + newDocumentId: string; + selectedGroupId?: string; + }>(); + const sessionId = getSessionId(c); + + const documentPath: DocumentPath = { documentId: body.newDocumentId }; + + let documentName: string; + try { + const doc = await getDocument(onshapeApi, documentPath); + documentName = doc.name; + } catch { + return c.json( + { + type: "handled", + message: "Failed to find the specified document.", + isError: true + }, + 422 + ); + } + + try { + await getLatestVersion(onshapeApi, documentPath); + } catch { + return c.json( + { + type: "handled", + message: "Failed to find a document version to use.", + isError: true + }, + 422 + ); + } - await db.insert(libraries).values({ id: libraryId }).onConflictDoNothing(); + const db = getDb(c.env.DB); - const existingGroup = await db - .select({ id: groups.id }) - .from(groups) - .where( - and( - eq(groups.documentId, body.newDocumentId), - eq(groups.libraryId, libraryId) + await db + .insert(libraries) + .values({ id: libraryId }) + .onConflictDoNothing(); + + const existingGroup = await db + .select({ id: groups.id }) + .from(groups) + .where( + and( + eq(groups.documentId, body.newDocumentId), + eq(groups.libraryId, libraryId) + ) ) - ) - .get(); - - if (existingGroup) { - return c.json( - { - type: "handled", - message: "Document has already been added to library.", - isError: true - }, - 422 - ); - } + .get(); + + if (existingGroup) { + return c.json( + { + type: "handled", + message: "Document has already been added to library.", + isError: true + }, + 422 + ); + } - const params: LoadDocumentParams = { - documentId: body.newDocumentId, - libraryId, - sessionId, - selectedGroupId: body.selectedGroupId - }; - await c.env.LOAD_DOCUMENT_WORKFLOW.create({ params }); + const params: LoadDocumentParams = { + documentId: body.newDocumentId, + libraryId, + sessionId, + selectedGroupId: body.selectedGroupId + }; + await c.env.LOAD_DOCUMENT_WORKFLOW.create({ params }); - return c.json({ name: documentName }); -}); + return c.json({ name: documentName }); + } +); /** DELETE /api/group/library/:libraryId?groupId=X */ -groupRoutes.delete("/group" + libraryRoute(), async (c) => { - await requireEditorAccess(c); - const libraryId = getLibraryParam(c); - const groupId = c.req.query("groupId"); - if (!groupId) return c.json({ error: "groupId required" }, 400); - - const db = getDb(c.env.DB); - - // Cascade deletes insertables → favorites, and configurations automatically - await db - .delete(groups) - .where(and(eq(groups.id, groupId), eq(groups.libraryId, libraryId))); - - await bumpLibraryVersion(db, libraryId); - await rebuildSearchDb(db, libraryId); - return c.json({ success: true }); -}); +groupRoutes.delete( + "/group" + libraryRoute(), + requireEditorMiddleware, + async (c) => { + const libraryId = getLibraryParam(c); + const groupId = c.req.query("groupId"); + if (!groupId) return c.json({ error: "groupId required" }, 400); + + const db = getDb(c.env.DB); + + // Cascade deletes insertables → favorites, and configurations automatically + await db + .delete(groups) + .where( + and(eq(groups.id, groupId), eq(groups.libraryId, libraryId)) + ); + + await bumpLibraryVersion(db, libraryId); + await rebuildSearchDb(db, libraryId); + return c.json({ success: true }); + } +); diff --git a/src/backend/routes/insertables.ts b/src/backend/routes/insertables.ts index c4a0b003..cce7bee8 100644 --- a/src/backend/routes/insertables.ts +++ b/src/backend/routes/insertables.ts @@ -2,7 +2,7 @@ import { eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import { getApp, getInsertableParam, insertableRoute } from "../app"; import { getDb, type Db } from "../db"; -import { requireAdminMiddleware } from "../access-level-utils"; +import { requireEditorMiddleware } from "../access-level-utils"; import { insertables, configurations } from "../../shared/schema"; import { bumpLibraryVersion } from "../library-data"; import { type ElementPath } from "../../shared/onshape-path"; @@ -29,7 +29,7 @@ export const insertableRoutes = getApp(); /** POST /api/toggle-open-composite/insertable/:insertableId */ insertableRoutes.post( "/toggle-open-composite" + insertableRoute(), - requireAdminMiddleware, + requireEditorMiddleware, async (c) => { const insertableId = getInsertableParam(c); const body = await c.req.json<{ isOpenComposite: boolean }>(); @@ -56,7 +56,7 @@ insertableRoutes.post( /** POST /api/toggle-insert-and-fasten/insertable/:insertableId */ insertableRoutes.post( "/toggle-insert-and-fasten" + insertableRoute(), - requireAdminMiddleware, + requireEditorMiddleware, async (c) => { const db = getDb(c.env.DB); diff --git a/src/backend/routes/thumbnails.ts b/src/backend/routes/thumbnails.ts index 76a1f177..936043de 100644 --- a/src/backend/routes/thumbnails.ts +++ b/src/backend/routes/thumbnails.ts @@ -2,7 +2,7 @@ import { eq } from "drizzle-orm"; import { getApp, getInsertableParam, insertableRoute } from "../app"; import { getInsertableElementPath } from "./insertables"; import { getDb } from "../db"; -import { requireAdminMiddleware } from "../access-level-utils"; +import { requireEditorMiddleware } from "../access-level-utils"; import { bumpLibraryVersion } from "../library-data"; import { getElementThumbnail, @@ -16,6 +16,7 @@ import { groups, insertables } from "../../shared/schema"; import { HTTPException } from "hono/http-exception"; import { ThumbnailUrls } from "../../shared/types"; import { OnshapeApi } from "../onshape-api/onshape-api"; +import { BuildIssueType, clearBuildIssue } from "../../shared/build-checker"; const THUMBNAIL_CACHE_TTL = 30 * 24 * 3600; @@ -177,7 +178,7 @@ thumbnailRoutes.get( /** POST /api/reload-insertable-thumbnail/insertable/:insertableId */ thumbnailRoutes.post( "/reload-insertable-thumbnail" + insertableRoute(), - requireAdminMiddleware, + requireEditorMiddleware, async (c) => { const onshapeApi = await c.var.getOnshapeApi(); const insertableId = getInsertableParam(c); @@ -188,7 +189,8 @@ thumbnailRoutes.post( const row = await db .select({ microversionId: insertables.microversionId, - libraryId: insertables.libraryId + libraryId: insertables.libraryId, + buildIssues: insertables.buildIssues }) .from(insertables) .where(eq(insertables.id, insertableId)) @@ -207,7 +209,13 @@ thumbnailRoutes.post( await db .update(insertables) - .set({ thumbnailUrls: thumbnails }) + .set({ + thumbnailUrls: thumbnails, + buildIssues: clearBuildIssue( + row.buildIssues, + BuildIssueType.THUMBNAIL_FAILED + ) + }) .where(eq(insertables.id, insertableId)); await bumpLibraryVersion(db, row.libraryId); @@ -218,7 +226,7 @@ thumbnailRoutes.post( /** POST /api/reload-group-thumbnail/group/:groupId */ thumbnailRoutes.post( "/reload-group-thumbnail/group/:groupId", - requireAdminMiddleware, + requireEditorMiddleware, async (c) => { const onshapeApi = await c.var.getOnshapeApi(); const groupId = c.req.param("groupId"); @@ -228,7 +236,8 @@ thumbnailRoutes.post( .select({ documentId: groups.documentId, instanceId: groups.instanceId, - libraryId: groups.libraryId + libraryId: groups.libraryId, + buildIssues: groups.buildIssues }) .from(groups) .where(eq(groups.id, groupId)) @@ -252,7 +261,13 @@ thumbnailRoutes.post( await db .update(groups) - .set({ thumbnailUrls: thumbnails }) + .set({ + thumbnailUrls: thumbnails, + buildIssues: clearBuildIssue( + row.buildIssues, + BuildIssueType.THUMBNAIL_FAILED + ) + }) .where(eq(groups.id, groupId)); await bumpLibraryVersion(db, row.libraryId); diff --git a/src/frontend/app/app-navbar.tsx b/src/frontend/app/app-navbar.tsx index 753eaaec..ca537ca3 100644 --- a/src/frontend/app/app-navbar.tsx +++ b/src/frontend/app/app-navbar.tsx @@ -1,10 +1,12 @@ -import { ActionIcon, Button, Group, Menu, TextInput } from "@mantine/core"; import { - IconChevronDown, - IconSearch, - IconSettings, - IconX -} from "@tabler/icons-react"; + ActionIcon, + Button, + Group, + Input, + Menu, + TextInput +} from "@mantine/core"; +import { IconChevronDown, IconSearch, IconSettings } from "@tabler/icons-react"; import { IconSize, PrimaryColor } from "../common/style-constants"; import { ReactNode, RefObject, useRef } from "react"; import { useNavigate } from "@tanstack/react-router"; @@ -114,24 +116,21 @@ export function SearchBar() { const [uiState, setUiState] = useUiState(); const clearButton = uiState.searchQuery ? ( - { if (ref.current) { ref.current.value = ""; } setUiState({ searchQuery: undefined }); }} - > - - + /> ) : undefined; return ( } placeholder="Search library..." ref={ref} diff --git a/src/frontend/cards/build-status.tsx b/src/frontend/cards/build-status.tsx new file mode 100644 index 00000000..ab5e8fe6 --- /dev/null +++ b/src/frontend/cards/build-status.tsx @@ -0,0 +1,363 @@ +import { Badge, Divider, Group, HoverCard, Stack, Text } from "@mantine/core"; +import { + IconAlertOctagon, + IconAlertTriangle, + IconCheck, + IconInfoCircle, + IconX +} from "@tabler/icons-react"; +import { ComponentPropsWithRef, ReactNode, useMemo } from "react"; +import { + addBuildIssue, + BuildIssue, + BuildIssueSeverity, + BuildIssueType, + getIssueSeverity, + getMaxSeverity +} from "../../shared/build-checker"; +import { + GroupBuildStatus, + InsertableBuildStatus +} from "../../shared/api-models"; +import { getVendorName, Vendor } from "../../shared/types"; +import { FontWeight, IconColor, IconSize } from "../common/style-constants"; +import { RequireAccessLevel } from "../api-utils/access-level"; +import { useBuildStatusQuery } from "../queries"; + +/** + * The value of a "current state" row. A discriminated union so `StateValue` can + * render each kind appropriately (a check/cross for booleans, badges for + * vendors, plain text otherwise). + */ +export type StateRowValue = + | { kind: "bool"; value: boolean } + | { kind: "vendors"; vendors: Vendor[] } + | { kind: "text"; text: string }; + +/** A single label/value row shown in the "current state" section. */ +export interface StateRow { + label: string; + value: StateRowValue; +} + +/** Builds the read-only "current state" rows shown for an insertable. */ +function getInsertableStateRows(insertable: InsertableBuildStatus): StateRow[] { + const rows: StateRow[] = [ + { + label: "Visible to users", + value: { kind: "bool", value: insertable.isVisible } + } + ]; + if (insertable.isOpenComposite !== undefined) { + rows.push({ + label: "Open composite", + value: { kind: "bool", value: insertable.isOpenComposite } + }); + } + rows.push({ + label: "Insert and fasten", + value: { kind: "bool", value: insertable.supportsFasten } + }); + rows.push({ + label: "Vendors", + value: { kind: "vendors", vendors: insertable.vendors } + }); + rows.push({ + label: "Configurable", + value: { kind: "bool", value: !!insertable.configuration } + }); + return rows; +} + +/** Builds the read-only "current state" rows shown for a group. */ +function getGroupStateRows(groupStatus: GroupBuildStatus): StateRow[] { + return [ + { + label: "Default sort order", + value: { + kind: "text", + text: groupStatus.sortAlphabetically + ? "Alphabetical" + : "Standard" + } + } + ]; +} + +/** + * Returns the build issues for an insertable, merging insertable-level and + * configuration-level issues. + */ +function getInsertableBuildIssues( + insertable: InsertableBuildStatus +): BuildIssue[] { + const configIssues = insertable.configuration?.buildIssues ?? []; + return [...insertable.buildIssues, ...configIssues]; +} + +/** + * Returns the build issues for a group, combining stored build-time issues with + * the live "no unhidden insertables" check (computed here since visibility is + * per-insertable state in the same build-status response). + */ +function useGroupBuildIssues( + groupStatus: GroupBuildStatus | undefined, + insertableStatuses: Record | undefined +): BuildIssue[] { + return useMemo(() => { + if (!groupStatus) return []; + const hasUnhidden = groupStatus.insertableOrder.some( + (id) => insertableStatuses?.[id]?.isVisible + ); + if (hasUnhidden) { + return groupStatus.buildIssues; + } + return addBuildIssue(groupStatus.buildIssues, { + type: BuildIssueType.NO_UNHIDDEN_INSERTABLES + }); + }, [groupStatus, insertableStatuses]); +} + +interface IssueIconProps extends ComponentPropsWithRef<"svg"> { + /** The severity to render, or null if all checks pass. */ + severity: BuildIssueSeverity | null; + /** @default IconSize.SMALL */ + size?: number; +} + +/** Renders the icon for a build-issue severity in its severity color. */ +export function IssueIcon({ + severity, + ref, + ...others +}: IssueIconProps): ReactNode { + switch (severity) { + case BuildIssueSeverity.ERROR: + return ( + + ); + case BuildIssueSeverity.WARNING: + return ( + + ); + case BuildIssueSeverity.INFO: + return ( + + ); + case null: + return ( + + ); + } +} + +interface BuildStatusCardProps { + issues: BuildIssue[]; + /** Read-only "current state" rows shown above the build issues. */ + stateRows: StateRow[]; +} + +/** The hover-card dropdown content: state rows + divider + build issues. */ +export function BuildStatusCard(props: BuildStatusCardProps): ReactNode { + const { issues, stateRows } = props; + // Size to content (capped) so short rows/messages don't wrap. + return ( + + + + + + ); +} + +interface BuildStatusBadgeProps { + issues: BuildIssue[]; + /** Read-only "current state" rows shown above the build issues. */ + stateRows: StateRow[]; +} + +/** + * An inline tag summarizing the build-checker state for a group or insertable, + * with a read-only hover card showing the current state and any build issues. + * Only visible to editors and admins. + */ +export function BuildStatusBadge(props: BuildStatusBadgeProps): ReactNode { + const { issues, stateRows } = props; + const maxSeverity = getMaxSeverity(issues); + + return ( + + + + + + + + + + + ); +} + +/** Build-status badge pre-wired for an insertable. */ +export function InsertableStatusBadge({ + insertableId +}: { + insertableId: string; +}): ReactNode { + const { data } = useBuildStatusQuery(); + const insertable = data?.insertables[insertableId]; + if (!insertable) return null; + return ( + + ); +} + +/** Build-status badge pre-wired for a group (includes live visibility check). */ +export function GroupStatusBadge({ groupId }: { groupId: string }): ReactNode { + const { data } = useBuildStatusQuery(); + const groupStatus = data?.groups[groupId]; + const issues = useGroupBuildIssues(groupStatus, data?.insertables); + if (!groupStatus) return null; + return ( + + ); +} + +function CurrentStateSection({ rows }: { rows: StateRow[] }): ReactNode { + return ( + + + Current state + + {rows.map((row) => ( + + {row.label} + + + ))} + + ); +} + +/** Renders a "current state" value: a check/cross for booleans, badges for vendors. */ +function StateValue({ value }: { value: StateRowValue }): ReactNode { + if (value.kind === "bool") { + return value.value ? ( + + ) : ( + + ); + } + + if (value.kind === "text") { + return {value.text}; + } + + if (value.vendors.length === 0) { + return ( + + None + + ); + } + return ( + + {value.vendors.map((vendor) => ( + + {vendor} + + ))} + + ); +} + +/** The human-readable message for a build issue, rendered at display time. */ +function getIssueMessage(issue: BuildIssue): string { + switch (issue.type) { + case BuildIssueType.THUMBNAIL_FAILED: + return "The thumbnail failed to generate."; + case BuildIssueType.NO_THUMBNAIL_TAB: + return "No thumbnail tab is set."; + case BuildIssueType.NO_VENDORS: + return "No vendors could be parsed."; + case BuildIssueType.NO_UNHIDDEN_INSERTABLES: + return "This group has no unhidden insertables."; + } +} + +function BuildIssuesSection({ issues }: { issues: BuildIssue[] }): ReactNode { + if (issues.length === 0) { + return ( + + + Build checks + + + + + All checks pass + + + ); + } + + return ( + + + Build checks + + {issues.map((issue) => ( + + + {getIssueMessage(issue)} + + ))} + + ); +} diff --git a/src/frontend/cards/card-components.tsx b/src/frontend/cards/card-components.tsx index 75a0fdd4..e045fc5b 100644 --- a/src/frontend/cards/card-components.tsx +++ b/src/frontend/cards/card-components.tsx @@ -1,4 +1,4 @@ -import { ActionIcon, Badge, Group, Menu, Table, Text } from "@mantine/core"; +import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core"; import { IconDots, IconExternalLink, @@ -8,7 +8,7 @@ import { IconRefresh, IconSettings } from "@tabler/icons-react"; -import { IconSize } from "../common/style-constants"; +import { IconColor, IconSize } from "../common/style-constants"; import { copyUrlToClipboard, makeUrl, openUrlInNewTab } from "../common/url"; import { PropsWithChildren, ReactNode, useCallback } from "react"; import { AppContextMenu } from "../app-common/app-menu"; @@ -132,10 +132,12 @@ interface CardTitleProps { title: string; searchHit?: SearchHit; thumbnailUrls: ThumbnailUrls; + /** Optional build-status badge rendered after the title. */ + buildStatusBadge?: ReactNode; } export function CardTitle(props: CardTitleProps) { - const { searchHit, title, thumbnailUrls } = props; + const { searchHit, title, thumbnailUrls, buildStatusBadge } = props; const disabled = props.disabled ?? false; const isHidden = props.showHiddenTag ?? false; @@ -153,10 +155,13 @@ export function CardTitle(props: CardTitleProps) { {cardTitle} {isHidden && ( - - - + )} + {buildStatusBadge} ); } @@ -248,7 +253,7 @@ export function AdminOptionsSubmenu(props: PropsWithChildren): ReactNode { } > Admin options diff --git a/src/frontend/cards/insertable-card.tsx b/src/frontend/cards/insertable-card.tsx index f28c227c..45ae3d5b 100644 --- a/src/frontend/cards/insertable-card.tsx +++ b/src/frontend/cards/insertable-card.tsx @@ -7,13 +7,12 @@ import { IconPlus } from "@tabler/icons-react"; import { IconSize } from "../common/style-constants"; -import { useLoaderData, useRouter } from "@tanstack/react-router"; +import { useRouter } from "@tanstack/react-router"; import { PropsWithChildren, ReactNode } from "react"; import { Favorite, getFavoriteForInsertable, - InsertableOut, - LibraryOut + InsertableOut } from "../../shared/api-models"; import { ElementType } from "../../shared/types"; import { SearchHit } from "../search/search"; @@ -22,6 +21,7 @@ import { FavoriteInsertableItem } from "../favorites/favorite-button"; import { useIsInsertableHidden, useSetVisibilityMutation } from "./card-hooks"; +import { InsertableStatusBadge } from "./build-status"; import { AdminOptionsSubmenu, CardTitle, @@ -35,17 +35,16 @@ import { useIsAssemblyInPartStudio } from "../insert/insert-hooks"; import { openInsertMenu } from "../insert/insert-menu"; import { contextDataQueryKey, - libraryQueryKey, libraryQueryMatchKey, + useBuildStatusQuery, useFavoritesQuery } from "../queries"; import { useMutation } from "@tanstack/react-query"; import { apiPost } from "../api-utils/api"; import { queryClient } from "../query-client"; import { showSuccessToast } from "../common/notifications"; -import { toInsertablePath, useLibraryId } from "../api-utils/library"; +import { toInsertablePath } from "../api-utils/library"; import { getAppErrorHandler } from "../api-utils/errors"; -import { getQueryUpdater } from "../common/utils"; interface InsertableCardProps extends PropsWithChildren { insertable: InsertableOut; @@ -94,6 +93,9 @@ export function InsertableCard(props: InsertableCardProps): ReactNode { title={insertable.name} thumbnailUrls={insertable.thumbnailUrls} showHiddenTag={!insertable.isVisible} + buildStatusBadge={ + + } /> } rightSection={ @@ -133,56 +135,95 @@ export function InsertableMenuItems( - + ); } interface InsertableAdminContextMenuProps { - insertable: InsertableOut; + insertableId: string; + elementType: ElementType; } export function InsertableAdminContextMenu( props: InsertableAdminContextMenuProps ): ReactNode { - const { insertable } = props; + const { insertableId, elementType } = props; + const insertableBuild = + useBuildStatusQuery().data?.insertables[insertableId]; - const libraryId = useLibraryId(); - const loaderData = useLoaderData({ from: "/app" }); - const router = useRouter(); + if (!insertableBuild) return null; - const setVisibilityMutation = useSetVisibilityMutation( - [insertable.id], - !insertable.isVisible + return ( + <> + + + {elementType === ElementType.PART_STUDIO && ( + + )} + + ); +} - const setOpenCompositeMutation = useMutation({ +interface ToggleVisibilityMenuItemProps { + insertableId: string; + isVisible: boolean; +} + +function ToggleVisibilityMenuItem({ + insertableId, + isVisible +}: ToggleVisibilityMenuItemProps): ReactNode { + const mutation = useSetVisibilityMutation([insertableId], !isVisible); + return ( + mutation.mutate()} + color={isVisible ? "red" : "blue"} + leftSection={ + isVisible ? ( + + ) : ( + + ) + } + > + {isVisible ? "Hide element" : "Show element"} + + ); +} + +interface ToggleOpenCompositeMenuItemProps { + insertableId: string; + isOpenComposite: boolean; +} + +function ToggleOpenCompositeMenuItem({ + insertableId, + isOpenComposite +}: ToggleOpenCompositeMenuItemProps): ReactNode { + const router = useRouter(); + + const mutation = useMutation({ mutationKey: ["toggle-open-composite"], - mutationFn: () => { - return apiPost( - "/toggle-open-composite" + toInsertablePath(insertable.id), - { - body: { isOpenComposite: !insertable.isOpenComposite } - } - ); - }, + mutationFn: () => + apiPost("/toggle-open-composite" + toInsertablePath(insertableId), { + body: { isOpenComposite: !isOpenComposite } + }), onError: getAppErrorHandler("Failed to update open composite setting."), - onMutate: () => { - void queryClient.cancelQueries({ - queryKey: libraryQueryMatchKey() - }); - queryClient.setQueryData( - libraryQueryKey(libraryId, loaderData.accessData.cacheVersion), - getQueryUpdater((data: LibraryOut) => { - const current = data.insertables[insertable.id]; - if (current) { - current.isOpenComposite = !insertable.isOpenComposite; - } - return data; - }) - ); - }, onSettled: async () => { await queryClient.refetchQueries({ queryKey: contextDataQueryKey() @@ -194,16 +235,45 @@ export function InsertableAdminContextMenu( } }); - const setSupportsFastenMutation = useMutation({ + return ( + mutation.mutate()} + color={isOpenComposite ? "yellow" : undefined} + leftSection={ + isOpenComposite ? ( + + ) : ( + + ) + } + > + {isOpenComposite ? "No open composites" : "Has open composite"} + + ); +} + +interface ToggleInsertAndFastenMenuItemProps { + insertableId: string; + supportsFasten: boolean; +} + +function ToggleInsertAndFastenMenuItem({ + insertableId, + supportsFasten +}: ToggleInsertAndFastenMenuItemProps): ReactNode { + const router = useRouter(); + + const mutation = useMutation({ mutationKey: ["toggle-insert-and-fasten"], - mutationFn: (supportsFasten: boolean) => { - return apiPost( - "/toggle-insert-and-fasten" + toInsertablePath(insertable.id), - { body: { supportsFasten } } - ); - }, - onSuccess: (_result, supportsFasten: boolean) => { - if (supportsFasten) { + mutationFn: (newValue: boolean) => + apiPost( + "/toggle-insert-and-fasten" + toInsertablePath(insertableId), + { + body: { supportsFasten: newValue } + } + ), + onSuccess: (_result, newValue: boolean) => { + if (newValue) { showSuccessToast("Successfully enabled Insert and fasten."); } }, @@ -220,55 +290,20 @@ export function InsertableAdminContextMenu( }); return ( - <> - setVisibilityMutation.mutate()} - color={insertable.isVisible ? "red" : "blue"} - leftSection={ - insertable.isVisible ? ( - - ) : ( - - ) - } - > - {insertable.isVisible ? "Hide element" : "Show element"} - - - {insertable.elementType === ElementType.PART_STUDIO && ( - setOpenCompositeMutation.mutate()} - color={insertable.isOpenComposite ? "yellow" : undefined} - leftSection={ - insertable.isOpenComposite ? ( - - ) : ( - - ) - } - > - {insertable.isOpenComposite - ? "No open composites" - : "Has open composite"} - - )} - - setSupportsFastenMutation.mutate(!insertable.supportsFasten) - } - color={insertable.supportsFasten ? "red" : "blue"} - leftSection={ - insertable.supportsFasten ? ( - - ) : ( - - ) - } - > - {insertable.supportsFasten - ? "Disable insert and fasten" - : "Enable Insert and fasten"} - - + mutation.mutate(!supportsFasten)} + color={supportsFasten ? "red" : "blue"} + leftSection={ + supportsFasten ? ( + + ) : ( + + ) + } + > + {supportsFasten + ? "Disable insert and fasten" + : "Enable Insert and fasten"} + ); } diff --git a/src/frontend/common/style-constants.ts b/src/frontend/common/style-constants.ts index 7fe82b1f..9ad83aca 100644 --- a/src/frontend/common/style-constants.ts +++ b/src/frontend/common/style-constants.ts @@ -55,5 +55,6 @@ export const HeartIconColor = "var(--mantine-color-red-6)"; export enum IconColor { YELLOW = "var(--mantine-color-yellow-6)", BLUE = "var(--mantine-color-blue-6)", - RED = HeartIconColor + RED = HeartIconColor, + GREEN = "var(--mantine-color-green-6)" } diff --git a/src/frontend/groups/group-card.tsx b/src/frontend/groups/group-card.tsx index ff891d2d..7f8fb0ec 100644 --- a/src/frontend/groups/group-card.tsx +++ b/src/frontend/groups/group-card.tsx @@ -25,9 +25,11 @@ import { ReloadThumbnailMenuItem } from "../cards/card-components"; import { AddGroupItem } from "./add-group-menu"; +import { GroupStatusBadge } from "../cards/build-status"; import { libraryQueryKey, libraryQueryMatchKey, + useBuildStatusQuery, useLibraryQuery } from "../queries"; import { toLibraryPath, useLibraryId } from "../api-utils/library"; @@ -44,7 +46,6 @@ interface GroupCardProps extends PropsWithChildren { export function GroupCard(props: GroupCardProps): ReactNode { const { group } = props; const navigate = useNavigate(); - return ( { @@ -57,6 +58,7 @@ export function GroupCard(props: GroupCardProps): ReactNode { } /> } rightSection={} @@ -72,89 +74,170 @@ interface GroupMenuItemsProps { export function GroupMenuItems(props: GroupMenuItemsProps): ReactNode { const { group } = props; + return ( + <> + + + + + + ); +} + +interface GroupAdminContextMenuProps { + groupId: string; + groupName: string; +} +export function GroupAdminContextMenu({ + groupId, + groupName +}: GroupAdminContextMenuProps): ReactNode { const isHome = useIsHome(); + const groupStatus = useBuildStatusQuery().data?.groups[groupId]; + const groupOrder = useLibraryQuery().data?.groupOrder ?? []; + const setGroupOrderMutation = useSetGroupOrderMutation(); + + if (!groupStatus) return null; + + return ( + <> + {isHome && ( + + setGroupOrderMutation.mutate(newOrder) + } + /> + )} + + + + + {isHome && ( + <> + + + + + )} + + ); +} + +function ShowAllElementsMenuItem({ + insertableOrder +}: { + insertableOrder: string[]; +}): ReactNode { + const mutation = useSetVisibilityMutation(insertableOrder, true); + return ( + } + onClick={() => mutation.mutate()} + > + Show all elements + + ); +} + +function HideAllElementsMenuItem({ + insertableOrder +}: { + insertableOrder: string[]; +}): ReactNode { + const mutation = useSetVisibilityMutation(insertableOrder, false); + return ( + } + onClick={() => mutation.mutate()} + > + Hide all elements + + ); +} + +interface ToggleSortOrderMenuItemProps { + groupId: string; + groupName: string; + sortAlphabetically: boolean; +} + +function ToggleSortOrderMenuItem({ + groupId, + groupName, + sortAlphabetically +}: ToggleSortOrderMenuItemProps): ReactNode { const libraryId = useLibraryId(); - const deleteGroupMutation = useMutation({ - mutationKey: ["delete-group"], - mutationFn: async () => { - return apiDelete("/group" + toLibraryPath(libraryId), { - query: { groupId: group.id } - }); - }, - onSuccess: () => { + const mutation = useMutation({ + mutationKey: ["sort-group-alphabetically"], + mutationFn: async () => + apiPost("/sort-group-alphabetically" + toLibraryPath(libraryId), { + body: { groupId, sortAlphabetically: !sortAlphabetically } + }), + onError: getAppErrorHandler(`Failed to update group ${groupName}.`), + onSettled: () => { void queryClient.invalidateQueries({ queryKey: libraryQueryMatchKey() }); } }); - const setGroupOrderMutation = useSetGroupOrderMutation(); - const groupOrder = useLibraryQuery().data?.groupOrder ?? []; - - const showAllMutation = useSetVisibilityMutation( - group.insertableOrder, - true - ); - - const hideAllMutation = useSetVisibilityMutation( - group.insertableOrder, - false + return ( + + ) : ( + + ) + } + onClick={() => mutation.mutate()} + > + {sortAlphabetically ? "Use tab order" : "Sort alphabetically"} + ); +} - const orderItems = isHome && ( - setGroupOrderMutation.mutate(newOrder)} - /> - ); +function DeleteGroupMenuItem({ groupId }: { groupId: string }): ReactNode { + const libraryId = useLibraryId(); - const modifyGroupItems = isHome && ( - <> - - } - color="red" - onClick={() => { - deleteGroupMutation.mutate(); - }} - > - Delete - - - - ); + const mutation = useMutation({ + mutationKey: ["delete-group"], + mutationFn: async () => + apiDelete("/group" + toLibraryPath(libraryId), { + query: { groupId } + }), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: libraryQueryMatchKey() + }); + } + }); return ( - <> - - - {orderItems} - } - onClick={() => { - showAllMutation.mutate(); - }} - > - Show all elements - - } - onClick={() => { - hideAllMutation.mutate(); - }} - > - Hide all elements - - - - {modifyGroupItems} - - + } + color="red" + onClick={() => mutation.mutate()} + > + Delete + ); } @@ -162,13 +245,13 @@ function useSetGroupOrderMutation() { const libraryId = useLibraryId(); const cacheVersion = useLoaderData({ from: "/app" }).accessData .cacheVersion; + return useMutation({ mutationKey: ["group-order"], - mutationFn: async (groupOrder: string[]) => { - return apiPost("/group-order" + toLibraryPath(libraryId), { + mutationFn: async (groupOrder: string[]) => + apiPost("/group-order" + toLibraryPath(libraryId), { body: { groupOrder } - }); - }, + }), onMutate: (newOrder: string[]) => { queryClient.setQueryData( libraryQueryKey(libraryId, cacheVersion), @@ -184,69 +267,5 @@ function useSetGroupOrderMutation() { queryKey: libraryQueryMatchKey() }); } - // Don't need an onSettled handler since group-order doesn't expire - }); -} - -function useToggleSortOrderMutation(group: GroupOut) { - const libraryId = useLibraryId(); - const cacheVersion = useLoaderData({ from: "/app" }).accessData - .cacheVersion; - - return useMutation({ - mutationKey: ["sort-group-alphabetically"], - mutationFn: async () => { - return apiPost( - "/sort-group-alphabetically" + toLibraryPath(libraryId), - { - body: { - groupId: group.id, - sortAlphabetically: !group.sortAlphabetically - } - } - ); - }, - onMutate: () => { - queryClient.setQueryData( - libraryQueryKey(libraryId, cacheVersion), - getQueryUpdater((data: LibraryOut) => { - const oldGroup = data.groups[group.id]; - if (oldGroup) { - oldGroup.sortAlphabetically = !group.sortAlphabetically; - } - return data; - }) - ); - }, - onError: getAppErrorHandler(`Failed to update group ${group.name}.`), - onSettled: () => { - void queryClient.invalidateQueries({ - queryKey: libraryQueryMatchKey() - }); - } }); } - -interface GroupDataItemsProps { - group: GroupOut; -} - -function GroupDataItems({ group }: GroupDataItemsProps) { - const toggleSortOrderMutation = useToggleSortOrderMutation(group); - return ( - - ) : ( - - ) - } - onClick={() => { - toggleSortOrderMutation.mutate(); - }} - > - {group.sortAlphabetically ? "Use tab order" : "Sort alphabetically"} - - ); -} diff --git a/src/frontend/queries.ts b/src/frontend/queries.ts index 69fea077..5178151f 100644 --- a/src/frontend/queries.ts +++ b/src/frontend/queries.ts @@ -4,13 +4,17 @@ import { queryOptions, useQuery } from "@tanstack/react-query"; import { useLoaderData } from "@tanstack/react-router"; import { apiGet } from "./api-utils/api"; -import { type FavoritesData, type LibraryOut } from "../shared/api-models"; +import { + type FavoritesData, + type LibraryBuildStatus, + type LibraryOut +} from "../shared/api-models"; import { LibraryId } from "../shared/types"; 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 "./search/search"; +import { SEARCH_OPTIONS } from "../shared/search"; import { InstancePath } from "../shared/onshape-path"; export function getConfigurationMatchKey() { @@ -119,6 +123,35 @@ export function getFavoritesQuery(libraryId: LibraryId) { }); } +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 + }), + staleTime: Infinity, + gcTime: Infinity + }); +} + +export function useBuildStatusQuery() { + const libraryId = useLibraryId(); + const cacheVersion = useLoaderData({ from: "/app" }).accessData + .cacheVersion; + return useQuery(getBuildStatusQuery(libraryId, cacheVersion)); +} + export function useFavoritesQuery() { const libraryId = useLibraryId(); return useQuery(getFavoritesQuery(libraryId)); diff --git a/src/frontend/search/search.test.ts b/src/frontend/search/search.test.ts index 07221dd6..47307928 100644 --- a/src/frontend/search/search.test.ts +++ b/src/frontend/search/search.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { processTerm, tokenize } from "./search"; +import { processTerm, tokenize } from "../../shared/search"; describe("processTerm", () => { it("should process camelCase", () => { diff --git a/src/frontend/search/search.ts b/src/frontend/search/search.ts index 07cfdc08..0d9ed579 100644 --- a/src/frontend/search/search.ts +++ b/src/frontend/search/search.ts @@ -2,15 +2,6 @@ import MiniSearch, { SearchResult as MiniSearchResult } from "minisearch"; import { Vendor } from "../../shared/types"; import { SearchDocument } from "../../shared/search"; -// Re-export the shared index definitions so existing frontend imports keep working. -export type { SearchDocument } from "../../shared/search"; -export { - SEARCH_OPTIONS, - buildSearchDb, - processTerm, - tokenize -} from "../../shared/search"; - /** * A user facing name to use for elements currently being filtered/searched on. */ diff --git a/src/shared/api-models.ts b/src/shared/api-models.ts index 58211026..2235854d 100644 --- a/src/shared/api-models.ts +++ b/src/shared/api-models.ts @@ -1,6 +1,7 @@ import { ElementPath, InstancePath } from "./onshape-path"; -import { Configuration } from "./configuration-models"; +import { Configuration, ParameterObj } from "./configuration-models"; import { ElementType, LibraryId, ThumbnailUrls, Vendor } from "./types"; +import { BuildIssue } from "./build-checker"; export interface InsertableOut { id: string; @@ -11,10 +12,7 @@ export interface InsertableOut { path: ElementPath; name: string; microversionId: string; - versionName: string; - versionCreatedAt: string; isVisible: boolean; - isOpenComposite: boolean; supportsFasten: boolean; elementType: ElementType; thumbnailUrls: ThumbnailUrls; @@ -27,11 +25,35 @@ export interface GroupOut { documentId: string; path: InstancePath; name: string; - sortAlphabetically: boolean; thumbnailUrls: ThumbnailUrls; insertableOrder: string[]; } +export interface ConfigurationBuildStatus { + buildIssues: BuildIssue[]; + parameters: ParameterObj[]; +} + +export interface GroupBuildStatus { + buildIssues: BuildIssue[]; + sortAlphabetically: boolean; + insertableOrder: string[]; +} + +export interface InsertableBuildStatus { + buildIssues: BuildIssue[]; + isVisible: boolean; + isOpenComposite: boolean; + supportsFasten: boolean; + vendors: Vendor[]; + configuration?: ConfigurationBuildStatus; +} + +export interface LibraryBuildStatus { + groups: Record; + insertables: Record; +} + export type Insertables = Record; export type Groups = Record; diff --git a/src/shared/build-checker.test.ts b/src/shared/build-checker.test.ts new file mode 100644 index 00000000..6fa1e0dc --- /dev/null +++ b/src/shared/build-checker.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + addBuildIssue, + BuildIssue, + BuildIssueSeverity, + BuildIssueType, + clearBuildIssue, + getMaxSeverity +} from "./build-checker"; + +/** A representative issue type for each severity. */ +const TYPE_BY_SEVERITY: Record = { + [BuildIssueSeverity.INFO]: BuildIssueType.NO_VENDORS, + [BuildIssueSeverity.WARNING]: BuildIssueType.NO_THUMBNAIL_TAB, + [BuildIssueSeverity.ERROR]: BuildIssueType.THUMBNAIL_FAILED +}; + +const issue = (severity: BuildIssueSeverity): BuildIssue => ({ + type: TYPE_BY_SEVERITY[severity] +}); + +describe("getMaxSeverity", () => { + it("returns null when there are no issues", () => { + 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); + }); +}); + +describe("addBuildIssue", () => { + it("appends a new issue", () => { + const result = addBuildIssue([], { + type: BuildIssueType.NO_VENDORS + }); + expect(result).toEqual([{ type: BuildIssueType.NO_VENDORS }]); + }); + + it("does not duplicate an issue with the same type", () => { + const existing: BuildIssue[] = [{ type: BuildIssueType.NO_VENDORS }]; + const result = addBuildIssue(existing, { + type: BuildIssueType.NO_VENDORS + }); + expect(result).toBe(existing); + }); +}); + +describe("clearBuildIssue", () => { + it("removes issues with the given type", () => { + const result = clearBuildIssue( + [ + { type: BuildIssueType.THUMBNAIL_FAILED }, + { type: BuildIssueType.NO_VENDORS } + ], + BuildIssueType.THUMBNAIL_FAILED + ); + expect(result).toEqual([{ type: BuildIssueType.NO_VENDORS }]); + }); + + it("returns an equivalent array when the type is absent", () => { + const result = clearBuildIssue( + [{ type: BuildIssueType.NO_VENDORS }], + BuildIssueType.THUMBNAIL_FAILED + ); + expect(result).toEqual([{ type: BuildIssueType.NO_VENDORS }]); + }); +}); diff --git a/src/shared/build-checker.ts b/src/shared/build-checker.ts new file mode 100644 index 00000000..79e3e6c2 --- /dev/null +++ b/src/shared/build-checker.ts @@ -0,0 +1,103 @@ +/** + * Build checker: a small framework for flagging data-quality issues with groups + * and insertables. Most checks run at build time (during the load-document + * workflow) and are stored on the group/insertable; a few are computed live in + * the frontend when they depend on per-user state (e.g. access level). + */ + +export enum BuildIssueSeverity { + /** A potential issue that is usually fine, e.g. no vendors parsed. */ + INFO = "info", + /** A non-critical issue that should be fixed, e.g. no thumbnail tab set. */ + WARNING = "warning", + /** A major issue, e.g. a thumbnail failing to generate. */ + ERROR = "error" +} + +/** Discriminates the {@link BuildIssue} union. */ +export enum BuildIssueType { + THUMBNAIL_FAILED = "thumbnail-failed", + NO_THUMBNAIL_TAB = "no-thumbnail-tab", + NO_VENDORS = "no-vendors", + NO_UNHIDDEN_INSERTABLES = "no-unhidden-insertables" +} + +/** + * Base shape for a build issue, discriminated on type. + */ +interface BuildIssueOf { + type: T; +} + +export type BuildIssue = + | BuildIssueOf + | BuildIssueOf + | BuildIssueOf + | BuildIssueOf; + +/** The severity for a given issue, derived from its type. */ +export function getIssueSeverity(issue: BuildIssue): BuildIssueSeverity { + switch (issue.type) { + case BuildIssueType.THUMBNAIL_FAILED: + case BuildIssueType.NO_UNHIDDEN_INSERTABLES: + return BuildIssueSeverity.ERROR; + case BuildIssueType.NO_THUMBNAIL_TAB: + return BuildIssueSeverity.WARNING; + case BuildIssueType.NO_VENDORS: + return BuildIssueSeverity.INFO; + } +} + +/** + * Adds `issue` to `issues`, returning a new array. No-op (returns the original + * array) if an issue with the same type is already present, so the same check + * can be applied repeatedly without duplicating issues. + */ +export function addBuildIssue( + issues: BuildIssue[], + issue: BuildIssue +): BuildIssue[] { + if (issues.some((existing) => existing.type === issue.type)) { + return issues; + } + return [...issues, issue]; +} + +/** + * Removes any issue with the given `type`, returning a new array. Used e.g. + * when a thumbnail is successfully reloaded to clear a stale `thumbnail-failed` + * issue. + */ +export function clearBuildIssue( + issues: BuildIssue[], + type: BuildIssueType +): BuildIssue[] { + return issues.filter((issue) => issue.type !== type); +} + +/** Worst-to-best ordering. Higher index = more severe. */ +const SEVERITY_ORDER: BuildIssueSeverity[] = [ + BuildIssueSeverity.INFO, + BuildIssueSeverity.WARNING, + BuildIssueSeverity.ERROR +]; + +/** + * Returns the worst severity present in `issues`, or `null` when there are no + * issues (i.e. all checks pass). + */ +export function getMaxSeverity( + issues: BuildIssue[] +): BuildIssueSeverity | null { + let max: BuildIssueSeverity | null = null; + for (const issue of issues) { + const severity = getIssueSeverity(issue); + if ( + max === null || + SEVERITY_ORDER.indexOf(severity) > SEVERITY_ORDER.indexOf(max) + ) { + max = severity; + } + } + return max; +} diff --git a/src/shared/schema.ts b/src/shared/schema.ts index 7b7f1e97..02afb733 100644 --- a/src/shared/schema.ts +++ b/src/shared/schema.ts @@ -9,6 +9,7 @@ import { } from "./types"; import { ThumbnailUrls } from "./types"; import { Configuration, ParameterObj } from "./configuration-models"; +import { BuildIssue } from "./build-checker"; export const libraries = sqliteTable("libraries", { id: text("id").primaryKey(), @@ -37,7 +38,12 @@ export const groups = sqliteTable( sortOrder: integer("sort_order").notNull().default(0), thumbnailUrls: text("thumbnail_urls", { mode: "json" - }).$type() + }).$type(), + // Build-time issues flagged by the build checker, recomputed on reload. + buildIssues: text("build_issues", { mode: "json" }) + .$type() + .notNull() + .default([]) }, (t) => [unique().on(t.documentId, t.libraryId)] ); @@ -84,7 +90,12 @@ export const insertables = sqliteTable( }).$type(), fastenInfo: text("fasten_info", { mode: "json" - }).$type() + }).$type(), + // Build-time issues flagged by the build checker, recomputed on reload. + buildIssues: text("build_issues", { mode: "json" }) + .$type() + .notNull() + .default([]) }, (t) => [unique().on(t.elementId, t.groupId)] ); @@ -97,6 +108,10 @@ export const configurations = sqliteTable("configurations", { parameters: text("parameters", { mode: "json" }) .$type() .notNull() + .default([]), + buildIssues: text("build_issues", { mode: "json" }) + .$type() + .notNull() .default([]) });