From 860d5e27f77e2540b2b4f04600f5179e12864eaf Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 26 Jun 2026 12:31:05 -0500 Subject: [PATCH 01/42] Add styles import/export feature Add a Styles settings page that exports vertex/edge styles to a file and imports default styles from a file. Introduces: - A versioned file envelope primitive (core/fileEnvelope). - A salvaging styling parser that keeps valid fields and reports per-field issues rather than rejecting the whole file (core/styling). - importedVertexStylesAtom / importedEdgeStylesAtom and the styles cascade (app defaults < imported < user) in userPreferences. - DOMPurify SVG sanitization at the icon render sinks. Imported icons are restricted to Lucide references and base64 data URIs; remote URLs are rejected so importing never triggers outbound requests. Import merges into the imported layer and confirms before overwriting existing imported defaults. Export and import surface success/failure and per-field warnings through dialogs. Includes ADRs (file envelope, styling format + salvaging contract) and user docs for the Styles page. --- CONTEXT.md | 17 +- docs/adr/20260624-shared-file-envelope.md | 49 ++ ...tyling-file-format-and-salvaging-import.md | 84 +++ docs/features/README.md | 2 +- docs/features/settings.md | 30 + package.json | 3 + packages/graph-explorer/package.json | 1 + packages/graph-explorer/src/App.tsx | 2 + .../src/components/VertexIcon.tsx | 8 + .../src/core/StateProvider/storageAtoms.ts | 21 +- .../src/core/StateProvider/userPreferences.ts | 155 +++-- .../core/fileEnvelope/fileEnvelope.test.ts | 137 +++++ .../src/core/fileEnvelope/fileEnvelope.ts | 72 +++ .../src/core/fileEnvelope/index.ts | 8 + .../graph-explorer/src/core/styling/index.ts | 14 + .../src/core/styling/roundTrip.test.ts | 561 ++++++++++++++++++ .../src/core/styling/stylingParser.test.ts | 504 ++++++++++++++++ .../src/core/styling/stylingParser.ts | 236 ++++++++ .../styling/useStylingImportExport.test.ts | 361 +++++++++++ .../core/styling/useStylingImportExport.ts | 139 +++++ .../src/modules/GraphViewer/renderNode.tsx | 6 +- .../src/routes/Settings/SettingsRoot.tsx | 8 +- .../src/routes/Settings/SettingsStyles.tsx | 462 +++++++++++++++ .../Settings/StylingImportIssuesDialog.tsx | 101 ++++ .../src/routes/Settings/index.ts | 1 + packages/graph-explorer/src/utils/fileData.ts | 1 + pnpm-lock.yaml | 7 + 27 files changed, 2928 insertions(+), 62 deletions(-) create mode 100644 docs/adr/20260624-shared-file-envelope.md create mode 100644 docs/adr/20260624-styling-file-format-and-salvaging-import.md create mode 100644 packages/graph-explorer/src/core/fileEnvelope/fileEnvelope.test.ts create mode 100644 packages/graph-explorer/src/core/fileEnvelope/fileEnvelope.ts create mode 100644 packages/graph-explorer/src/core/fileEnvelope/index.ts create mode 100644 packages/graph-explorer/src/core/styling/index.ts create mode 100644 packages/graph-explorer/src/core/styling/roundTrip.test.ts create mode 100644 packages/graph-explorer/src/core/styling/stylingParser.test.ts create mode 100644 packages/graph-explorer/src/core/styling/stylingParser.ts create mode 100644 packages/graph-explorer/src/core/styling/useStylingImportExport.test.ts create mode 100644 packages/graph-explorer/src/core/styling/useStylingImportExport.ts create mode 100644 packages/graph-explorer/src/routes/Settings/SettingsStyles.tsx create mode 100644 packages/graph-explorer/src/routes/Settings/StylingImportIssuesDialog.tsx diff --git a/CONTEXT.md b/CONTEXT.md index f786449c7..2469a484b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -63,8 +63,21 @@ A key-value pair on a Vertex or Edge. UI label varies by query language: "Proper _Avoid_: Attribute (legacy code term being phased out) **Styles**: -Display customizations per Vertex Type and Edge Type — shape, color, icon, line style, and display labels. Persisted and merged with Schema-discovered metadata to produce the final rendering. The two specific instances are **Vertex Styles** and **Edge Styles**, each stored in IndexedDB as its own type-keyed Map: `userVertexStylesAtom` (`Map`, key `"user-vertex-styles"`) and `userEdgeStylesAtom` (`Map`, key `"user-edge-styles"`), replacing the former single `userStylingAtom` object. Storage keys follow a `--styles` convention; only the user-defined layer (`user-`) exists today, with additional style layers planned in later work. -_Avoid_: User Preferences, preferences, user styling, user settings (the `*Preferences*` code names — `VertexPreferencesStorageModel`, `userPreferences.ts`, `vertexPreferencesAtom` — are the legacy term being phased out) +Display customizations per Vertex Type and Edge Type — shape, color, icon, line style, and display labels. Resolved through a **Styles Cascade** (highest precedence first): User Custom Styles → Imported Default Styles → App Default Styles. Persisted and merged with Schema-discovered metadata to produce the final rendering. The two specific instances are **Vertex Styles** and **Edge Styles**, each stored in IndexedDB as its own type-keyed Map per layer. Storage keys follow a `--styles` convention. +_Avoid_: User Preferences, preferences, user styling, user settings (the `*Preferences*` code names — `VertexPreferencesStorageModel`, `userPreferences.ts`, `vertexPreferencesAtom` — are the legacy term being phased out); "customization" as a standalone noun + +**Styles Cascade**: +The precedence stack that resolves which style value a vertex or edge type displays. From highest to lowest priority: (4) **User Custom Styles** — per-type edits made in the style dialogs, (3) **Imported Default Styles** — loaded from a file via Settings, (2) **Server Default Styles** — fetched from server config (future, out of scope), (1) **App Default Styles** — hardcoded in the codebase (`defaultVertexPreferences` / `defaultEdgePreferences`). All layers below User Custom Styles (1–3) collectively are **Default Styles** — what "Reset to Default" restores to. The verb is **customize**; the UI shorthand is **custom styles** / **default styles**. +_Avoid_: Effective styles (no separate atom), style overrides + +**User Custom Styles**: +The highest-precedence layer in the Styles Cascade. Per-type edits a user makes in the vertex/edge style dialogs. Stored in `userVertexStylesAtom` (`Map`, key `"user-vertex-styles"`) and `userEdgeStylesAtom` (`Map`, key `"user-edge-styles"`). Clearing these ("Reset all custom styles" in Settings) reveals Imported Default Styles beneath. + +**Imported Default Styles**: +The second-highest-precedence layer in the Styles Cascade (below User Custom, above App Default). Loaded from a styling export file via the Settings → Styles screen. Stored in `importedVertexStylesAtom` (key `"imported-vertex-styles"`) and `importedEdgeStylesAtom` (key `"imported-edge-styles"`). Non-destructive to User Custom Styles — import writes only this layer. Clearing these ("Clear imported defaults" in Settings) falls through to App Default Styles. + +**App Default Styles**: +The lowest-precedence layer in the Styles Cascade. Hardcoded in the codebase as `defaultVertexPreferences` and `defaultEdgePreferences`. Not persisted — always available as the final fallback. **Schema Sync**: The process that queries the database to discover vertex types, edge types, and their attributes. Required before a user can explore a new Connection. diff --git a/docs/adr/20260624-shared-file-envelope.md b/docs/adr/20260624-shared-file-envelope.md new file mode 100644 index 000000000..d4a484cfc --- /dev/null +++ b/docs/adr/20260624-shared-file-envelope.md @@ -0,0 +1,49 @@ +# ADR — Shared file envelope for import/export formats + +- **Status:** Accepted +- **Date:** 2026-06-24 +- **Related:** ADR `styling-file-format-and-salvaging-import` (first consumer beyond connection export). `saveConfigurationToFile` in `utils/saveConfigurationToFile.ts` (predecessor pattern that predates this envelope). + +## Context + +Graph Explorer already exports connection configurations to JSON files (`saveConfigurationToFile`). A new styling import/export feature needs its own file format. Both share the same structural concerns: identifying what the file contains, which version of the format was used, and when it was created. Without a shared envelope, each feature invents its own metadata shape, making it harder to add future exports (graph snapshots, layout presets) consistently. + +## Decision + +A shared **file envelope** primitive lives in `packages/graph-explorer/src/core/fileEnvelope/`. It provides: + +1. **An envelope schema** — a Zod object wrapping any payload: + + ```ts + { + meta: { kind: string, version: string, timestamp: string, source: string, sourceVersion: string }, + data: T // payload — validated separately per kind + } + ``` + +2. **`meta` uses `z.looseObject()`** — unknown fields are preserved, not stripped. This is forward-compatibility: a newer exporter can add fields (e.g., `exportedBy`) without breaking older importers that ignore them. + +3. **`kind` discriminates the file type.** Known kinds today: `"styling-export"`. Connection export adopts the envelope in follow-up work (not this PR). Each `kind` has its own payload schema registered separately. + +4. **`version` is the payload schema version**, not the app version. The `sourceVersion` field carries the app version for diagnostics. Semver-style (`"1.0"`) — major bump = breaking, minor = additive. + +5. **Helpers:** + - `createFileEnvelope(kind, version, data)` — stamps `timestamp`, `source` ("Graph Explorer"), and `sourceVersion` from the build constant. + - `parseFileEnvelope(blob)` — reads JSON, validates the outer envelope schema, returns `{ meta, rawData }` without validating the payload (caller validates `rawData` per `kind`). + + Callers compose the envelope with their own file-save logic (e.g., `toJsonFileData` + `saveFile` from `utils/fileData`). + +### Why a separate module (not inline in the styling code) + +The envelope is intentionally generic so that the existing connection export and future formats (graph session snapshots, layout exports) can reuse it without coupling to the styling module. Keeping it in `core/fileEnvelope/` makes the reuse path obvious. + +### Why not reuse `SerializedBackupSchema` from `localDb.ts` + +The backup format (`graph-explorer-config.json`) dumps the entire IndexedDB store — all keys, all values, no per-kind typing. It serves disaster recovery, not feature-level import/export. The envelope serves typed, single-purpose files. They have different validation needs and audiences. + +## Consequences + +- New file-based import/export features get metadata handling for free — they only define their payload schema and `kind` string. +- The styling import validates `kind === "styling-export"` and rejects other kinds with a clear error, rather than silently misinterpreting a connection export file. +- The existing connection export (`saveConfigurationToFile`) does **not** migrate to this envelope in this PR — that is a separate, backward-compatible follow-up. The envelope is designed so it can wrap the existing connection shape without breaking existing importers (they already use `z.looseObject`). +- `parseFileEnvelope` returns the raw payload as `unknown` — the caller is responsible for kind-specific validation. This keeps the envelope layer thin and avoids a registry pattern. diff --git a/docs/adr/20260624-styling-file-format-and-salvaging-import.md b/docs/adr/20260624-styling-file-format-and-salvaging-import.md new file mode 100644 index 000000000..81777ebb4 --- /dev/null +++ b/docs/adr/20260624-styling-file-format-and-salvaging-import.md @@ -0,0 +1,84 @@ +# ADR — Styling file format and salvaging import contract + +- **Status:** Accepted +- **Date:** 2026-06-24 +- **Related:** ADR `shared-file-envelope` (the outer envelope this payload lives inside). ADR `type-keyed-map-atoms-for-user-preferences` (the Map storage shape that the imported layer mirrors). Issue #1866 (Preferences → Styles rename — deferred, not bundled here). + +## Context + +Users want to share styling configurations across machines and team members. The feature needs a file format that is stable enough to share, resilient enough to survive partial corruption or forward-incompatible additions, and secure enough that importing a file from an untrusted source does not introduce XSS vectors through icon URLs or SVG content. + +## Decision + +### File format + +The styling export file uses the shared file envelope (ADR `shared-file-envelope`) with `kind: "styling-export"`, `version: "1.0"`. The payload shape: + +```ts +type StylingExportPayload = { + vertices: Record; + edges: Record>; +}; + +type VertexStyleFileEntry = Omit< + VertexPreferencesStorageModel, + "type" | "iconUrl" +> & { icon?: string }; +``` + +Vertex and edge entries mirror `VertexPreferencesStorageModel` / `EdgePreferencesStorageModel` minus the `type` field (the type is the Record key). The vertex entry additionally renames `iconUrl` → `icon` at this seam (see below). Fields are all optional — a partial entry is valid (only the specified fields override defaults). These types live in `useStylingImportExport.ts`. + +### Icon security + +The icon field (named `icon` in the file format, mapped to `iconUrl` at the storage-model seam) is validated against an allowlist regex: + +- `lucide:` — built-in Lucide icon references (alphanumeric + hyphens only after prefix) +- `data:image/;base64,` — inline base64 data URIs with image MIME types + +Any other value (bare names, `javascript:`, relative paths, **and `http(s)://` URLs**) is dropped and reported as an import issue. Remote URLs are intentionally rejected: importing a file should never cause the app to issue outbound requests to a host chosen by the file's author. Only `icon` is accepted as an input field — `iconUrl` (the storage-model name) is not a valid file field. + +`iconImageType` is constrained to known image MIME types: `image/svg+xml`, `image/png`, `image/jpeg`, `image/gif`, `image/webp`. Unknown values are dropped; the icon falls through to default `` rendering behavior. + +### Salvaging parser contract + +The parser is **salvaging** — it extracts as much valid data as possible rather than failing on the first bad field. Contract: + +```ts +function parseStylingPayload(rawData: unknown): StylingParseResult | null; + +type StylingParseResult = { + vertexStyles: Map; + edgeStyles: Map; + issues: ImportIssue[]; +}; + +type ImportIssue = { + entityType: "vertex" | "edge"; + typeName: string; + field: string; + message: string; +}; +``` + +- Returns `null` only on structural envelope failure (not valid JSON, missing `vertices`/`edges` keys, wrong types at the top level). The caller treats `null` as a hard failure. +- Per-field validation: each field is independently checked. An invalid field is dropped from the entry (falls through to default), reported in `issues`, and does not affect other fields in the same entry or other entries. +- Unknown fields are reported as issues (typo detection) but do not cause entry rejection. +- Empty entries (all fields invalid or unknown) are dropped entirely — they would be no-ops. + +### Export semantics + +Export produces the **effective merged view**: for each type that has either user or imported styling, the output is `{ ...importedMap.get(type), ...userMap.get(type) }` with user fields winning on conflict. Re-importing this file on a fresh machine reproduces the full visual appearance. + +### Import semantics + +Import writes to the **imported layer** (`importedVertexStylesAtom` / `importedEdgeStylesAtom`) — it is **non-destructive** to user customizations. The user layer is untouched. If the user has customized a field that the import also specifies, the user's value wins in the cascade (user layer has higher precedence). + +Import **merges** into the existing imported layer rather than replacing it: each type in the file is set onto the current imported map, and types not present in the file are retained. When the file specifies a type that already has an imported default, that overlap is surfaced via `getConflicts` and the user confirms before the overwrite proceeds. This lets a user assemble imported defaults from several files while still being warned before any existing imported default is overwritten. + +## Consequences + +- **Partial imports are useful, not errors.** A file with 50 valid entries and 2 corrupt ones imports the 50 and reports the 2. Users see exactly what was wrong and can fix the source file. +- **Forward-compatible.** Unknown fields in `meta` are preserved (looseObject). Unknown fields in payload entries are reported but don't break parsing. A newer exporter adding fields (e.g., `glow`, `animation`) produces a file that older importers can partially consume. +- **No XSS surface.** Icon URLs are allowlisted; SVG content fetched from URLs is sanitized by DOMPurify at the render sink (separate from this parser). The parser's job is preventing obviously-malicious URLs from entering storage. +- **The `icon` → `iconUrl` rename at the seam** keeps the file format user-friendly (`icon` is the natural name in a config file) while the storage model uses the more precise `iconUrl` internally. This seam is in the parser, not spread across consumers. +- **Coverage thresholds stay or rise.** The salvaging parser and icon validation are contract-tested by driving every accepted shape, line, and arrow value through `parseStylingPayload` and asserting rejection of unknown values. Any field addition or rename is a deliberate decision that forces a test update. diff --git a/docs/features/README.md b/docs/features/README.md index eb154199b..658f5ab2e 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -22,7 +22,7 @@ Execute raw Gremlin, openCypher, or SPARQL queries directly from the Search pane ### Customizable Styles -Personalize how each node and edge type appears on the canvas. Change colors, shapes, borders, icons, and which property is displayed as the label. You can also rename how type names are displayed throughout the application — for example, show the `airport` label as "Airport" or a `route` edge as "Flies To" — without modifying the underlying data. +Personalize how each node and edge type appears on the canvas. Change colors, shapes, borders, icons, and which property is displayed as the label. You can also rename how type names are displayed throughout the application — for example, show the `airport` label as "Airport" or a `route` edge as "Flies To" — without modifying the underlying data. Styles can be [exported and imported](./settings.md#styles) from the Settings → Styles page to share a visual configuration across machines or with teammates. ### Save & Load Graph diff --git a/docs/features/settings.md b/docs/features/settings.md index 511245f85..386a4cd17 100644 --- a/docs/features/settings.md +++ b/docs/features/settings.md @@ -8,6 +8,36 @@ - **Save Configuration:** This action will export all the configuration data within the Graph Explorer local database. This will not store any data from the connected graph databases. However, the export may contain the shape of the schema for your databases and the connection URL. - **Load Configuration:** This action will replace all the Graph Explorer configuration data you currently have with the data in the provided configuration file. This is a destructive act and can not be undone. It is **strongly** suggested that you perform a **Save Configuration** action before performing a **Load Configuration** action to preserve any existing configuration data. +## Styles + +The Styles settings page lets you manage your styling configuration — the colors, shapes, icons, and other visual properties applied to vertex and edge types in the graph view. + +Graph Explorer uses a three-layer cascade to determine how each type looks: + +1. **Custom styles** (highest priority) — per-type edits you make in the style dialogs +2. **Imported defaults** — loaded from a file via this settings page +3. **App defaults** — the built-in fallback styles + +When you customize a type in the style dialog, that change only affects your custom layer. Imported defaults fill in where you haven't customized. + +### Export styles + +Saves your current effective styles (custom + imported, merged) to `graph-explorer-styles.json`. Re-importing this file on another machine or browser reproduces your full visual configuration. + +### Import default styles + +Loads a styling file and merges it into your imported defaults. Your custom styles are not affected — they continue to take priority. If the file contains types that already have imported defaults, you'll see a confirmation listing the types that will be replaced. Types not in the file are left unchanged. + +The import is resilient to partially-invalid files: valid entries are imported and invalid fields are skipped with a warning report. Icons must be Lucide references or base64-encoded data URIs; remote URLs are not imported. + +### Reset custom styles + +Clears all your per-type style customizations. After this, types will show their imported defaults (if any) or the app defaults. This cannot be undone — consider exporting first. + +### Reset imported defaults + +Removes all imported default styles. Your custom styles remain unaffected. The page shows an indicator of how many types currently have imported defaults loaded. + ## About In the _About_ page you can see the version number and submit any feedback. diff --git a/package.json b/package.json index 0d773f061..2fb402569 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,9 @@ "build": "pnpm --stream -r run build", "dev": "pnpm --stream -r run dev" }, + "dependencies": { + "dompurify": "^3.4.11" + }, "devDependencies": { "@tanstack/eslint-plugin-query": "5.101.0", "@types/node": "^24.13.1", diff --git a/packages/graph-explorer/package.json b/packages/graph-explorer/package.json index 38ea75307..08e29ac44 100644 --- a/packages/graph-explorer/package.json +++ b/packages/graph-explorer/package.json @@ -31,6 +31,7 @@ "cytoscape-klay": "^3.1.4", "date-fns": "^4.4.0", "dedent": "^1.7.2", + "dompurify": "^3.4.11", "file-saver": "^2.0.5", "flat": "^6.0.1", "jotai": "^2.20.1", diff --git a/packages/graph-explorer/src/App.tsx b/packages/graph-explorer/src/App.tsx index 738a81d43..1362fae0d 100644 --- a/packages/graph-explorer/src/App.tsx +++ b/packages/graph-explorer/src/App.tsx @@ -10,6 +10,7 @@ import { SettingsAbout, SettingsGeneral, SettingsRoot, + SettingsStyles, } from "./routes/Settings"; export default function App() { @@ -23,6 +24,7 @@ export default function App() { } /> }> } /> + } /> } /> } /> diff --git a/packages/graph-explorer/src/components/VertexIcon.tsx b/packages/graph-explorer/src/components/VertexIcon.tsx index 18b58222b..f3baf8049 100644 --- a/packages/graph-explorer/src/components/VertexIcon.tsx +++ b/packages/graph-explorer/src/components/VertexIcon.tsx @@ -1,5 +1,6 @@ import type { CSSProperties } from "react"; +import DOMPurify from "dompurify"; import { DynamicIcon } from "lucide-react/dynamic"; import SVG from "react-inlinesvg"; @@ -13,6 +14,12 @@ import { getLucideName, isValidLucideIconName } from "@/utils/lucideIcons"; import { SearchResultSymbol } from "./SearchResult"; +function sanitizeSvg(svg: string): string { + return DOMPurify.sanitize(svg, { + USE_PROFILES: { svg: true, svgFilters: true }, + }); +} + interface Props { vertexStyle: VertexPreferences; className?: string; @@ -43,6 +50,7 @@ function VertexIcon({ vertexStyle, className, alt }: Props) { return ( --styles` set - * (imported, server, base layers land in later work). + * marks the user-defined layer of the `--styles` set. */ atomWithLocalForage( "user-vertex-styles", new Map(), reconcileMapByKey, ), - /** User-defined edge style overrides, keyed by type. See above. */ + /** User-defined edge style overrides, keyed by type. */ atomWithLocalForage( "user-edge-styles", new Map(), reconcileMapByKey, ), + /** Imported default vertex styles, loaded from a styling export file. */ + atomWithLocalForage( + "imported-vertex-styles", + new Map(), + reconcileMapByKey, + ), + /** Imported default edge styles, loaded from a styling export file. */ + atomWithLocalForage( + "imported-edge-styles", + new Map(), + reconcileMapByKey, + ), atomWithLocalForage("graph-view-layout", defaultGraphViewLayout), /** Stores the graph session data for each connection. */ atomWithLocalForage>( @@ -127,6 +140,8 @@ export { schemaAtom, userVertexStylesAtom, userEdgeStylesAtom, + importedVertexStylesAtom, + importedEdgeStylesAtom, graphViewLayoutAtom, allGraphSessionsAtom, showDebugActionsAtom, diff --git a/packages/graph-explorer/src/core/StateProvider/userPreferences.ts b/packages/graph-explorer/src/core/StateProvider/userPreferences.ts index 80f25c6f0..dff118f9b 100644 --- a/packages/graph-explorer/src/core/StateProvider/userPreferences.ts +++ b/packages/graph-explorer/src/core/StateProvider/userPreferences.ts @@ -10,46 +10,58 @@ import DEFAULT_ICON_URL from "@/utils/defaultIconUrl"; import type { EdgeType, VertexType } from "../entities"; import { useActiveSchema } from "./schema"; -import { userEdgeStylesAtom, userVertexStylesAtom } from "./storageAtoms"; - -export type ShapeStyle = - | "rectangle" - | "roundrectangle" - | "ellipse" - | "triangle" - | "pentagon" - | "hexagon" - | "heptagon" - | "octagon" - | "star" - | "barrel" - | "diamond" - | "vee" - | "rhomboid" - | "tag" - | "round-rectangle" - | "round-triangle" - | "round-diamond" - | "round-pentagon" - | "round-hexagon" - | "round-heptagon" - | "round-octagon" - | "round-tag" - | "cut-rectangle" - | "concave-hexagon"; -export type LineStyle = "solid" | "dashed" | "dotted"; -export type ArrowStyle = - | "triangle" - | "triangle-tee" - | "circle-triangle" - | "triangle-cross" - | "triangle-backcurve" - | "tee" - | "vee" - | "square" - | "circle" - | "diamond" - | "none"; +import { + importedEdgeStylesAtom, + importedVertexStylesAtom, + userEdgeStylesAtom, + userVertexStylesAtom, +} from "./storageAtoms"; + +export const SHAPE_STYLES = [ + "rectangle", + "roundrectangle", + "ellipse", + "triangle", + "pentagon", + "hexagon", + "heptagon", + "octagon", + "star", + "barrel", + "diamond", + "vee", + "rhomboid", + "tag", + "round-rectangle", + "round-triangle", + "round-diamond", + "round-pentagon", + "round-hexagon", + "round-heptagon", + "round-octagon", + "round-tag", + "cut-rectangle", + "concave-hexagon", +] as const; +export type ShapeStyle = (typeof SHAPE_STYLES)[number]; + +export const LINE_STYLES = ["solid", "dashed", "dotted"] as const; +export type LineStyle = (typeof LINE_STYLES)[number]; + +export const ARROW_STYLES = [ + "triangle", + "triangle-tee", + "circle-triangle", + "triangle-cross", + "triangle-backcurve", + "tee", + "vee", + "square", + "circle", + "diamond", + "none", +] as const; +export type ArrowStyle = (typeof ARROW_STYLES)[number]; /** The user preferences to be used for the specified vertex type as the type used for storing in local storage. */ export type VertexPreferencesStorageModel = { @@ -164,48 +176,83 @@ export type LegacyUserStylingStorageModel = { edges?: Array; }; -/** Vertex preferences indexed by type for O(1) lookup with default fallback. */ +/** + * The styles cascade — the single place vertex/edge appearance is assembled. + * + * Three layers, lowest precedence first: + * 1. App defaults — {@link defaultVertexPreferences} / {@link defaultEdgePreferences}. + * 2. Imported defaults — `importedVertexStylesAtom` / `importedEdgeStylesAtom` + * (storage keys `imported-vertex-styles` / `imported-edge-styles`), loaded + * from a styling export file on the Settings → Styles page. + * 3. User customizations — `userVertexStylesAtom` / `userEdgeStylesAtom` + * (storage keys `user-vertex-styles` / `user-edge-styles`), edits made in the + * style dialogs. + * + * Higher layers override lower ones per field (see {@link createVertexPreference} + * / {@link createEdgePreference}). Related modules: `core/styling` (the salvaging + * import parser and import/export hooks) and `core/fileEnvelope` (the file + * wrapper). The `icon`↔`iconUrl` rename happens at the file-format seam in the + * styling parser, not here. + */ + +/** Vertex preferences indexed by type for O(1) lookup with cascade fallback. + * Cascade: user > imported > app defaults. */ export const vertexPreferencesAtom = atom(get => { - const vertexStyles = get(userVertexStylesAtom); + const userStyles = get(userVertexStylesAtom); + const importedStyles = get(importedVertexStylesAtom); return { get(type: VertexType) { - return createVertexPreference(type, vertexStyles.get(type)); + return createVertexPreference( + type, + importedStyles.get(type), + userStyles.get(type), + ); }, }; }); -/** Edge preferences indexed by type for O(1) lookup with default fallback. */ +/** Edge preferences indexed by type for O(1) lookup with cascade fallback. + * Cascade: user > imported > app defaults. */ export const edgePreferencesAtom = atom(get => { - const edgeStyles = get(userEdgeStylesAtom); + const userStyles = get(userEdgeStylesAtom); + const importedStyles = get(importedEdgeStylesAtom); return { get(type: EdgeType) { - return createEdgePreference(type, edgeStyles.get(type)); + return createEdgePreference( + type, + importedStyles.get(type), + userStyles.get(type), + ); }, }; }); -/** Combines the stored user preferences with the defined default values. */ +/** Combines the cascade layers: app defaults < imported < user. */ export function createVertexPreference( type: VertexType, - stored?: VertexPreferencesStorageModel, + imported?: VertexPreferencesStorageModel, + user?: VertexPreferencesStorageModel, ): VertexPreferences { return { type, ...defaultVertexPreferences, - ...stored, + ...imported, + ...user, } as const; } -/** Combines the stored user preferences with the defined default values. */ +/** Combines the cascade layers: app defaults < imported < user. */ export function createEdgePreference( type: EdgeType, - stored?: EdgePreferencesStorageModel, -) { + imported?: EdgePreferencesStorageModel, + user?: EdgePreferencesStorageModel, +): EdgePreferences { return { type, ...defaultEdgePreferences, - ...stored, - }; + ...imported, + ...user, + } as const; } /** Returns an array of vertex preferences based on the known vertex types in the schema. */ diff --git a/packages/graph-explorer/src/core/fileEnvelope/fileEnvelope.test.ts b/packages/graph-explorer/src/core/fileEnvelope/fileEnvelope.test.ts new file mode 100644 index 000000000..52d07302e --- /dev/null +++ b/packages/graph-explorer/src/core/fileEnvelope/fileEnvelope.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from "vitest"; + +import { + createFileEnvelope, + parseFileEnvelope, + type FileEnvelope, +} from "./fileEnvelope"; + +describe("createFileEnvelope", () => { + test("stamps metadata with kind, version, and payload", () => { + const data = { vertices: {}, edges: {} }; + const envelope = createFileEnvelope("styling-export", "1.0", data); + + expect(envelope.meta.kind).toBe("styling-export"); + expect(envelope.meta.version).toBe("1.0"); + expect(envelope.meta.source).toBe("Graph Explorer"); + expect(envelope.meta.sourceVersion).toBe(__GRAPH_EXP_VERSION__); + expect(envelope.meta.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(envelope.data).toBe(data); + }); +}); + +describe("parseFileEnvelope", () => { + test("parses a valid envelope and returns meta + data", () => { + const envelope: FileEnvelope<{ foo: string }> = createFileEnvelope( + "styling-export", + "1.0", + { foo: "bar" }, + ); + const json = JSON.stringify(envelope); + const blob = new Blob([json], { type: "application/json" }); + + return expect(parseFileEnvelope(blob)).resolves.toStrictEqual({ + meta: envelope.meta, + data: { foo: "bar" }, + }); + }); + + test("preserves unknown meta fields (forward-compat)", () => { + const envelope = { + meta: { + kind: "styling-export", + version: "2.0", + timestamp: "2026-06-24T00:00:00.000Z", + source: "Graph Explorer", + sourceVersion: "9.9.9", + exportedBy: "future-field", + }, + data: { hello: "world" }, + }; + const blob = new Blob([JSON.stringify(envelope)], { + type: "application/json", + }); + + return expect(parseFileEnvelope(blob)).resolves.toStrictEqual({ + meta: envelope.meta, + data: { hello: "world" }, + }); + }); + + test("throws for invalid JSON", () => { + const blob = new Blob(["not json"], { type: "application/json" }); + return expect(parseFileEnvelope(blob)).rejects.toThrow( + "File is not valid JSON", + ); + }); + + test("throws when meta is missing", () => { + const blob = new Blob([JSON.stringify({ data: {} })], { + type: "application/json", + }); + return expect(parseFileEnvelope(blob)).rejects.toThrow( + "envelope structure", + ); + }); + + test("throws when meta.kind is missing", () => { + const blob = new Blob( + [ + JSON.stringify({ + meta: { + version: "1.0", + timestamp: "x", + source: "x", + sourceVersion: "x", + }, + data: {}, + }), + ], + { type: "application/json" }, + ); + return expect(parseFileEnvelope(blob)).rejects.toThrow( + "envelope structure", + ); + }); + + test("throws when data is missing", () => { + const blob = new Blob( + [ + JSON.stringify({ + meta: { + kind: "styling-export", + version: "1.0", + timestamp: "x", + source: "x", + sourceVersion: "x", + }, + }), + ], + { type: "application/json" }, + ); + return expect(parseFileEnvelope(blob)).rejects.toThrow( + "envelope structure", + ); + }); + + test("parses unknown version on a best-effort basis (forward-compat)", async () => { + const blob = new Blob( + [ + JSON.stringify({ + meta: { + kind: "styling-export", + version: "99.0", + timestamp: "x", + source: "x", + sourceVersion: "x", + }, + data: { vertices: {}, edges: {} }, + }), + ], + { type: "application/json" }, + ); + const result = await parseFileEnvelope(blob); + expect(result.meta.version).toBe("99.0"); + expect(result.data).toStrictEqual({ vertices: {}, edges: {} }); + }); +}); diff --git a/packages/graph-explorer/src/core/fileEnvelope/fileEnvelope.ts b/packages/graph-explorer/src/core/fileEnvelope/fileEnvelope.ts new file mode 100644 index 000000000..400a5fc24 --- /dev/null +++ b/packages/graph-explorer/src/core/fileEnvelope/fileEnvelope.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; + +import { LABELS } from "@/utils/constants"; + +const fileEnvelopeMetaSchema = z.looseObject({ + kind: z.string(), + version: z.string(), + timestamp: z.string(), + source: z.string(), + sourceVersion: z.string(), +}); + +const fileEnvelopeSchema = z.object({ + meta: fileEnvelopeMetaSchema, + data: z.unknown(), +}); + +export type FileEnvelopeMeta = z.infer; + +export type FileEnvelope = { + meta: FileEnvelopeMeta; + data: T; +}; + +export type ParsedEnvelope = z.infer; + +export class FileEnvelopeError extends Error { + readonly issues: z.core.$ZodIssue[] | undefined; + + constructor(message: string, issues?: z.core.$ZodIssue[]) { + super(message); + this.name = "FileEnvelopeError"; + this.issues = issues; + } +} + +export function createFileEnvelope( + kind: string, + version: string, + data: T, +): FileEnvelope { + return { + meta: { + kind, + version, + timestamp: new Date().toISOString(), + source: LABELS.APP_NAME, + sourceVersion: __GRAPH_EXP_VERSION__, + }, + data, + }; +} + +export async function parseFileEnvelope(blob: Blob): Promise { + let json: unknown; + try { + const text = await blob.text(); + json = JSON.parse(text); + } catch { + throw new FileEnvelopeError("File is not valid JSON"); + } + + const result = fileEnvelopeSchema.safeParse(json); + if (!result.success) { + throw new FileEnvelopeError( + "File does not have the expected envelope structure", + result.error.issues, + ); + } + + return result.data; +} diff --git a/packages/graph-explorer/src/core/fileEnvelope/index.ts b/packages/graph-explorer/src/core/fileEnvelope/index.ts new file mode 100644 index 000000000..4a69b7e71 --- /dev/null +++ b/packages/graph-explorer/src/core/fileEnvelope/index.ts @@ -0,0 +1,8 @@ +export { + createFileEnvelope, + FileEnvelopeError, + parseFileEnvelope, + type FileEnvelope, + type FileEnvelopeMeta, + type ParsedEnvelope, +} from "./fileEnvelope"; diff --git a/packages/graph-explorer/src/core/styling/index.ts b/packages/graph-explorer/src/core/styling/index.ts new file mode 100644 index 000000000..aa960608e --- /dev/null +++ b/packages/graph-explorer/src/core/styling/index.ts @@ -0,0 +1,14 @@ +export { + parseStylingPayload, + StylingParseError, + toFileEntry, + type ImportIssue, + type StylingExportPayload, + type StylingParseResult, + type VertexStyleFileEntry, +} from "./stylingParser"; +export { + useImportStylingFile, + useExportStylingFile, + type ImportConflicts, +} from "./useStylingImportExport"; diff --git a/packages/graph-explorer/src/core/styling/roundTrip.test.ts b/packages/graph-explorer/src/core/styling/roundTrip.test.ts new file mode 100644 index 000000000..0fef8eae4 --- /dev/null +++ b/packages/graph-explorer/src/core/styling/roundTrip.test.ts @@ -0,0 +1,561 @@ +// @vitest-environment happy-dom +import { describe, expect, test, vi } from "vitest"; + +import type { VertexType, EdgeType } from "@/core/entities"; +import type { + VertexPreferencesStorageModel, + EdgePreferencesStorageModel, +} from "@/core/StateProvider/userPreferences"; + +import { getAppStore } from "@/core"; +import { createEdgeType, createVertexType } from "@/core/entities"; +import { createFileEnvelope } from "@/core/fileEnvelope"; +import { + importedEdgeStylesAtom, + importedVertexStylesAtom, + userEdgeStylesAtom, + userVertexStylesAtom, +} from "@/core/StateProvider/storageAtoms"; +import { renderHookWithJotai } from "@/utils/testing"; + +import { + useExportStylingFile, + useImportStylingFile, +} from "./useStylingImportExport"; + +vi.mock("@/utils/fileData", () => ({ + toJsonFileData: vi.fn(), + fromFileToJson: vi.fn(), + saveFile: vi.fn(), +})); + +describe("round-trip: export then import", () => { + test("property graph types with lucide icons survive round-trip", async () => { + const store = getAppStore(); + store.set( + userVertexStylesAtom, + new Map([ + [ + createVertexType("airport"), + { + type: createVertexType("airport"), + displayNameAttribute: "code", + iconUrl: "lucide:anchor", + iconImageType: "image/svg+xml", + color: "#e66412", + }, + ], + [ + createVertexType("country"), + { + type: createVertexType("country"), + color: "#e612b8", + }, + ], + ]), + ); + store.set( + userEdgeStylesAtom, + new Map([ + [ + createEdgeType("route"), + { + type: createEdgeType("route"), + lineThickness: 1, + labelColor: "#eef4ff", + labelBackgroundOpacity: 1, + lineColor: "#0c4a6e", + }, + ], + ]), + ); + + const { result: exportResult } = renderHookWithJotai(() => + useExportStylingFile(), + ); + const payload = exportResult.current.getExportPayload(); + + expect(payload.vertices["airport"]).toStrictEqual({ + displayNameAttribute: "code", + icon: "lucide:anchor", + iconImageType: "image/svg+xml", + color: "#e66412", + }); + expect(payload.vertices["country"]).toStrictEqual({ + color: "#e612b8", + }); + expect(payload.edges["route"]).toStrictEqual({ + lineThickness: 1, + labelColor: "#eef4ff", + labelBackgroundOpacity: 1, + lineColor: "#0c4a6e", + }); + + const file = envelopeToFile(payload); + + store.set(userVertexStylesAtom, new Map()); + store.set(userEdgeStylesAtom, new Map()); + + const { result: importResult } = renderHookWithJotai(() => + useImportStylingFile(), + ); + const parseOut = await importResult.current.parseFile(file); + + importResult.current.applyImport(parseOut); + + expect(parseOut.issues).toStrictEqual([]); + + const importedVertices = store.get(importedVertexStylesAtom); + expect(importedVertices.get(createVertexType("airport"))).toStrictEqual({ + type: createVertexType("airport"), + displayNameAttribute: "code", + iconUrl: "lucide:anchor", + iconImageType: "image/svg+xml", + color: "#e66412", + }); + expect(importedVertices.get(createVertexType("country"))).toStrictEqual({ + type: createVertexType("country"), + color: "#e612b8", + }); + + const importedEdges = store.get(importedEdgeStylesAtom); + expect(importedEdges.get(createEdgeType("route"))).toStrictEqual({ + type: createEdgeType("route"), + lineThickness: 1, + labelColor: "#eef4ff", + labelBackgroundOpacity: 1, + lineColor: "#0c4a6e", + }); + }); + + test("RDF URI vertex types survive round-trip", async () => { + const store = getAppStore(); + store.set( + userVertexStylesAtom, + new Map([ + [ + createVertexType("http://data.nobelprize.org/terms/LaureateAward"), + { + type: createVertexType( + "http://data.nobelprize.org/terms/LaureateAward", + ), + iconUrl: "lucide:alarm-clock-check", + iconImageType: "image/svg+xml", + color: "#1229e6", + }, + ], + [ + createVertexType("http://data.nobelprize.org/terms/Laureate"), + { + type: createVertexType("http://data.nobelprize.org/terms/Laureate"), + iconUrl: "lucide:angry", + iconImageType: "image/svg+xml", + color: "#ba12e6", + }, + ], + [ + createVertexType("http://www.w3.org/ns/dcat#Catalog"), + { + type: createVertexType("http://www.w3.org/ns/dcat#Catalog"), + iconUrl: "lucide:alert-octagon", + iconImageType: "image/svg+xml", + }, + ], + [ + createVertexType("http://dbpedia.org/ontology/Award"), + { + type: createVertexType("http://dbpedia.org/ontology/Award"), + displayNameAttribute: "http://www.w3.org/2000/01/rdf-schema#label", + }, + ], + ]), + ); + + const { result: exportResult } = renderHookWithJotai(() => + useExportStylingFile(), + ); + const payload = exportResult.current.getExportPayload(); + const file = envelopeToFile(payload); + + store.set(userVertexStylesAtom, new Map()); + + const { result: importResult } = renderHookWithJotai(() => + useImportStylingFile(), + ); + const parseOut = await importResult.current.parseFile(file); + + importResult.current.applyImport(parseOut); + + expect(parseOut.issues).toStrictEqual([]); + + const imported = store.get(importedVertexStylesAtom); + expect( + imported.get( + createVertexType("http://data.nobelprize.org/terms/LaureateAward"), + ), + ).toStrictEqual({ + type: createVertexType("http://data.nobelprize.org/terms/LaureateAward"), + iconUrl: "lucide:alarm-clock-check", + iconImageType: "image/svg+xml", + color: "#1229e6", + }); + expect( + imported.get( + createVertexType("http://data.nobelprize.org/terms/Laureate"), + ), + ).toStrictEqual({ + type: createVertexType("http://data.nobelprize.org/terms/Laureate"), + iconUrl: "lucide:angry", + iconImageType: "image/svg+xml", + color: "#ba12e6", + }); + expect( + imported.get(createVertexType("http://www.w3.org/ns/dcat#Catalog")), + ).toStrictEqual({ + type: createVertexType("http://www.w3.org/ns/dcat#Catalog"), + iconUrl: "lucide:alert-octagon", + iconImageType: "image/svg+xml", + }); + expect( + imported.get(createVertexType("http://dbpedia.org/ontology/Award")), + ).toStrictEqual({ + type: createVertexType("http://dbpedia.org/ontology/Award"), + displayNameAttribute: "http://www.w3.org/2000/01/rdf-schema#label", + }); + }); + + test("SVG base64 data URI icons survive round-trip", async () => { + const svgDataUri = + "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg=="; + + const store = getAppStore(); + store.set( + userVertexStylesAtom, + new Map([ + [ + createVertexType("CustomNode"), + { + type: createVertexType("CustomNode"), + iconUrl: svgDataUri, + iconImageType: "image/svg+xml", + color: "#333", + }, + ], + ]), + ); + + const { result: exportResult } = renderHookWithJotai(() => + useExportStylingFile(), + ); + const payload = exportResult.current.getExportPayload(); + + expect(payload.vertices["CustomNode"].icon).toBe(svgDataUri); + + const file = envelopeToFile(payload); + store.set(userVertexStylesAtom, new Map()); + + const { result: importResult } = renderHookWithJotai(() => + useImportStylingFile(), + ); + const parseOut = await importResult.current.parseFile(file); + + importResult.current.applyImport(parseOut); + + expect(parseOut.issues).toStrictEqual([]); + + const imported = store.get(importedVertexStylesAtom); + expect(imported.get(createVertexType("CustomNode"))!.iconUrl).toBe( + svgDataUri, + ); + }); + + test("raster image base64 data URI icons survive round-trip", async () => { + const pngDataUri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"; + const jpegDataUri = "data:image/jpeg;base64,/9j/4AAQSkZJRg=="; + + const store = getAppStore(); + store.set( + userVertexStylesAtom, + new Map([ + [ + createVertexType("PngNode"), + { + type: createVertexType("PngNode"), + iconUrl: pngDataUri, + iconImageType: "image/png", + color: "#111", + }, + ], + [ + createVertexType("JpegNode"), + { + type: createVertexType("JpegNode"), + iconUrl: jpegDataUri, + iconImageType: "image/jpeg", + color: "#222", + }, + ], + ]), + ); + + const { result: exportResult } = renderHookWithJotai(() => + useExportStylingFile(), + ); + const payload = exportResult.current.getExportPayload(); + const file = envelopeToFile(payload); + + store.set(userVertexStylesAtom, new Map()); + + const { result: importResult } = renderHookWithJotai(() => + useImportStylingFile(), + ); + const parseOut = await importResult.current.parseFile(file); + + importResult.current.applyImport(parseOut); + + expect(parseOut.issues).toStrictEqual([]); + + const imported = store.get(importedVertexStylesAtom); + expect(imported.get(createVertexType("PngNode"))).toStrictEqual({ + type: createVertexType("PngNode"), + iconUrl: pngDataUri, + iconImageType: "image/png", + color: "#111", + }); + expect(imported.get(createVertexType("JpegNode"))).toStrictEqual({ + type: createVertexType("JpegNode"), + iconUrl: jpegDataUri, + iconImageType: "image/jpeg", + color: "#222", + }); + }); + + test("HTTP/HTTPS URL icons are dropped on import (no outbound requests)", async () => { + const httpsUrl = "https://cdn.example.com/icons/airport.png"; + const httpUrl = "http://internal.corp/icon.svg"; + + const store = getAppStore(); + store.set( + userVertexStylesAtom, + new Map([ + [ + createVertexType("HttpsNode"), + { + type: createVertexType("HttpsNode"), + iconUrl: httpsUrl, + iconImageType: "image/png", + }, + ], + [ + createVertexType("HttpNode"), + { + type: createVertexType("HttpNode"), + iconUrl: httpUrl, + iconImageType: "image/svg+xml", + }, + ], + ]), + ); + + const { result: exportResult } = renderHookWithJotai(() => + useExportStylingFile(), + ); + const payload = exportResult.current.getExportPayload(); + const file = envelopeToFile(payload); + + store.set(userVertexStylesAtom, new Map()); + + const { result: importResult } = renderHookWithJotai(() => + useImportStylingFile(), + ); + const parseOut = await importResult.current.parseFile(file); + + importResult.current.applyImport(parseOut); + + // Both icons fail the allowlist, so each entry's only field is dropped and + // the icon issue is reported. iconImageType still survives for HttpsNode. + expect(parseOut.issues.map(i => i.field)).toStrictEqual(["icon", "icon"]); + + const imported = store.get(importedVertexStylesAtom); + expect( + imported.get(createVertexType("HttpsNode"))?.iconUrl, + ).toBeUndefined(); + expect(imported.get(createVertexType("HttpNode"))?.iconUrl).toBeUndefined(); + }); + + test("mixed icon types in a single export all survive round-trip", async () => { + const store = getAppStore(); + store.set( + userVertexStylesAtom, + new Map([ + [ + createVertexType("LucideType"), + { + type: createVertexType("LucideType"), + iconUrl: "lucide:user", + iconImageType: "image/svg+xml", + }, + ], + [ + createVertexType("SvgDataType"), + { + type: createVertexType("SvgDataType"), + iconUrl: "data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=", + iconImageType: "image/svg+xml", + }, + ], + [ + createVertexType("PngDataType"), + { + type: createVertexType("PngDataType"), + iconUrl: "data:image/png;base64,iVBORw0KGgo=", + iconImageType: "image/png", + }, + ], + [ + createVertexType("NoIconType"), + { + type: createVertexType("NoIconType"), + color: "#abc", + shape: "diamond", + }, + ], + ]), + ); + + const { result: exportResult } = renderHookWithJotai(() => + useExportStylingFile(), + ); + const payload = exportResult.current.getExportPayload(); + const file = envelopeToFile(payload); + + store.set(userVertexStylesAtom, new Map()); + + const { result: importResult } = renderHookWithJotai(() => + useImportStylingFile(), + ); + const parseOut = await importResult.current.parseFile(file); + + importResult.current.applyImport(parseOut); + + expect(parseOut.issues).toStrictEqual([]); + + const imported = store.get(importedVertexStylesAtom); + expect(imported.get(createVertexType("LucideType"))!.iconUrl).toBe( + "lucide:user", + ); + expect(imported.get(createVertexType("SvgDataType"))!.iconUrl).toBe( + "data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=", + ); + expect(imported.get(createVertexType("PngDataType"))!.iconUrl).toBe( + "data:image/png;base64,iVBORw0KGgo=", + ); + expect(imported.get(createVertexType("NoIconType"))).toStrictEqual({ + type: createVertexType("NoIconType"), + color: "#abc", + shape: "diamond", + }); + }); + + test("importing the exact file from the bug report succeeds without issues", async () => { + const fileContent = JSON.stringify({ + meta: { + kind: "styling-export", + version: "1.0", + timestamp: "2026-06-25T14:35:09.186Z", + source: "Graph Explorer", + sourceVersion: "3.1.0", + }, + data: { + vertices: { + "http://data.nobelprize.org/terms/LaureateAward": { + icon: "lucide:alarm-clock-check", + iconImageType: "image/svg+xml", + color: "#1229e6", + }, + "http://data.nobelprize.org/terms/Laureate": { + icon: "lucide:angry", + iconImageType: "image/svg+xml", + color: "#ba12e6", + }, + "http://www.w3.org/ns/dcat#Catalog": { + icon: "lucide:alert-octagon", + iconImageType: "image/svg+xml", + }, + airport: { + displayNameAttribute: "code", + icon: "lucide:anchor", + iconImageType: "image/svg+xml", + color: "#e66412", + }, + "http://dbpedia.org/ontology/Award": { + displayNameAttribute: "http://www.w3.org/2000/01/rdf-schema#label", + }, + country: { color: "#e612b8" }, + }, + edges: { + route: { + lineThickness: 1, + labelColor: "#eef4ff", + labelBackgroundOpacity: 1, + lineColor: "#0c4a6e", + }, + }, + }, + }); + + const file = new File([fileContent], "graph-explorer-styles.json", { + type: "application/json", + }); + + const { result } = renderHookWithJotai(() => useImportStylingFile()); + const parseOut = await result.current.parseFile(file); + + result.current.applyImport(parseOut); + + expect(parseOut.issues).toStrictEqual([]); + + const store = getAppStore(); + const imported = store.get(importedVertexStylesAtom); + + expect( + imported.get( + createVertexType("http://data.nobelprize.org/terms/LaureateAward"), + ), + ).toStrictEqual({ + type: createVertexType("http://data.nobelprize.org/terms/LaureateAward"), + iconUrl: "lucide:alarm-clock-check", + iconImageType: "image/svg+xml", + color: "#1229e6", + }); + + expect(imported.get(createVertexType("airport"))).toStrictEqual({ + type: createVertexType("airport"), + displayNameAttribute: "code", + iconUrl: "lucide:anchor", + iconImageType: "image/svg+xml", + color: "#e66412", + }); + + expect(imported.get(createVertexType("country"))).toStrictEqual({ + type: createVertexType("country"), + color: "#e612b8", + }); + + const importedEdges = store.get(importedEdgeStylesAtom); + expect(importedEdges.get(createEdgeType("route"))).toStrictEqual({ + type: createEdgeType("route"), + lineThickness: 1, + labelColor: "#eef4ff", + labelBackgroundOpacity: 1, + lineColor: "#0c4a6e", + }); + }); +}); + +function envelopeToFile(payload: unknown): File { + const envelope = createFileEnvelope("styling-export", "1.0", payload); + return new File([JSON.stringify(envelope)], "styles.json", { + type: "application/json", + }); +} diff --git a/packages/graph-explorer/src/core/styling/stylingParser.test.ts b/packages/graph-explorer/src/core/styling/stylingParser.test.ts new file mode 100644 index 000000000..13b9a039e --- /dev/null +++ b/packages/graph-explorer/src/core/styling/stylingParser.test.ts @@ -0,0 +1,504 @@ +import { describe, expect, test } from "vitest"; + +import { createVertexType, createEdgeType } from "@/core/entities"; + +import { parseStylingPayload } from "./stylingParser"; + +describe("style enum validation", () => { + const SHAPES = [ + "rectangle", + "roundrectangle", + "ellipse", + "triangle", + "pentagon", + "hexagon", + "heptagon", + "octagon", + "star", + "barrel", + "diamond", + "vee", + "rhomboid", + "tag", + "round-rectangle", + "round-triangle", + "round-diamond", + "round-pentagon", + "round-hexagon", + "round-heptagon", + "round-octagon", + "round-tag", + "cut-rectangle", + "concave-hexagon", + ]; + const LINE_STYLES = ["solid", "dashed", "dotted"]; + const ARROW_STYLES = [ + "triangle", + "triangle-tee", + "circle-triangle", + "triangle-cross", + "triangle-backcurve", + "tee", + "vee", + "square", + "circle", + "diamond", + "none", + ]; + + test.each(SHAPES)("accepts shape %s", shape => { + const result = parseStylingPayload({ + vertices: { A: { shape } }, + edges: {}, + }); + expect(result.vertexStyles.get(createVertexType("A"))!.shape).toBe(shape); + expect(result.issues).toHaveLength(0); + }); + + test("rejects an unknown shape", () => { + const result = parseStylingPayload({ + vertices: { A: { shape: "blob" } }, + edges: {}, + }); + expect(result.vertexStyles.has(createVertexType("A"))).toBe(false); + expect(result.issues[0]).toMatchObject({ field: "shape" }); + }); + + test.each(LINE_STYLES)("accepts border line style %s", borderStyle => { + const result = parseStylingPayload({ + vertices: { A: { borderStyle } }, + edges: {}, + }); + expect(result.vertexStyles.get(createVertexType("A"))!.borderStyle).toBe( + borderStyle, + ); + expect(result.issues).toHaveLength(0); + }); + + test.each(ARROW_STYLES)("accepts arrow style %s", sourceArrowStyle => { + const result = parseStylingPayload({ + vertices: {}, + edges: { A: { sourceArrowStyle } }, + }); + expect(result.edgeStyles.get(createEdgeType("A"))!.sourceArrowStyle).toBe( + sourceArrowStyle, + ); + expect(result.issues).toHaveLength(0); + }); + + test("rejects an unknown arrow style", () => { + const result = parseStylingPayload({ + vertices: {}, + edges: { A: { sourceArrowStyle: "swoosh" } }, + }); + expect(result.edgeStyles.has(createEdgeType("A"))).toBe(false); + expect(result.issues[0]).toMatchObject({ field: "sourceArrowStyle" }); + }); +}); + +describe("parseStylingPayload", () => { + test("throws for non-object input", () => { + expect(() => parseStylingPayload(null)).toThrow(); + expect(() => parseStylingPayload("string")).toThrow(); + expect(() => parseStylingPayload(42)).toThrow(); + expect(() => parseStylingPayload(undefined)).toThrow(); + }); + + test("throws when vertices/edges keys are missing", () => { + expect(() => parseStylingPayload({})).toThrow(); + expect(() => parseStylingPayload({ vertices: {} })).toThrow(); + expect(() => parseStylingPayload({ edges: {} })).toThrow(); + }); + + test("throws when vertices is not an object", () => { + expect(() => parseStylingPayload({ vertices: [], edges: {} })).toThrow(); + }); + + test("throws when edges is not an object", () => { + expect(() => parseStylingPayload({ vertices: {}, edges: [] })).toThrow(); + }); + + test("parses empty vertices and edges with no issues", () => { + const result = parseStylingPayload({ vertices: {}, edges: {} }); + expect(result).toStrictEqual({ + vertexStyles: new Map(), + edgeStyles: new Map(), + issues: [], + }); + }); + + test("parses valid vertex entry with all fields", () => { + const result = parseStylingPayload({ + vertices: { + Person: { + color: "#ff0000", + displayLabel: "Human", + icon: "lucide:user", + iconImageType: "image/svg+xml", + displayNameAttribute: "name", + longDisplayNameAttribute: "fullName", + shape: "ellipse", + backgroundOpacity: 0.5, + borderWidth: 2, + borderColor: "#000000", + borderStyle: "dashed", + }, + }, + edges: {}, + }); + + expect(result.issues).toStrictEqual([]); + expect(result.vertexStyles.size).toBe(1); + const personStyle = result.vertexStyles.get(createVertexType("Person")); + expect(personStyle).toStrictEqual({ + type: createVertexType("Person"), + color: "#ff0000", + displayLabel: "Human", + iconUrl: "lucide:user", + iconImageType: "image/svg+xml", + displayNameAttribute: "name", + longDisplayNameAttribute: "fullName", + shape: "ellipse", + backgroundOpacity: 0.5, + borderWidth: 2, + borderColor: "#000000", + borderStyle: "dashed", + }); + }); + + test("renames icon field to iconUrl at the storage-model seam", () => { + const result = parseStylingPayload({ + vertices: { Airport: { icon: "data:image/png;base64,iVBOR" } }, + edges: {}, + }); + + const style = result.vertexStyles.get(createVertexType("Airport")); + expect(style!.iconUrl).toBe("data:image/png;base64,iVBOR"); + expect("icon" in style!).toBe(false); + }); + + test("rejects iconUrl as an unknown input field", () => { + // The file format uses `icon`; `iconUrl` is the internal storage name and is + // not an accepted import field. + const result = parseStylingPayload({ + vertices: { Airport: { iconUrl: "lucide:plane" } }, + edges: {}, + }); + + expect(result.vertexStyles.has(createVertexType("Airport"))).toBe(false); + expect(result.issues[0]).toStrictEqual({ + entityType: "vertex", + typeName: "Airport", + field: "iconUrl", + message: expect.stringContaining("unknown field"), + }); + }); + + test("parses valid edge entry with all fields", () => { + const result = parseStylingPayload({ + vertices: {}, + edges: { + knows: { + displayLabel: "Knows", + displayNameAttribute: "since", + labelColor: "#333333", + labelBackgroundOpacity: 0.8, + labelBorderColor: "#444444", + labelBorderStyle: "solid", + labelBorderWidth: 1, + lineColor: "#555555", + lineThickness: 3, + lineStyle: "dotted", + sourceArrowStyle: "none", + targetArrowStyle: "vee", + }, + }, + }); + + expect(result.issues).toStrictEqual([]); + expect(result.edgeStyles.size).toBe(1); + const knowsStyle = result.edgeStyles.get(createEdgeType("knows")); + expect(knowsStyle).toStrictEqual({ + type: createEdgeType("knows"), + displayLabel: "Knows", + displayNameAttribute: "since", + labelColor: "#333333", + labelBackgroundOpacity: 0.8, + labelBorderColor: "#444444", + labelBorderStyle: "solid", + labelBorderWidth: 1, + lineColor: "#555555", + lineThickness: 3, + lineStyle: "dotted", + sourceArrowStyle: "none", + targetArrowStyle: "vee", + }); + }); + + test("drops invalid fields and reports issues", () => { + const result = parseStylingPayload({ + vertices: { + Airport: { + color: "#ff0000", + shape: "invalid-shape", + backgroundOpacity: "not-a-number", + }, + }, + edges: {}, + }); + + const style = result.vertexStyles.get(createVertexType("Airport")); + expect(style).toStrictEqual({ + type: createVertexType("Airport"), + color: "#ff0000", + }); + + expect(result.issues).toStrictEqual([ + { + entityType: "vertex", + typeName: "Airport", + field: "shape", + message: expect.stringContaining("invalid-shape"), + }, + { + entityType: "vertex", + typeName: "Airport", + field: "backgroundOpacity", + message: expect.stringContaining("number"), + }, + ]); + }); + + test("reports unknown fields as issues (typo detection)", () => { + const result = parseStylingPayload({ + vertices: { + Person: { colour: "#ff0000", colr: "blue" }, + }, + edges: {}, + }); + + expect(result.vertexStyles.has(createVertexType("Person"))).toBe(false); + expect(result.issues).toStrictEqual([ + { + entityType: "vertex", + typeName: "Person", + field: "colour", + message: expect.stringContaining("unknown"), + }, + { + entityType: "vertex", + typeName: "Person", + field: "colr", + message: expect.stringContaining("unknown"), + }, + ]); + }); + + test("drops entries with only invalid fields (no valid fields remain)", () => { + const result = parseStylingPayload({ + vertices: { + Ghost: { bogus: "value" }, + }, + edges: {}, + }); + + expect(result.vertexStyles.has(createVertexType("Ghost"))).toBe(false); + expect(result.issues).toHaveLength(1); + }); + + describe("icon allowlist", () => { + test("accepts lucide: prefix with valid name", () => { + const result = parseStylingPayload({ + vertices: { A: { icon: "lucide:arrow-right" } }, + edges: {}, + }); + expect(result.vertexStyles.get(createVertexType("A"))!.iconUrl).toBe( + "lucide:arrow-right", + ); + expect(result.issues).toStrictEqual([]); + }); + + test("accepts data:image/ URIs", () => { + const dataUri = "data:image/svg+xml;base64,PHN2Zz4="; + const result = parseStylingPayload({ + vertices: { A: { icon: dataUri } }, + edges: {}, + }); + expect(result.vertexStyles.get(createVertexType("A"))!.iconUrl).toBe( + dataUri, + ); + }); + + test("rejects http:// URLs (no outbound requests from imports)", () => { + const result = parseStylingPayload({ + vertices: { A: { icon: "http://example.com/icon.png" } }, + edges: {}, + }); + expect(result.vertexStyles.has(createVertexType("A"))).toBe(false); + expect(result.issues[0].field).toBe("icon"); + }); + + test("rejects https:// URLs (no outbound requests from imports)", () => { + const result = parseStylingPayload({ + vertices: { A: { icon: "https://cdn.example.com/icon.svg" } }, + edges: {}, + }); + expect(result.vertexStyles.has(createVertexType("A"))).toBe(false); + expect(result.issues[0].field).toBe("icon"); + }); + + test("rejects bare icon names", () => { + const result = parseStylingPayload({ + vertices: { A: { icon: "user" } }, + edges: {}, + }); + expect(result.vertexStyles.has(createVertexType("A"))).toBe(false); + expect(result.issues[0]).toStrictEqual({ + entityType: "vertex", + typeName: "A", + field: "icon", + message: expect.stringContaining("allowlist"), + }); + }); + + test("rejects javascript: protocol", () => { + const result = parseStylingPayload({ + vertices: { A: { icon: "javascript:alert(1)" } }, + edges: {}, + }); + expect(result.vertexStyles.has(createVertexType("A"))).toBe(false); + expect(result.issues[0].field).toBe("icon"); + }); + + test("rejects lucide: with invalid characters", () => { + const result = parseStylingPayload({ + vertices: { A: { icon: "lucide:
  • + + + Styles + +
  • diff --git a/packages/graph-explorer/src/routes/Settings/SettingsStyles.tsx b/packages/graph-explorer/src/routes/Settings/SettingsStyles.tsx new file mode 100644 index 000000000..0b1feaf14 --- /dev/null +++ b/packages/graph-explorer/src/routes/Settings/SettingsStyles.tsx @@ -0,0 +1,462 @@ +import { useAtomValue, useSetAtom } from "jotai"; +import { + DownloadIcon, + SwatchBookIcon, + Trash2Icon, + TriangleAlertIcon, + UploadIcon, +} from "lucide-react"; +import { useRef, useState } from "react"; + +import type { ImportConflicts, StylingParseResult } from "@/core/styling"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, + Button, + Group, + GroupHeader, + GroupItem, + GroupMedia, + GroupTitle, + LabelledSetting, + SettingsPageDescription, + SettingsPageHeader, + SettingsPageIcon, + SettingsPageTitle, + SettingsPage, +} from "@/components"; +import { createFileEnvelope } from "@/core/fileEnvelope"; +import { + importedEdgeStylesAtom, + importedVertexStylesAtom, + userEdgeStylesAtom, + userVertexStylesAtom, +} from "@/core/StateProvider/storageAtoms"; +import { useExportStylingFile, useImportStylingFile } from "@/core/styling"; +import { logger } from "@/utils"; +import { saveFile, toJsonFileData } from "@/utils/fileData"; + +import StylingImportIssuesDialog, { + type ImportOutcome, +} from "./StylingImportIssuesDialog"; + +export default function SettingsStyles() { + const { parseFile, getConflicts, applyImport } = useImportStylingFile(); + const { getExportPayload } = useExportStylingFile(); + const setUserVertexStyles = useSetAtom(userVertexStylesAtom); + const setUserEdgeStyles = useSetAtom(userEdgeStylesAtom); + const setImportedVertexStyles = useSetAtom(importedVertexStylesAtom); + const setImportedEdgeStyles = useSetAtom(importedEdgeStylesAtom); + const importedVertexStyles = useAtomValue(importedVertexStylesAtom); + const importedEdgeStyles = useAtomValue(importedEdgeStylesAtom); + + const fileInputRef = useRef(null); + const [importOutcome, setImportOutcome] = useState( + null, + ); + const [exportError, setExportError] = useState(null); + const [isBusy, setIsBusy] = useState(false); + const [confirmReset, setConfirmReset] = useState(false); + const [confirmClear, setConfirmClear] = useState(false); + const [pendingImport, setPendingImport] = useState<{ + parsed: StylingParseResult; + conflicts: ImportConflicts; + } | null>(null); + + function handleResetCustomStyles() { + setUserVertexStyles(new Map()); + setUserEdgeStyles(new Map()); + setConfirmReset(false); + } + + function handleClearImportedDefaults() { + setImportedVertexStyles(new Map()); + setImportedEdgeStyles(new Map()); + setConfirmClear(false); + } + + async function handleExport() { + setIsBusy(true); + try { + const payload = getExportPayload(); + const envelope = createFileEnvelope("styling-export", "1.0", payload); + const blob = toJsonFileData(envelope); + await saveFile(blob, "graph-explorer-styles.json"); + } catch (e: unknown) { + if (e instanceof Error && e.name === "AbortError") return; + logger.warn("Export failed", e); + setExportError( + e instanceof Error ? e.message : "The styles file could not be saved.", + ); + } finally { + setIsBusy(false); + } + } + + async function handleFileSelected(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + setIsBusy(true); + try { + const parsed = await parseFile(file); + + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + + const conflicts = getConflicts(parsed); + if (conflicts.vertices.length > 0 || conflicts.edges.length > 0) { + setPendingImport({ parsed, conflicts }); + } else { + const issues = applyImport(parsed); + setImportOutcome({ issues }); + } + } catch (error: unknown) { + const message = + error instanceof Error + ? error.message + : "An unexpected error occurred while importing the file."; + setImportOutcome({ error: message }); + } finally { + setIsBusy(false); + } + } + + function handleConfirmImport() { + if (!pendingImport) return; + const issues = applyImport(pendingImport.parsed); + setPendingImport(null); + setImportOutcome({ issues }); + } + + return ( + + + + + + Styles + + Share your node and edge styling with others, or reset them back to + defaults. Custom styles and imported defaults are tracked separately. + + + + + + Style Sharing + + + + + + + + + + + + + + + + + + + + + Danger Zone + + + + + + + + + + + + + + setConfirmReset(false)} + /> + setConfirmClear(false)} + /> + setPendingImport(null)} + /> + setImportOutcome(null)} + /> + setExportError(null)} + /> + + ); +} + +function ExportFailedDialog({ + error, + onClose, +}: { + error: string | null; + onClose: () => void; +}) { + return ( + !o && onClose()}> + + + + + + Export Failed + + {error ?? "The styles file could not be saved."} + + + + Close + + + + ); +} + +function ConfirmResetDialog({ + open, + onConfirm, + onCancel, +}: { + open: boolean; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( + !o && onCancel()}> + + + + + + Reset All Custom Styles + +

    + This will clear all your per-type style customizations. Your + imported defaults will remain. Consider exporting first. +

    +

    This cannot be undone.

    +
    +
    + + Cancel + + Reset Custom Styles + + +
    +
    + ); +} + +function ConfirmClearDialog({ + open, + onConfirm, + onCancel, +}: { + open: boolean; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( + !o && onCancel()}> + + + + + + Reset Imported Defaults + + This will remove all imported default styles. Your custom styles + will remain. You can re-import a file at any time. + + + + Cancel + + Reset Imported Defaults + + + + + ); +} + +function ConfirmImportMergeDialog({ + pending, + onConfirm, + onCancel, +}: { + pending: { parsed: StylingParseResult; conflicts: ImportConflicts } | null; + onConfirm: () => void; + onCancel: () => void; +}) { + if (!pending) return null; + + const { vertices, edges } = pending.conflicts; + const total = vertices.length + edges.length; + + return ( + !o && onCancel()}> + + + + + + + Replace {total} existing {total === 1 ? "default" : "defaults"}? + + + The imported file will overwrite these existing imported defaults. + New types will be added alongside them. + + +
    + {vertices.length > 0 ? ( + + ) : null} + {edges.length > 0 ? ( + + ) : null} +
    + + Cancel + + Import & Replace + + +
    +
    + ); +} + +function ConflictGroup({ label, types }: { label: string; types: string[] }) { + return ( + + + {label} + + {types.toSorted().map(type => ( + + {type} + + ))} + + ); +} + +function ImportedStylesStatus({ + vertexCount, + edgeCount, +}: { + vertexCount: number; + edgeCount: number; +}) { + if (vertexCount === 0 && edgeCount === 0) return null; + + const parts: string[] = []; + if (vertexCount > 0) { + parts.push(`${vertexCount} vertex`); + } + if (edgeCount > 0) { + parts.push(`${edgeCount} edge`); + } + + return ( +
    +
    + +
    +

    + {parts.join(" and ")}{" "} + {vertexCount + edgeCount === 1 ? "type has" : "types have"} imported + default styles +

    +
    + ); +} diff --git a/packages/graph-explorer/src/routes/Settings/StylingImportIssuesDialog.tsx b/packages/graph-explorer/src/routes/Settings/StylingImportIssuesDialog.tsx new file mode 100644 index 000000000..2ac05a9f2 --- /dev/null +++ b/packages/graph-explorer/src/routes/Settings/StylingImportIssuesDialog.tsx @@ -0,0 +1,101 @@ +import { AlertTriangleIcon, CheckCircleIcon } from "lucide-react"; + +import type { ImportIssue } from "@/core/styling"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, +} from "@/components"; + +export type ImportOutcome = { + error?: string; + issues?: ImportIssue[]; +}; + +type Props = { + outcome: ImportOutcome | null; + onClose: () => void; +}; + +export default function StylingImportIssuesDialog({ outcome, onClose }: Props) { + if (!outcome) return null; + + if (outcome.error) { + return ( + !open && onClose()}> + + + + + + Import Failed + {outcome.error} + + + Close + + + + ); + } + + const issues = outcome.issues ?? []; + const hasWarnings = issues.length > 0; + + return ( + !open && onClose()}> + + + + {hasWarnings ? : } + + + {hasWarnings ? "Imported with Warnings" : "Import Complete"} + + + {hasWarnings + ? "Some fields were skipped because they contained invalid values." + : "All styles were imported successfully."} + + + {hasWarnings ? : null} + + Close + + + + ); +} + +function IssuesList({ issues }: { issues: ImportIssue[] }) { + return ( +
    +
      + {issues.map((issue, i) => ( +
    • + + {issue.entityType}/{issue.typeName} + + {" — "} + {issue.field}: {issue.message} +
    • + ))} +
    +
    + ); +} diff --git a/packages/graph-explorer/src/routes/Settings/index.ts b/packages/graph-explorer/src/routes/Settings/index.ts index 6de383b1e..55bad75ba 100644 --- a/packages/graph-explorer/src/routes/Settings/index.ts +++ b/packages/graph-explorer/src/routes/Settings/index.ts @@ -1,3 +1,4 @@ export { default as SettingsRoot } from "./SettingsRoot"; export { default as SettingsGeneral } from "./SettingsGeneral"; +export { default as SettingsStyles } from "./SettingsStyles"; export { default as SettingsAbout } from "./SettingsAbout"; diff --git a/packages/graph-explorer/src/utils/fileData.ts b/packages/graph-explorer/src/utils/fileData.ts index fed9444ce..53cda2903 100644 --- a/packages/graph-explorer/src/utils/fileData.ts +++ b/packages/graph-explorer/src/utils/fileData.ts @@ -26,6 +26,7 @@ export async function fromFileToJson(blob: Blob) { export async function saveFile(file: Blob, defaultFileName: string) { if (!("showSaveFilePicker" in window)) { saveAs(file, defaultFileName); + return; } const fileHandle = await window.showSaveFilePicker({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77ece7c74..4491a12ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -223,6 +223,10 @@ overrides: importers: .: + dependencies: + dompurify: + specifier: ^3.4.11 + version: 3.4.11 devDependencies: '@tanstack/eslint-plugin-query': specifier: 5.101.0 @@ -317,6 +321,9 @@ importers: dedent: specifier: ^1.7.2 version: 1.7.2(babel-plugin-macros@3.1.0) + dompurify: + specifier: ^3.4.11 + version: 3.4.11 file-saver: specifier: ^2.0.5 version: 2.0.5 From 8ec4e9b24ba04e68dac0680ae236a71a75292f1d Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 29 Jun 2026 16:32:26 -0500 Subject: [PATCH 02/42] Add kind and major-version guard to file envelope; adopt in styling and graph export parseFileEnvelope now takes an expected kind and supported major version, rejecting wrong-kind and too-new files with a clear error instead of letting them fall through to a confusing partial parse. Styling and graph export both consume the guard; graph export drops its duplicate inline envelope schema and parses straight from the file Blob. --- docs/adr/20260624-shared-file-envelope.md | 42 +++-- .../core/fileEnvelope/fileEnvelope.test.ts | 160 +++++++++++------- .../src/core/fileEnvelope/fileEnvelope.ts | 49 +++++- .../src/core/fileEnvelope/index.ts | 1 + .../graph-explorer/src/core/styling/index.ts | 3 + .../src/core/styling/stylingParser.ts | 41 +++-- .../styling/useStylingImportExport.test.ts | 24 ++- .../core/styling/useStylingImportExport.ts | 28 +-- .../modules/GraphViewer/ImportGraphButton.tsx | 9 +- .../modules/GraphViewer/exportedGraph.test.ts | 84 +++++---- .../src/modules/GraphViewer/exportedGraph.ts | 84 ++++----- 11 files changed, 355 insertions(+), 170 deletions(-) diff --git a/docs/adr/20260624-shared-file-envelope.md b/docs/adr/20260624-shared-file-envelope.md index d4a484cfc..dc0f75f6e 100644 --- a/docs/adr/20260624-shared-file-envelope.md +++ b/docs/adr/20260624-shared-file-envelope.md @@ -6,7 +6,13 @@ ## Context -Graph Explorer already exports connection configurations to JSON files (`saveConfigurationToFile`). A new styling import/export feature needs its own file format. Both share the same structural concerns: identifying what the file contains, which version of the format was used, and when it was created. Without a shared envelope, each feature invents its own metadata shape, making it harder to add future exports (graph snapshots, layout presets) consistently. +Graph Explorer already exports several kinds of data to JSON files, and the same `{meta, data}` envelope shape was independently reinvented more than once: + +- **Graph export** (`modules/GraphViewer/exportedGraph.ts`) had this exact envelope inline: `meta: { kind, version, timestamp, source, sourceVersion }` wrapping a `data` payload. It was the prior art the shared module generalizes, and now **consumes** the module — `createExportedGraph` calls `createFileEnvelope("graph-export", "1.0", …)` and `parseExportedGraph(blob)` runs through `parseFileEnvelope`'s kind + major-version guard before validating its payload and sanitizing entity ids. +- **Backup** (`SerializedBackupSchema` in `localDb.ts`) uses the same five concepts with renamed fields (`backupSource`, `backupSourceVersion`, `backupVersion`, `backupTimestamp`, `data`). +- **Connection export** (`saveConfigurationToFile`) is the outlier — a flat `{ id, displayLabel, connection, schema }` with no envelope at all. + +A new styling import/export feature needs the same structural concerns: identifying what the file contains, which version of the format was used, and when it was created. Rather than reinvent the metadata shape a fourth time, the styling feature adopts a shared envelope, and the module documents the canonical shape so the next consumer reuses it instead of hand-rolling another copy. ## Decision @@ -23,19 +29,36 @@ A shared **file envelope** primitive lives in `packages/graph-explorer/src/core/ 2. **`meta` uses `z.looseObject()`** — unknown fields are preserved, not stripped. This is forward-compatibility: a newer exporter can add fields (e.g., `exportedBy`) without breaking older importers that ignore them. -3. **`kind` discriminates the file type.** Known kinds today: `"styling-export"`. Connection export adopts the envelope in follow-up work (not this PR). Each `kind` has its own payload schema registered separately. +3. **`kind` discriminates the file type.** Known kinds today: `"styling-export"`. Each `kind` has its own payload schema, validated by the caller. The caller passes the `kind` it expects; the envelope rejects a mismatch with a clear error so a wrong-kind file (e.g. a connection export) is never silently misinterpreted. 4. **`version` is the payload schema version**, not the app version. The `sourceVersion` field carries the app version for diagnostics. Semver-style (`"1.0"`) — major bump = breaking, minor = additive. -5. **Helpers:** +5. **`parseFileEnvelope` guards `kind` and major `version`.** The caller passes an `EnvelopeExpectation` (`{ kind, supportedMajorVersion }`). The envelope: + - rejects a mismatched `kind`, + - rejects a file whose **major** version is newer than the build supports — turning "a file from a future Graph Explorer" into one clear error instead of a wall of "unknown field" warnings from the salvaging payload parser, + - accepts a newer **minor** version of the same major — the additive-only contract means the salvaging parser ignores fields it doesn't recognize. + + Major (not exact) matching is deliberate: an exact `z.literal` match would reject forward-compatible minor additions, defeating the `looseObject` design. + +6. **Helpers:** - `createFileEnvelope(kind, version, data)` — stamps `timestamp`, `source` ("Graph Explorer"), and `sourceVersion` from the build constant. - - `parseFileEnvelope(blob)` — reads JSON, validates the outer envelope schema, returns `{ meta, rawData }` without validating the payload (caller validates `rawData` per `kind`). + - `parseFileEnvelope(blob, expectation)` — reads JSON, validates the outer envelope schema, guards kind + major version, and returns `{ meta, data }` without validating the payload (caller validates `data` per `kind`). - Callers compose the envelope with their own file-save logic (e.g., `toJsonFileData` + `saveFile` from `utils/fileData`). + Callers compose the envelope with their own file-save logic (e.g., `toJsonFileData` + `saveFile` from `utils/fileData`), and own a named version constant (e.g. `STYLING_EXPORT_VERSION` / `STYLING_EXPORT_MAJOR_VERSION`) so the bumpable value lives next to the payload schema. + +### Future shape: discriminated union on kind + version + +Today there is one payload schema per kind and the guard is the only version-aware code. When a breaking **v2** payload arrives, the consumer selects its schema by discriminating on `(kind, majorVersion)` — the guard already supplies a validated major version for that switch. The envelope stays a thin gate; the kind-specific dispatch lives in the consumer, not a registry in the envelope. ### Why a separate module (not inline in the styling code) -The envelope is intentionally generic so that the existing connection export and future formats (graph session snapshots, layout exports) can reuse it without coupling to the styling module. Keeping it in `core/fileEnvelope/` makes the reuse path obvious. +The `{meta, data}` envelope already exists independently in graph export (`exportedGraph.ts`) and, with renamed fields, in backup (`SerializedBackupSchema`). Extracting the canonical shape into `core/fileEnvelope/` gives the next consumer a reuse path instead of a fourth hand-rolled copy. + +### Migration status of the other consumers + +- **Graph export** — migrated. Its file format was already byte-identical to the envelope (same `kind`, same `version: "1.0"`, same five meta fields), so adopting the module is a pure refactor with no on-disk change: files written before and after the migration parse identically. Adopting `parseFileEnvelope` also upgraded it from an exact `z.literal("1.0")` match (which would have rejected a future minor `1.1`) to forward-compatible major-version guarding. +- **Backup** — left alone. Would require renaming persisted fields (`backupVersion` → `version`, …) on the disaster-recovery restore path — a data migration, not a refactor. +- **Connection export** — left alone. Flat (no envelope); wrapping it nests the file one level deeper, so every previously-exported file becomes unreadable unless the importer detects both shapes forever. That is a deliberate versioned format break, not a "backward-compatible follow-up," and is out of scope here. ### Why not reuse `SerializedBackupSchema` from `localDb.ts` @@ -43,7 +66,6 @@ The backup format (`graph-explorer-config.json`) dumps the entire IndexedDB stor ## Consequences -- New file-based import/export features get metadata handling for free — they only define their payload schema and `kind` string. -- The styling import validates `kind === "styling-export"` and rejects other kinds with a clear error, rather than silently misinterpreting a connection export file. -- The existing connection export (`saveConfigurationToFile`) does **not** migrate to this envelope in this PR — that is a separate, backward-compatible follow-up. The envelope is designed so it can wrap the existing connection shape without breaking existing importers (they already use `z.looseObject`). -- `parseFileEnvelope` returns the raw payload as `unknown` — the caller is responsible for kind-specific validation. This keeps the envelope layer thin and avoids a registry pattern. +- New file-based import/export features get metadata handling and version-guarding for free — they only define their payload schema, `kind` string, and supported major version. +- A wrong-kind or too-new file produces one clear, actionable error instead of a confusing partial import. +- `parseFileEnvelope` returns the payload as `unknown` — the caller is responsible for kind-specific validation. This keeps the envelope layer thin and avoids a registry pattern. diff --git a/packages/graph-explorer/src/core/fileEnvelope/fileEnvelope.test.ts b/packages/graph-explorer/src/core/fileEnvelope/fileEnvelope.test.ts index 52d07302e..64ccae2ed 100644 --- a/packages/graph-explorer/src/core/fileEnvelope/fileEnvelope.test.ts +++ b/packages/graph-explorer/src/core/fileEnvelope/fileEnvelope.test.ts @@ -2,10 +2,20 @@ import { describe, expect, test } from "vitest"; import { createFileEnvelope, - parseFileEnvelope, + type EnvelopeExpectation, type FileEnvelope, + parseFileEnvelope, } from "./fileEnvelope"; +const stylingExpectation: EnvelopeExpectation = { + kind: "styling-export", + supportedMajorVersion: 1, +}; + +function blobOf(value: unknown): Blob { + return new Blob([JSON.stringify(value)], { type: "application/json" }); +} + describe("createFileEnvelope", () => { test("stamps metadata with kind, version, and payload", () => { const data = { vertices: {}, edges: {} }; @@ -27,10 +37,11 @@ describe("parseFileEnvelope", () => { "1.0", { foo: "bar" }, ); - const json = JSON.stringify(envelope); - const blob = new Blob([json], { type: "application/json" }); + const blob = blobOf(envelope); - return expect(parseFileEnvelope(blob)).resolves.toStrictEqual({ + return expect( + parseFileEnvelope(blob, stylingExpectation), + ).resolves.toStrictEqual({ meta: envelope.meta, data: { foo: "bar" }, }); @@ -40,7 +51,7 @@ describe("parseFileEnvelope", () => { const envelope = { meta: { kind: "styling-export", - version: "2.0", + version: "1.0", timestamp: "2026-06-24T00:00:00.000Z", source: "Graph Explorer", sourceVersion: "9.9.9", @@ -48,11 +59,10 @@ describe("parseFileEnvelope", () => { }, data: { hello: "world" }, }; - const blob = new Blob([JSON.stringify(envelope)], { - type: "application/json", - }); - return expect(parseFileEnvelope(blob)).resolves.toStrictEqual({ + return expect( + parseFileEnvelope(blobOf(envelope), stylingExpectation), + ).resolves.toStrictEqual({ meta: envelope.meta, data: { hello: "world" }, }); @@ -60,78 +70,102 @@ describe("parseFileEnvelope", () => { test("throws for invalid JSON", () => { const blob = new Blob(["not json"], { type: "application/json" }); - return expect(parseFileEnvelope(blob)).rejects.toThrow( + return expect(parseFileEnvelope(blob, stylingExpectation)).rejects.toThrow( "File is not valid JSON", ); }); test("throws when meta is missing", () => { - const blob = new Blob([JSON.stringify({ data: {} })], { - type: "application/json", + return expect( + parseFileEnvelope(blobOf({ data: {} }), stylingExpectation), + ).rejects.toThrow("envelope structure"); + }); + + test("throws when meta.kind is missing", () => { + const blob = blobOf({ + meta: { version: "1.0", timestamp: "x", source: "x", sourceVersion: "x" }, + data: {}, }); - return expect(parseFileEnvelope(blob)).rejects.toThrow( + return expect(parseFileEnvelope(blob, stylingExpectation)).rejects.toThrow( "envelope structure", ); }); - test("throws when meta.kind is missing", () => { - const blob = new Blob( - [ - JSON.stringify({ - meta: { - version: "1.0", - timestamp: "x", - source: "x", - sourceVersion: "x", - }, - data: {}, - }), - ], - { type: "application/json" }, - ); - return expect(parseFileEnvelope(blob)).rejects.toThrow( + test("throws when data is missing", () => { + const blob = blobOf({ + meta: { + kind: "styling-export", + version: "1.0", + timestamp: "x", + source: "x", + sourceVersion: "x", + }, + }); + return expect(parseFileEnvelope(blob, stylingExpectation)).rejects.toThrow( "envelope structure", ); }); - test("throws when data is missing", () => { - const blob = new Blob( - [ - JSON.stringify({ - meta: { - kind: "styling-export", - version: "1.0", - timestamp: "x", - source: "x", - sourceVersion: "x", - }, - }), - ], - { type: "application/json" }, + test("rejects a file whose kind does not match the expectation", () => { + const blob = blobOf({ + meta: { + kind: "graph-export", + version: "1.0", + timestamp: "x", + source: "x", + sourceVersion: "x", + }, + data: {}, + }); + return expect(parseFileEnvelope(blob, stylingExpectation)).rejects.toThrow( + /Expected a "styling-export" file.*got "graph-export"/, ); - return expect(parseFileEnvelope(blob)).rejects.toThrow( - "envelope structure", + }); + + test("accepts a newer minor version of the same major", async () => { + const blob = blobOf({ + meta: { + kind: "styling-export", + version: "1.5", + timestamp: "x", + source: "x", + sourceVersion: "x", + }, + data: { vertices: {}, edges: {} }, + }); + const result = await parseFileEnvelope(blob, stylingExpectation); + expect(result.meta.version).toBe("1.5"); + }); + + test("rejects a newer major version as too new", () => { + const blob = blobOf({ + meta: { + kind: "styling-export", + version: "2.0", + timestamp: "x", + source: "x", + sourceVersion: "x", + }, + data: { vertices: {}, edges: {} }, + }); + return expect(parseFileEnvelope(blob, stylingExpectation)).rejects.toThrow( + /newer version of Graph Explorer/, ); }); - test("parses unknown version on a best-effort basis (forward-compat)", async () => { - const blob = new Blob( - [ - JSON.stringify({ - meta: { - kind: "styling-export", - version: "99.0", - timestamp: "x", - source: "x", - sourceVersion: "x", - }, - data: { vertices: {}, edges: {} }, - }), - ], - { type: "application/json" }, + test("rejects an unparseable version string", () => { + const blob = blobOf({ + meta: { + kind: "styling-export", + version: "not-a-version", + timestamp: "x", + source: "x", + sourceVersion: "x", + }, + data: { vertices: {}, edges: {} }, + }); + return expect(parseFileEnvelope(blob, stylingExpectation)).rejects.toThrow( + /unrecognized version/, ); - const result = await parseFileEnvelope(blob); - expect(result.meta.version).toBe("99.0"); - expect(result.data).toStrictEqual({ vertices: {}, edges: {} }); }); }); diff --git a/packages/graph-explorer/src/core/fileEnvelope/fileEnvelope.ts b/packages/graph-explorer/src/core/fileEnvelope/fileEnvelope.ts index 400a5fc24..652bbcd18 100644 --- a/packages/graph-explorer/src/core/fileEnvelope/fileEnvelope.ts +++ b/packages/graph-explorer/src/core/fileEnvelope/fileEnvelope.ts @@ -51,7 +51,28 @@ export function createFileEnvelope( }; } -export async function parseFileEnvelope(blob: Blob): Promise { +export type EnvelopeExpectation = { + /** The `kind` discriminator this caller knows how to read. */ + kind: string; + /** + * The highest payload-schema major version this build understands. A file + * with the same major imports (the salvaging payload parser ignores unknown + * minor-version additions); a higher major is rejected as too new. + */ + supportedMajorVersion: number; +}; + +/** + * Reads and validates the outer envelope, then guards `kind` and major + * `version` against what the caller supports. Throws {@link FileEnvelopeError} + * for invalid JSON, a malformed envelope, the wrong `kind`, an unparseable + * version, or a major version newer than this build. The payload (`data`) is + * returned unvalidated for the caller to parse per kind. + */ +export async function parseFileEnvelope( + blob: Blob, + expectation: EnvelopeExpectation, +): Promise { let json: unknown; try { const text = await blob.text(); @@ -68,5 +89,31 @@ export async function parseFileEnvelope(blob: Blob): Promise { ); } + const { meta } = result.data; + + if (meta.kind !== expectation.kind) { + throw new FileEnvelopeError( + `Expected a "${expectation.kind}" file, but got "${meta.kind}"`, + ); + } + + const majorVersion = parseMajorVersion(meta.version); + if (majorVersion === null) { + throw new FileEnvelopeError( + `File has an unrecognized version "${meta.version}"`, + ); + } + if (majorVersion > expectation.supportedMajorVersion) { + throw new FileEnvelopeError( + `This file was created by a newer version of ${LABELS.APP_NAME} and cannot be imported. Update ${LABELS.APP_NAME} and try again.`, + ); + } + return result.data; } + +/** Extracts the leading integer of a semver-style `"major.minor"` string. */ +function parseMajorVersion(version: string): number | null { + const major = Number.parseInt(version, 10); + return Number.isNaN(major) ? null : major; +} diff --git a/packages/graph-explorer/src/core/fileEnvelope/index.ts b/packages/graph-explorer/src/core/fileEnvelope/index.ts index 4a69b7e71..f41bf7f02 100644 --- a/packages/graph-explorer/src/core/fileEnvelope/index.ts +++ b/packages/graph-explorer/src/core/fileEnvelope/index.ts @@ -1,5 +1,6 @@ export { createFileEnvelope, + type EnvelopeExpectation, FileEnvelopeError, parseFileEnvelope, type FileEnvelope, diff --git a/packages/graph-explorer/src/core/styling/index.ts b/packages/graph-explorer/src/core/styling/index.ts index aa960608e..1c1b044f1 100644 --- a/packages/graph-explorer/src/core/styling/index.ts +++ b/packages/graph-explorer/src/core/styling/index.ts @@ -1,5 +1,8 @@ export { parseStylingPayload, + STYLING_EXPORT_KIND, + STYLING_EXPORT_MAJOR_VERSION, + STYLING_EXPORT_VERSION, StylingParseError, toFileEntry, type ImportIssue, diff --git a/packages/graph-explorer/src/core/styling/stylingParser.ts b/packages/graph-explorer/src/core/styling/stylingParser.ts index 971e78e9d..895ddbfd5 100644 --- a/packages/graph-explorer/src/core/styling/stylingParser.ts +++ b/packages/graph-explorer/src/core/styling/stylingParser.ts @@ -11,6 +11,19 @@ import { type VertexPreferencesStorageModel, } from "@/core/StateProvider/userPreferences"; +// --- Format identity --- + +/** The envelope `kind` discriminator for styling export files. */ +export const STYLING_EXPORT_KIND = "styling-export"; + +/** + * Payload schema version. Bump the major for a breaking change (renamed or + * removed fields); bump the minor for additive changes that older readers can + * safely ignore via the salvaging parser. + */ +export const STYLING_EXPORT_VERSION = "1.0"; +export const STYLING_EXPORT_MAJOR_VERSION = 1; + // --- Public types --- export type ImportIssue = { @@ -43,9 +56,11 @@ export type VertexStyleFileEntry = Omit< "type" | "iconUrl" > & { icon?: string }; +export type EdgeStyleFileEntry = Omit; + export type StylingExportPayload = { vertices: Record; - edges: Record>; + edges: Record; }; export function toFileEntry( @@ -143,7 +158,7 @@ export function parseStylingPayload(rawData: unknown): StylingParseResult { const edgeStyles = new Map(); for (const [typeName, entry] of Object.entries(structure.data.vertices)) { - const resolved = salvageEntry( + const resolved = salvageEntry( "vertex", typeName, entry, @@ -154,12 +169,12 @@ export function parseStylingPayload(rawData: unknown): StylingParseResult { vertexStyles.set(createVertexType(typeName), { type: createVertexType(typeName), ...resolved, - } as VertexPreferencesStorageModel); + }); } } for (const [typeName, entry] of Object.entries(structure.data.edges)) { - const resolved = salvageEntry( + const resolved = salvageEntry( "edge", typeName, entry, @@ -170,7 +185,7 @@ export function parseStylingPayload(rawData: unknown): StylingParseResult { edgeStyles.set(createEdgeType(typeName), { type: createEdgeType(typeName), ...resolved, - } as EdgePreferencesStorageModel); + }); } } @@ -179,14 +194,20 @@ export function parseStylingPayload(rawData: unknown): StylingParseResult { // --- Generic salvaging entry parser --- -function salvageEntry( +/** + * Validates each field of one entry independently. Invalid and unknown fields + * are dropped and reported in `issues`; valid fields are collected into a + * partial storage model. The single file→storage rename (`icon`→`iconUrl`) + * happens here so it stays at this seam rather than spreading to consumers. + */ +function salvageEntry( entityType: "vertex" | "edge", typeName: string, entry: Record, fieldSchemas: Record, issues: ImportIssue[], -): Record { - const resolved: Record = {}; +): Partial> { + const resolved: Partial> = {}; for (const [field, value] of Object.entries(entry)) { if (!Object.hasOwn(fieldSchemas, field)) { @@ -211,9 +232,9 @@ function salvageEntry( continue; } - // The single field rename: file uses `icon`, storage uses `iconUrl` const storageField = field === "icon" ? "iconUrl" : field; - resolved[storageField] = parsed.data; + resolved[storageField as keyof typeof resolved] = + parsed.data as (typeof resolved)[keyof typeof resolved]; } return resolved; diff --git a/packages/graph-explorer/src/core/styling/useStylingImportExport.test.ts b/packages/graph-explorer/src/core/styling/useStylingImportExport.test.ts index fcc907e7e..5982ef86d 100644 --- a/packages/graph-explorer/src/core/styling/useStylingImportExport.test.ts +++ b/packages/graph-explorer/src/core/styling/useStylingImportExport.test.ts @@ -215,7 +215,29 @@ describe("useImportStylingFile", () => { }); await expect(result.current.parseFile(file)).rejects.toThrow( - 'Expected a styling export file, got "connection-export"', + 'Expected a "styling-export" file, but got "connection-export"', + ); + }); + + test("throws when the file was made by a newer major version", async () => { + const { result } = renderHookWithJotai(() => useImportStylingFile()); + + const envelope = { + meta: { + kind: "styling-export", + version: "2.0", + timestamp: "2026-01-01T00:00:00Z", + source: "Graph Explorer", + sourceVersion: "9.9.9", + }, + data: { vertices: {}, edges: {} }, + }; + const file = new File([JSON.stringify(envelope)], "styles.json", { + type: "application/json", + }); + + await expect(result.current.parseFile(file)).rejects.toThrow( + /newer version of Graph Explorer/, ); }); }); diff --git a/packages/graph-explorer/src/core/styling/useStylingImportExport.ts b/packages/graph-explorer/src/core/styling/useStylingImportExport.ts index 7a7f43ddd..f4fa01325 100644 --- a/packages/graph-explorer/src/core/styling/useStylingImportExport.ts +++ b/packages/graph-explorer/src/core/styling/useStylingImportExport.ts @@ -4,7 +4,7 @@ import { useCallback } from "react"; import type { EdgeType, VertexType } from "@/core/entities"; import type { EdgePreferencesStorageModel } from "@/core/StateProvider/userPreferences"; -import { FileEnvelopeError, parseFileEnvelope } from "@/core/fileEnvelope"; +import { parseFileEnvelope } from "@/core/fileEnvelope"; import { importedEdgeStylesAtom, importedVertexStylesAtom, @@ -18,7 +18,12 @@ import type { VertexStyleFileEntry, } from "./stylingParser"; -import { parseStylingPayload, toFileEntry } from "./stylingParser"; +import { + parseStylingPayload, + STYLING_EXPORT_KIND, + STYLING_EXPORT_MAJOR_VERSION, + toFileEntry, +} from "./stylingParser"; export type ImportConflicts = { vertices: string[]; @@ -33,20 +38,17 @@ export function useImportStylingFile() { /** * Parses a styling export file. Throws {@link FileEnvelopeError} if the file - * is not valid JSON or lacks the envelope structure, and - * {@link StylingParseError} if the payload is structurally unusable. Per-field - * salvage issues are returned in the result (not thrown). + * is not valid JSON, lacks the envelope structure, is the wrong kind, or was + * created by a newer major version; and {@link StylingParseError} if the + * payload is structurally unusable. Per-field salvage issues are returned in + * the result (not thrown). */ const parseFile = useCallback( async (file: File): Promise => { - const envelope = await parseFileEnvelope(file); - - if (envelope.meta.kind !== "styling-export") { - throw new FileEnvelopeError( - `Expected a styling export file, got "${envelope.meta.kind}"`, - ); - } - + const envelope = await parseFileEnvelope(file, { + kind: STYLING_EXPORT_KIND, + supportedMajorVersion: STYLING_EXPORT_MAJOR_VERSION, + }); return parseStylingPayload(envelope.data); }, [], diff --git a/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.tsx b/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.tsx index cb7b23bed..ae9314578 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.tsx +++ b/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.tsx @@ -7,11 +7,11 @@ import { ZodError } from "zod"; import { Button, FileButton, Spinner } from "@/components"; import { fetchEntityDetails, notifyOnIncompleteRestoration } from "@/connector"; import { configurationAtom, type ConnectionWithId, useExplorer } from "@/core"; +import { FileEnvelopeError } from "@/core/fileEnvelope"; import { useAddToGraph } from "@/hooks"; import { useEntityCountFormatterCallback } from "@/hooks/useEntityCountFormatter"; import { getTranslation } from "@/hooks/useTranslations"; import { logger } from "@/utils"; -import { fromFileToJson } from "@/utils/fileData"; import { type ExportedGraphConnection, @@ -63,8 +63,7 @@ function useImportGraphMutation() { const mutation = useMutation({ mutationFn: async (file: File) => { // 1. Parse the file - const data = await fromFileToJson(file); - const graph = await parseExportedGraph(data); + const graph = await parseExportedGraph(file); // 2. Check connection if (!isMatchingConnection(explorer.connection, graph.connection)) { @@ -118,7 +117,9 @@ export function createErrorNotification( file: File, allConnections: ConnectionWithId[], ) { - if (error instanceof ZodError) { + if (error instanceof FileEnvelopeError) { + return error.message; + } else if (error instanceof ZodError) { return `Parsing the file "${file.name}" failed. Please ensure the file was originally saved from Graph Explorer and is not corrupt.`; } else if (error instanceof InvalidConnectionError) { const matchingByUrlAndQueryEngine = allConnections.filter(connection => diff --git a/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.test.ts b/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.test.ts index 207cc74c6..2a6932f2e 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.test.ts +++ b/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.test.ts @@ -25,11 +25,15 @@ import { createFileSafeTimestamp, type ExportedGraphConnection, type ExportedGraphFile, - exportedGraphSchema, isMatchingConnection, parseExportedGraph, } from "./exportedGraph"; +/** Wraps an exported-graph object in a Blob, as the file entry point expects. */ +function toGraphFileBlob(graph: ExportedGraphFile): Blob { + return new Blob([JSON.stringify(graph)], { type: "application/json" }); +} + describe("createExportedGraph", () => { let timestamp: Date; let appVersion: string; @@ -60,7 +64,7 @@ describe("createExportedGraph", () => { const expectedMeta = { kind: "graph-export", version: "1.0", - timestamp: timestamp, + timestamp: timestamp.toISOString(), source: "Graph Explorer", sourceVersion: appVersion, } satisfies ExportedGraphFile["meta"]; @@ -91,7 +95,7 @@ describe("createExportedGraph", () => { const graph = createExportedGraph(vertexIds, edgeIds, connection); - expect(graph.meta.timestamp).toEqual(timestamp); + expect(graph.meta.timestamp).toEqual(timestamp.toISOString()); }); it("should use app version from global variable", () => { @@ -184,7 +188,7 @@ describe("parseExportedGraph", () => { vertices: new Set(exportedGraph.data.vertices), edges: new Set(exportedGraph.data.edges), }; - const parsed = await parseExportedGraph(exportedGraph); + const parsed = await parseExportedGraph(toGraphFileBlob(exportedGraph)); expect(parsed).toEqual(expected); }); @@ -195,7 +199,7 @@ describe("parseExportedGraph", () => { vertices: new Set(exportedGraph.data.vertices), edges: new Set(exportedGraph.data.edges), }; - const parsed = await parseExportedGraph(exportedGraph); + const parsed = await parseExportedGraph(toGraphFileBlob(exportedGraph)); expect(parsed).toEqual(expected); }); @@ -204,7 +208,7 @@ describe("parseExportedGraph", () => { exportedGraph.data.vertices.push(""); exportedGraph.data.edges.push(""); - const parsed = await parseExportedGraph(exportedGraph); + const parsed = await parseExportedGraph(toGraphFileBlob(exportedGraph)); expect(parsed.vertices.has("" as VertexId)).toBeFalsy(); expect(parsed.edges.has("" as EdgeId)).toBeFalsy(); @@ -217,7 +221,7 @@ describe("parseExportedGraph", () => { exportedGraph.data.vertices.push(vertexId); exportedGraph.data.edges.push(edgeId); - const parsed = await parseExportedGraph(exportedGraph); + const parsed = await parseExportedGraph(toGraphFileBlob(exportedGraph)); expect(parsed.vertices.has(vertexId as VertexId)).toBeTruthy(); expect(parsed.edges.has(edgeId as EdgeId)).toBeTruthy(); @@ -230,7 +234,7 @@ describe("parseExportedGraph", () => { exportedGraph.data.vertices.push(maliciousVertexId); exportedGraph.data.edges.push(maliciousEdgeId); - const parsed = await parseExportedGraph(exportedGraph); + const parsed = await parseExportedGraph(toGraphFileBlob(exportedGraph)); expect(parsed.vertices.has(maliciousVertexId as VertexId)).toBeFalsy(); expect(parsed.edges.has(maliciousEdgeId as EdgeId)).toBeFalsy(); @@ -249,7 +253,7 @@ describe("parseExportedGraph", () => { exportedGraph.data.vertices.push(vertexIdWithWhitespace); exportedGraph.data.edges.push(edgeIdWithWhitespace); - const parsed = await parseExportedGraph(exportedGraph); + const parsed = await parseExportedGraph(toGraphFileBlob(exportedGraph)); expect(parsed.vertices.has(vertexIdWithWhitespace as VertexId)).toBeFalsy(); expect(parsed.edges.has(edgeIdWithWhitespace as EdgeId)).toBeFalsy(); @@ -272,7 +276,7 @@ describe("parseExportedGraph", () => { vertices: new Set(exportedGraph.data.vertices), edges: new Set(exportedGraph.data.edges.slice(0, -1)), }; - const parsed = await parseExportedGraph(exportedGraph); + const parsed = await parseExportedGraph(toGraphFileBlob(exportedGraph)); expect(parsed).toEqual(expected); }); @@ -286,40 +290,62 @@ describe("parseExportedGraph", () => { vertices: new Set(exportedGraph.data.vertices), edges: new Set(exportedGraph.data.edges.slice(0, -1)), }; - const parsed = await parseExportedGraph(exportedGraph); + const parsed = await parseExportedGraph(toGraphFileBlob(exportedGraph)); expect(parsed).toEqual(expected); }); -}); -describe("exportedGraphSchema", () => { - it("should validate exported graph schema", () => { + it("should reject a file that is not a graph export", async () => { const exportedGraph = createRandomExportedGraph(); - expect(exportedGraphSchema.safeParse(exportedGraph).success).toBeTruthy(); + const wrongKind = { + ...exportedGraph, + meta: { ...exportedGraph.meta, kind: "styling-export" }, + }; + + await expect( + parseExportedGraph(toGraphFileBlob(wrongKind as ExportedGraphFile)), + ).rejects.toThrow(/Expected a "graph-export" file/); }); - it("should validate exported graph with empty vertices and edges", () => { + it("should reject a file from a newer major version", async () => { const exportedGraph = createRandomExportedGraph(); - exportedGraph.data.vertices = []; - exportedGraph.data.edges = []; - expect(exportedGraphSchema.safeParse(exportedGraph).success).toBeTruthy(); + const tooNew = { + ...exportedGraph, + meta: { ...exportedGraph.meta, version: "2.0" }, + }; + + await expect(parseExportedGraph(toGraphFileBlob(tooNew))).rejects.toThrow( + /newer version of Graph Explorer/, + ); }); - it("should not validate exported graph schema with invalid vertices", () => { + it("should accept a newer minor version of the same major", async () => { const exportedGraph = createRandomExportedGraph(); - exportedGraph.data.vertices = [false, true] as any; - expect(exportedGraphSchema.safeParse(exportedGraph).success).toBeFalsy(); + const newerMinor = { + ...exportedGraph, + meta: { ...exportedGraph.meta, version: "1.5" }, + }; + + const parsed = await parseExportedGraph(toGraphFileBlob(newerMinor)); + expect(parsed.vertices).toEqual(new Set(exportedGraph.data.vertices)); }); - it("should not validate exported graph schema with invalid edges", () => { + it("should reject a malformed payload", async () => { const exportedGraph = createRandomExportedGraph(); - exportedGraph.data.edges = [false, true] as any; - expect(exportedGraphSchema.safeParse(exportedGraph).success).toBeFalsy(); + const malformed = { + ...exportedGraph, + data: { ...exportedGraph.data, vertices: [false, true] as any }, + }; + + await expect( + parseExportedGraph(toGraphFileBlob(malformed)), + ).rejects.toThrow(); }); - it("should not validate exported graph with different meta kind value", () => { - const exportedGraph = createRandomExportedGraph(); - exportedGraph.meta.kind = "not-graph-export" as any; - expect(exportedGraphSchema.safeParse(exportedGraph).success).toBeFalsy(); + it("should reject a file that is not valid JSON", async () => { + const blob = new Blob(["not json"], { type: "application/json" }); + await expect(parseExportedGraph(blob)).rejects.toThrow( + "File is not valid JSON", + ); }); }); diff --git a/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.ts b/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.ts index 2cb2f3aee..e33158f48 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.ts +++ b/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.ts @@ -5,6 +5,8 @@ import { } from "@shared/types"; import { z } from "zod"; +import type { FileEnvelope } from "@/core/fileEnvelope"; + import { parseRdfEdgeIdString } from "@/connector/sparql/parseEdgeId"; import { createEdgeId, @@ -13,28 +15,31 @@ import { type EntityRawId, type VertexId, } from "@/core/entities"; -import { escapeString, LABELS, logger } from "@/utils"; - -export const exportedGraphSchema = z.object({ - meta: z.object({ - kind: z.literal("graph-export"), - version: z.literal("1.0"), - timestamp: z.coerce.date(), - source: z.string(), - sourceVersion: z.string(), - }), - data: z.object({ - connection: z.object({ - dbUrl: z.string(), - queryEngine: z.enum(queryEngineOptions), - }), - vertices: z.array(z.union([z.string(), z.number()])), - edges: z.array(z.union([z.string(), z.number()])), +import { createFileEnvelope, parseFileEnvelope } from "@/core/fileEnvelope"; +import { escapeString, logger } from "@/utils"; + +/** The envelope `kind` discriminator for graph export files. */ +export const GRAPH_EXPORT_KIND = "graph-export"; + +/** + * Payload schema version. Bump the major for a breaking change; bump the minor + * for additive changes that older readers can safely ignore. + */ +export const GRAPH_EXPORT_VERSION = "1.0"; +export const GRAPH_EXPORT_MAJOR_VERSION = 1; + +const graphExportPayloadSchema = z.object({ + connection: z.object({ + dbUrl: z.string(), + queryEngine: z.enum(queryEngineOptions), }), + vertices: z.array(z.union([z.string(), z.number()])), + edges: z.array(z.union([z.string(), z.number()])), }); -export type ExportedGraphFile = z.infer; -export type ExportedGraphConnection = ExportedGraphFile["data"]["connection"]; +export type GraphExportPayload = z.infer; +export type ExportedGraphFile = FileEnvelope; +export type ExportedGraphConnection = GraphExportPayload["connection"]; /** Creates an exported graph suitable for saving to a file. */ export function createExportedGraph( @@ -42,20 +47,11 @@ export function createExportedGraph( edgeIds: EdgeId[], connection: ConnectionConfig, ): ExportedGraphFile { - return { - meta: { - kind: "graph-export", - source: LABELS.APP_NAME, - sourceVersion: __GRAPH_EXP_VERSION__, - version: "1.0", - timestamp: new Date(), - }, - data: { - connection: createExportedConnection(connection), - vertices: vertexIds, - edges: edgeIds, - }, - }; + return createFileEnvelope(GRAPH_EXPORT_KIND, GRAPH_EXPORT_VERSION, { + connection: createExportedConnection(connection), + vertices: vertexIds, + edges: edgeIds, + }); } /** Creates an exported connection object from the given connection config. */ @@ -73,14 +69,24 @@ export function createExportedConnection( }; } -export async function parseExportedGraph(data: unknown) { - const parsed = await exportedGraphSchema.parseAsync(data); - - const connection = parsed.data.connection; +/** + * Reads a graph export file: validates the shared envelope (kind + major + * version), then the graph payload, then sanitizes the entity ids. Throws + * {@link FileEnvelopeError} for a non-graph or too-new file and {@link ZodError} + * for a malformed payload. + */ +export async function parseExportedGraph(blob: Blob) { + const envelope = await parseFileEnvelope(blob, { + kind: GRAPH_EXPORT_KIND, + supportedMajorVersion: GRAPH_EXPORT_MAJOR_VERSION, + }); + const payload = graphExportPayloadSchema.parse(envelope.data); + + const connection = payload.connection; // Do some basic validation and skip any invalid IDs const vertices = new Set( - parsed.data.vertices + payload.vertices .values() .map(trimIfString) .filter(isNotEmptyIfString) @@ -91,7 +97,7 @@ export async function parseExportedGraph(data: unknown) { // Do some basic validation and skip any invalid IDs const edges = new Set( - parsed.data.edges + payload.edges .values() .map(trimIfString) .filter(isNotEmptyIfString) From 2d72b2fb8d1a300327518aa008e3532d36536866 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 29 Jun 2026 16:32:44 -0500 Subject: [PATCH 03/42] Add imperative confirm dialog and simplify styling settings flow ConfirmDialogProvider exposes a promise-returning useConfirm() so a confirm step reads inline in an async flow. SettingsStyles uses it for reset and import-merge confirmations, collapsing the pending-import state and a separate handler into one top-to-bottom flow, and models the operation lifecycle as a single state union. --- .../src/components/ConfirmDialog.test.tsx | 102 ++++++ .../src/components/ConfirmDialog.tsx | 105 ++++++ .../graph-explorer/src/components/index.ts | 2 + .../src/routes/DefaultLayout.tsx | 12 +- .../src/routes/Settings/SettingsStyles.tsx | 319 +++++++----------- 5 files changed, 346 insertions(+), 194 deletions(-) create mode 100644 packages/graph-explorer/src/components/ConfirmDialog.test.tsx create mode 100644 packages/graph-explorer/src/components/ConfirmDialog.tsx diff --git a/packages/graph-explorer/src/components/ConfirmDialog.test.tsx b/packages/graph-explorer/src/components/ConfirmDialog.test.tsx new file mode 100644 index 000000000..e97ea4e64 --- /dev/null +++ b/packages/graph-explorer/src/components/ConfirmDialog.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, test, vi } from "vitest"; + +import { ConfirmDialogProvider, useConfirm } from "./ConfirmDialog"; + +function ConfirmTrigger({ + onResult, +}: { + onResult: (confirmed: boolean) => void; +}) { + const confirm = useConfirm(); + return ( + + ); +} + +function renderWithProvider(onResult: (confirmed: boolean) => void) { + return render( + + + , + ); +} + +describe("ConfirmDialogProvider", () => { + test("resolves true when the user confirms", async () => { + const user = userEvent.setup(); + const onResult = vi.fn(); + renderWithProvider(onResult); + + await user.click(screen.getByRole("button", { name: "Open" })); + expect(screen.getByText("Delete everything?")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Delete" })); + + expect(onResult).toHaveBeenCalledWith(true); + expect(screen.queryByText("Delete everything?")).not.toBeInTheDocument(); + }); + + test("resolves false when the user cancels", async () => { + const user = userEvent.setup(); + const onResult = vi.fn(); + renderWithProvider(onResult); + + await user.click(screen.getByRole("button", { name: "Open" })); + await user.click(screen.getByRole("button", { name: "Keep" })); + + expect(onResult).toHaveBeenCalledWith(false); + expect(screen.queryByText("Delete everything?")).not.toBeInTheDocument(); + }); + + test("resolves false when the dialog is dismissed via escape", async () => { + const user = userEvent.setup(); + const onResult = vi.fn(); + renderWithProvider(onResult); + + await user.click(screen.getByRole("button", { name: "Open" })); + await user.keyboard("{Escape}"); + + expect(onResult).toHaveBeenCalledWith(false); + }); + + test("each confirmation resolves its own promise", async () => { + const user = userEvent.setup(); + const results: boolean[] = []; + renderWithProvider(confirmed => results.push(confirmed)); + + await user.click(screen.getByRole("button", { name: "Open" })); + await user.click(screen.getByRole("button", { name: "Keep" })); + + await user.click(screen.getByRole("button", { name: "Open" })); + await user.click(screen.getByRole("button", { name: "Delete" })); + + expect(results).toEqual([false, true]); + }); + + test("useConfirm throws when used outside the provider", () => { + function Orphan() { + useConfirm(); + return null; + } + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => render()).toThrow( + "useConfirm must be used within a ConfirmDialogProvider", + ); + spy.mockRestore(); + }); +}); diff --git a/packages/graph-explorer/src/components/ConfirmDialog.tsx b/packages/graph-explorer/src/components/ConfirmDialog.tsx new file mode 100644 index 000000000..7e4102534 --- /dev/null +++ b/packages/graph-explorer/src/components/ConfirmDialog.tsx @@ -0,0 +1,105 @@ +import type { ReactNode } from "react"; + +import { createContext, use, useCallback, useRef, useState } from "react"; + +import type { Button } from "./Button"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, +} from "./AlertDialog"; + +type ButtonVariant = React.ComponentProps["variant"]; + +export type ConfirmOptions = { + title: ReactNode; + description?: ReactNode; + /** Extra content rendered between the description and the footer. */ + body?: ReactNode; + icon?: ReactNode; + /** Tailwind classes for the icon media element (e.g. danger vs primary). */ + mediaClassName?: string; + confirmLabel?: string; + cancelLabel?: string; + confirmVariant?: ButtonVariant; +}; + +/** + * Resolves a confirmation dialog imperatively. `confirm(options)` returns a + * promise that resolves `true` when the user confirms and `false` when they + * cancel or dismiss. This keeps a confirm step inline in an async flow instead + * of splitting it across pending-state and a separate handler. + */ +export type ConfirmFn = (options: ConfirmOptions) => Promise; + +const ConfirmContext = createContext(null); + +export function useConfirm(): ConfirmFn { + const confirm = use(ConfirmContext); + if (!confirm) { + throw new Error("useConfirm must be used within a ConfirmDialogProvider"); + } + return confirm; +} + +export function ConfirmDialogProvider({ children }: { children: ReactNode }) { + const [pending, setPending] = useState(null); + const resolveRef = useRef<((confirmed: boolean) => void) | null>(null); + + const confirm = useCallback(options => { + return new Promise(resolve => { + resolveRef.current = resolve; + setPending(options); + }); + }, []); + + const settle = useCallback((confirmed: boolean) => { + resolveRef.current?.(confirmed); + resolveRef.current = null; + setPending(null); + }, []); + + return ( + + {children} + {pending ? ( + !open && settle(false)}> + + + {pending.icon ? ( + + {pending.icon} + + ) : null} + {pending.title} + {pending.description ? ( + + {pending.description} + + ) : null} + + {pending.body} + + settle(false)}> + {pending.cancelLabel ?? "Cancel"} + + settle(true)} + > + {pending.confirmLabel ?? "Confirm"} + + + + + ) : null} + + ); +} diff --git a/packages/graph-explorer/src/components/index.ts b/packages/graph-explorer/src/components/index.ts index 46d05448c..8a31cf32b 100644 --- a/packages/graph-explorer/src/components/index.ts +++ b/packages/graph-explorer/src/components/index.ts @@ -4,6 +4,8 @@ export * from "./Button"; export * from "./Checkbox"; +export * from "./ConfirmDialog"; + export { default as CheckboxList } from "./CheckboxList"; export * from "./CheckboxList"; diff --git a/packages/graph-explorer/src/routes/DefaultLayout.tsx b/packages/graph-explorer/src/routes/DefaultLayout.tsx index 0b2d07086..c0b87148f 100644 --- a/packages/graph-explorer/src/routes/DefaultLayout.tsx +++ b/packages/graph-explorer/src/routes/DefaultLayout.tsx @@ -4,7 +4,7 @@ import { useEffect } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { Outlet } from "react-router"; -import { TooltipProvider } from "@/components"; +import { ConfirmDialogProvider, TooltipProvider } from "@/components"; import { Toaster } from "@/components/Toaster"; import { diagnosticLoggingAtom } from "@/core"; import AppErrorPage from "@/core/AppErrorPage"; @@ -35,10 +35,12 @@ export default function DefaultLayout() { - - - - + + + + + + diff --git a/packages/graph-explorer/src/routes/Settings/SettingsStyles.tsx b/packages/graph-explorer/src/routes/Settings/SettingsStyles.tsx index 0b1feaf14..32cbaacb7 100644 --- a/packages/graph-explorer/src/routes/Settings/SettingsStyles.tsx +++ b/packages/graph-explorer/src/routes/Settings/SettingsStyles.tsx @@ -8,12 +8,11 @@ import { } from "lucide-react"; import { useRef, useState } from "react"; -import type { ImportConflicts, StylingParseResult } from "@/core/styling"; +import type { ImportConflicts, ImportIssue } from "@/core/styling"; import { AlertDialog, AlertDialogAction, - AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, @@ -32,6 +31,7 @@ import { SettingsPageIcon, SettingsPageTitle, SettingsPage, + useConfirm, } from "@/components"; import { createFileEnvelope } from "@/core/fileEnvelope"; import { @@ -40,15 +40,35 @@ import { userEdgeStylesAtom, userVertexStylesAtom, } from "@/core/StateProvider/storageAtoms"; -import { useExportStylingFile, useImportStylingFile } from "@/core/styling"; +import { + STYLING_EXPORT_KIND, + STYLING_EXPORT_VERSION, + useExportStylingFile, + useImportStylingFile, +} from "@/core/styling"; import { logger } from "@/utils"; import { saveFile, toJsonFileData } from "@/utils/fileData"; -import StylingImportIssuesDialog, { - type ImportOutcome, -} from "./StylingImportIssuesDialog"; +import StylingImportIssuesDialog from "./StylingImportIssuesDialog"; + +/** + * The single lifecycle of an export or import operation. Modeled as one union + * so illegal combinations (busy while a result dialog is open, or two result + * dialogs at once) are unrepresentable. Conflict confirmation is handled inline + * via {@link useConfirm}, so it is not a state here. + */ +type StylingOpState = + | { status: "idle" } + | { status: "busy" } + | { status: "importDone"; issues: ImportIssue[] } + | { status: "importError"; message: string } + | { status: "exportError"; message: string }; + +const IDLE: StylingOpState = { status: "idle" }; +const BUSY: StylingOpState = { status: "busy" }; export default function SettingsStyles() { + const confirm = useConfirm(); const { parseFile, getConflicts, applyImport } = useImportStylingFile(); const { getExportPayload } = useExportStylingFile(); const setUserVertexStyles = useSetAtom(userVertexStylesAtom); @@ -59,45 +79,70 @@ export default function SettingsStyles() { const importedEdgeStyles = useAtomValue(importedEdgeStylesAtom); const fileInputRef = useRef(null); - const [importOutcome, setImportOutcome] = useState( - null, - ); - const [exportError, setExportError] = useState(null); - const [isBusy, setIsBusy] = useState(false); - const [confirmReset, setConfirmReset] = useState(false); - const [confirmClear, setConfirmClear] = useState(false); - const [pendingImport, setPendingImport] = useState<{ - parsed: StylingParseResult; - conflicts: ImportConflicts; - } | null>(null); + const [op, setOp] = useState(IDLE); - function handleResetCustomStyles() { + async function handleResetCustomStyles() { + const confirmed = await confirm({ + title: "Reset All Custom Styles", + description: ( + <> +

    + This will clear all your per-type style customizations. Your + imported defaults will remain. Consider exporting first. +

    +

    This cannot be undone.

    + + ), + icon: , + mediaClassName: "bg-danger-subtle text-danger", + confirmLabel: "Reset Custom Styles", + confirmVariant: "primary-danger", + }); + if (!confirmed) return; setUserVertexStyles(new Map()); setUserEdgeStyles(new Map()); - setConfirmReset(false); } - function handleClearImportedDefaults() { + async function handleClearImportedDefaults() { + const confirmed = await confirm({ + title: "Reset Imported Defaults", + description: + "This will remove all imported default styles. Your custom styles will remain. You can re-import a file at any time.", + icon: , + mediaClassName: "bg-danger-subtle text-danger", + confirmLabel: "Reset Imported Defaults", + confirmVariant: "primary-danger", + }); + if (!confirmed) return; setImportedVertexStyles(new Map()); setImportedEdgeStyles(new Map()); - setConfirmClear(false); } async function handleExport() { - setIsBusy(true); + setOp(BUSY); try { const payload = getExportPayload(); - const envelope = createFileEnvelope("styling-export", "1.0", payload); + const envelope = createFileEnvelope( + STYLING_EXPORT_KIND, + STYLING_EXPORT_VERSION, + payload, + ); const blob = toJsonFileData(envelope); await saveFile(blob, "graph-explorer-styles.json"); + setOp(IDLE); } catch (e: unknown) { - if (e instanceof Error && e.name === "AbortError") return; + if (e instanceof Error && e.name === "AbortError") { + setOp(IDLE); + return; + } logger.warn("Export failed", e); - setExportError( - e instanceof Error ? e.message : "The styles file could not be saved.", - ); - } finally { - setIsBusy(false); + setOp({ + status: "exportError", + message: + e instanceof Error + ? e.message + : "The styles file could not be saved.", + }); } } @@ -105,7 +150,7 @@ export default function SettingsStyles() { const file = e.target.files?.[0]; if (!file) return; - setIsBusy(true); + setOp(BUSY); try { const parsed = await parseFile(file); @@ -114,29 +159,37 @@ export default function SettingsStyles() { } const conflicts = getConflicts(parsed); - if (conflicts.vertices.length > 0 || conflicts.edges.length > 0) { - setPendingImport({ parsed, conflicts }); - } else { - const issues = applyImport(parsed); - setImportOutcome({ issues }); + const conflictCount = conflicts.vertices.length + conflicts.edges.length; + if (conflictCount > 0) { + const confirmed = await confirm({ + title: `Replace ${conflictCount} existing ${conflictCount === 1 ? "default" : "defaults"}?`, + description: + "The imported file will overwrite these existing imported defaults. New types will be added alongside them.", + icon: , + mediaClassName: "bg-primary-subtle text-primary-foreground", + confirmLabel: "Import & Replace", + body: , + }); + if (!confirmed) { + setOp(IDLE); + return; + } } + + const issues = applyImport(parsed); + setOp({ status: "importDone", issues }); } catch (error: unknown) { - const message = - error instanceof Error - ? error.message - : "An unexpected error occurred while importing the file."; - setImportOutcome({ error: message }); - } finally { - setIsBusy(false); + setOp({ + status: "importError", + message: + error instanceof Error + ? error.message + : "An unexpected error occurred while importing the file.", + }); } } - function handleConfirmImport() { - if (!pendingImport) return; - const issues = applyImport(pendingImport.parsed); - setPendingImport(null); - setImportOutcome({ issues }); - } + const isBusy = op.status === "busy"; return ( @@ -158,7 +211,7 @@ export default function SettingsStyles() { - ); -} - -function renderWithProvider(onResult: (confirmed: boolean) => void) { - return render( - - - , - ); -} - -describe("ConfirmDialogProvider", () => { - test("resolves true when the user confirms", async () => { - const user = userEvent.setup(); - const onResult = vi.fn(); - renderWithProvider(onResult); - - await user.click(screen.getByRole("button", { name: "Open" })); - expect(screen.getByText("Delete everything?")).toBeInTheDocument(); - - await user.click(screen.getByRole("button", { name: "Delete" })); - - expect(onResult).toHaveBeenCalledWith(true); - expect(screen.queryByText("Delete everything?")).not.toBeInTheDocument(); - }); - - test("resolves false when the user cancels", async () => { - const user = userEvent.setup(); - const onResult = vi.fn(); - renderWithProvider(onResult); - - await user.click(screen.getByRole("button", { name: "Open" })); - await user.click(screen.getByRole("button", { name: "Keep" })); - - expect(onResult).toHaveBeenCalledWith(false); - expect(screen.queryByText("Delete everything?")).not.toBeInTheDocument(); - }); - - test("resolves false when the dialog is dismissed via escape", async () => { - const user = userEvent.setup(); - const onResult = vi.fn(); - renderWithProvider(onResult); - - await user.click(screen.getByRole("button", { name: "Open" })); - await user.keyboard("{Escape}"); - - expect(onResult).toHaveBeenCalledWith(false); - }); - - test("each confirmation resolves its own promise", async () => { - const user = userEvent.setup(); - const results: boolean[] = []; - renderWithProvider(confirmed => results.push(confirmed)); - - await user.click(screen.getByRole("button", { name: "Open" })); - await user.click(screen.getByRole("button", { name: "Keep" })); - - await user.click(screen.getByRole("button", { name: "Open" })); - await user.click(screen.getByRole("button", { name: "Delete" })); - - expect(results).toEqual([false, true]); - }); - - test("useConfirm throws when used outside the provider", () => { - function Orphan() { - useConfirm(); - return null; - } - const spy = vi.spyOn(console, "error").mockImplementation(() => {}); - expect(() => render()).toThrow( - "useConfirm must be used within a ConfirmDialogProvider", - ); - spy.mockRestore(); - }); -}); diff --git a/packages/graph-explorer/src/components/ConfirmDialog.tsx b/packages/graph-explorer/src/components/ConfirmDialog.tsx deleted file mode 100644 index 7e4102534..000000000 --- a/packages/graph-explorer/src/components/ConfirmDialog.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import type { ReactNode } from "react"; - -import { createContext, use, useCallback, useRef, useState } from "react"; - -import type { Button } from "./Button"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogMedia, - AlertDialogTitle, -} from "./AlertDialog"; - -type ButtonVariant = React.ComponentProps["variant"]; - -export type ConfirmOptions = { - title: ReactNode; - description?: ReactNode; - /** Extra content rendered between the description and the footer. */ - body?: ReactNode; - icon?: ReactNode; - /** Tailwind classes for the icon media element (e.g. danger vs primary). */ - mediaClassName?: string; - confirmLabel?: string; - cancelLabel?: string; - confirmVariant?: ButtonVariant; -}; - -/** - * Resolves a confirmation dialog imperatively. `confirm(options)` returns a - * promise that resolves `true` when the user confirms and `false` when they - * cancel or dismiss. This keeps a confirm step inline in an async flow instead - * of splitting it across pending-state and a separate handler. - */ -export type ConfirmFn = (options: ConfirmOptions) => Promise; - -const ConfirmContext = createContext(null); - -export function useConfirm(): ConfirmFn { - const confirm = use(ConfirmContext); - if (!confirm) { - throw new Error("useConfirm must be used within a ConfirmDialogProvider"); - } - return confirm; -} - -export function ConfirmDialogProvider({ children }: { children: ReactNode }) { - const [pending, setPending] = useState(null); - const resolveRef = useRef<((confirmed: boolean) => void) | null>(null); - - const confirm = useCallback(options => { - return new Promise(resolve => { - resolveRef.current = resolve; - setPending(options); - }); - }, []); - - const settle = useCallback((confirmed: boolean) => { - resolveRef.current?.(confirmed); - resolveRef.current = null; - setPending(null); - }, []); - - return ( - - {children} - {pending ? ( - !open && settle(false)}> - - - {pending.icon ? ( - - {pending.icon} - - ) : null} - {pending.title} - {pending.description ? ( - - {pending.description} - - ) : null} - - {pending.body} - - settle(false)}> - {pending.cancelLabel ?? "Cancel"} - - settle(true)} - > - {pending.confirmLabel ?? "Confirm"} - - - - - ) : null} - - ); -} diff --git a/packages/graph-explorer/src/components/index.ts b/packages/graph-explorer/src/components/index.ts index 8a31cf32b..46d05448c 100644 --- a/packages/graph-explorer/src/components/index.ts +++ b/packages/graph-explorer/src/components/index.ts @@ -4,8 +4,6 @@ export * from "./Button"; export * from "./Checkbox"; -export * from "./ConfirmDialog"; - export { default as CheckboxList } from "./CheckboxList"; export * from "./CheckboxList"; diff --git a/packages/graph-explorer/src/core/styling/index.ts b/packages/graph-explorer/src/core/styling/index.ts index 98dc5db77..80df56290 100644 --- a/packages/graph-explorer/src/core/styling/index.ts +++ b/packages/graph-explorer/src/core/styling/index.ts @@ -5,13 +5,17 @@ export { STYLING_EXPORT_VERSION, StylingParseError, toFileEntry, + type EntryImportIssue, + type GeneralImportIssue, type ImportIssue, type StylingExportPayload, type StylingParseResult, type VertexStyleFileEntry, } from "./stylingParser"; export { - useImportStylingFile, + getStylingConflicts, + parseStylingFile, + useApplyStylingImport, useExportStylingFile, type ImportConflicts, } from "./useStylingImportExport"; diff --git a/packages/graph-explorer/src/core/styling/roundTrip.test.ts b/packages/graph-explorer/src/core/styling/roundTrip.test.ts index 0fef8eae4..a86a6c95c 100644 --- a/packages/graph-explorer/src/core/styling/roundTrip.test.ts +++ b/packages/graph-explorer/src/core/styling/roundTrip.test.ts @@ -19,8 +19,9 @@ import { import { renderHookWithJotai } from "@/utils/testing"; import { + parseStylingFile, + useApplyStylingImport, useExportStylingFile, - useImportStylingFile, } from "./useStylingImportExport"; vi.mock("@/utils/fileData", () => ({ @@ -97,13 +98,11 @@ describe("round-trip: export then import", () => { store.set(userEdgeStylesAtom, new Map()); const { result: importResult } = renderHookWithJotai(() => - useImportStylingFile(), + useApplyStylingImport(), ); - const parseOut = await importResult.current.parseFile(file); + const parseOut = await parseStylingFile(file); - importResult.current.applyImport(parseOut); - - expect(parseOut.issues).toStrictEqual([]); + importResult.current(parseOut); const importedVertices = store.get(importedVertexStylesAtom); expect(importedVertices.get(createVertexType("airport"))).toStrictEqual({ @@ -180,13 +179,11 @@ describe("round-trip: export then import", () => { store.set(userVertexStylesAtom, new Map()); const { result: importResult } = renderHookWithJotai(() => - useImportStylingFile(), + useApplyStylingImport(), ); - const parseOut = await importResult.current.parseFile(file); - - importResult.current.applyImport(parseOut); + const parseOut = await parseStylingFile(file); - expect(parseOut.issues).toStrictEqual([]); + importResult.current(parseOut); const imported = store.get(importedVertexStylesAtom); expect( @@ -255,13 +252,11 @@ describe("round-trip: export then import", () => { store.set(userVertexStylesAtom, new Map()); const { result: importResult } = renderHookWithJotai(() => - useImportStylingFile(), + useApplyStylingImport(), ); - const parseOut = await importResult.current.parseFile(file); + const parseOut = await parseStylingFile(file); - importResult.current.applyImport(parseOut); - - expect(parseOut.issues).toStrictEqual([]); + importResult.current(parseOut); const imported = store.get(importedVertexStylesAtom); expect(imported.get(createVertexType("CustomNode"))!.iconUrl).toBe( @@ -307,13 +302,11 @@ describe("round-trip: export then import", () => { store.set(userVertexStylesAtom, new Map()); const { result: importResult } = renderHookWithJotai(() => - useImportStylingFile(), + useApplyStylingImport(), ); - const parseOut = await importResult.current.parseFile(file); - - importResult.current.applyImport(parseOut); + const parseOut = await parseStylingFile(file); - expect(parseOut.issues).toStrictEqual([]); + importResult.current(parseOut); const imported = store.get(importedVertexStylesAtom); expect(imported.get(createVertexType("PngNode"))).toStrictEqual({ @@ -330,7 +323,7 @@ describe("round-trip: export then import", () => { }); }); - test("HTTP/HTTPS URL icons are dropped on import (no outbound requests)", async () => { + test("HTTP/HTTPS URL icons reject the whole import (no outbound requests)", async () => { const httpsUrl = "https://cdn.example.com/icons/airport.png"; const httpUrl = "http://internal.corp/icon.svg"; @@ -365,22 +358,19 @@ describe("round-trip: export then import", () => { store.set(userVertexStylesAtom, new Map()); - const { result: importResult } = renderHookWithJotai(() => - useImportStylingFile(), - ); - const parseOut = await importResult.current.parseFile(file); - - importResult.current.applyImport(parseOut); - - // Both icons fail the allowlist, so each entry's only field is dropped and - // the icon issue is reported. iconImageType still survives for HttpsNode. - expect(parseOut.issues.map(i => i.field)).toStrictEqual(["icon", "icon"]); + // Both icons fail the allowlist. Import is atomic, so the whole file is + // rejected and both icon issues are reported; nothing is persisted, so no + // outbound request can be triggered. + await expect(parseStylingFile(file)).rejects.toMatchObject({ + issues: expect.arrayContaining([ + expect.objectContaining({ field: "icon", typeName: "HttpsNode" }), + expect.objectContaining({ field: "icon", typeName: "HttpNode" }), + ]), + }); const imported = store.get(importedVertexStylesAtom); - expect( - imported.get(createVertexType("HttpsNode"))?.iconUrl, - ).toBeUndefined(); - expect(imported.get(createVertexType("HttpNode"))?.iconUrl).toBeUndefined(); + expect(imported.has(createVertexType("HttpsNode"))).toBe(false); + expect(imported.has(createVertexType("HttpNode"))).toBe(false); }); test("mixed icon types in a single export all survive round-trip", async () => { @@ -432,13 +422,11 @@ describe("round-trip: export then import", () => { store.set(userVertexStylesAtom, new Map()); const { result: importResult } = renderHookWithJotai(() => - useImportStylingFile(), + useApplyStylingImport(), ); - const parseOut = await importResult.current.parseFile(file); - - importResult.current.applyImport(parseOut); + const parseOut = await parseStylingFile(file); - expect(parseOut.issues).toStrictEqual([]); + importResult.current(parseOut); const imported = store.get(importedVertexStylesAtom); expect(imported.get(createVertexType("LucideType"))!.iconUrl).toBe( @@ -508,12 +496,10 @@ describe("round-trip: export then import", () => { type: "application/json", }); - const { result } = renderHookWithJotai(() => useImportStylingFile()); - const parseOut = await result.current.parseFile(file); - - result.current.applyImport(parseOut); + const { result } = renderHookWithJotai(() => useApplyStylingImport()); + const parseOut = await parseStylingFile(file); - expect(parseOut.issues).toStrictEqual([]); + result.current(parseOut); const store = getAppStore(); const imported = store.get(importedVertexStylesAtom); diff --git a/packages/graph-explorer/src/core/styling/stylingParser.test.ts b/packages/graph-explorer/src/core/styling/stylingParser.test.ts index cfa6ccb5f..815022979 100644 --- a/packages/graph-explorer/src/core/styling/stylingParser.test.ts +++ b/packages/graph-explorer/src/core/styling/stylingParser.test.ts @@ -2,7 +2,20 @@ import { describe, expect, test } from "vitest"; import { createVertexType, createEdgeType } from "@/core/entities"; -import { parseStylingPayload } from "./stylingParser"; +import { parseStylingPayload, StylingParseError } from "./stylingParser"; + +/** Parses, asserting failure, and returns the thrown issues for inspection. */ +function parseExpectingIssues(rawData: unknown) { + try { + parseStylingPayload(rawData); + } catch (error) { + if (error instanceof StylingParseError) { + return error.issues; + } + throw error; + } + throw new Error("Expected parseStylingPayload to throw"); +} describe("style enum validation", () => { const SHAPES = [ @@ -52,16 +65,14 @@ describe("style enum validation", () => { edges: {}, }); expect(result.vertexStyles.get(createVertexType("A"))!.shape).toBe(shape); - expect(result.issues).toHaveLength(0); }); test("rejects an unknown shape", () => { - const result = parseStylingPayload({ + const issues = parseExpectingIssues({ vertices: { A: { shape: "blob" } }, edges: {}, }); - expect(result.vertexStyles.has(createVertexType("A"))).toBe(false); - expect(result.issues[0]).toMatchObject({ field: "shape" }); + expect(issues[0]).toMatchObject({ scope: "entry", field: "shape" }); }); test.each(LINE_STYLES)("accepts border line style %s", borderStyle => { @@ -72,7 +83,6 @@ describe("style enum validation", () => { expect(result.vertexStyles.get(createVertexType("A"))!.borderStyle).toBe( borderStyle, ); - expect(result.issues).toHaveLength(0); }); test.each(ARROW_STYLES)("accepts arrow style %s", sourceArrowStyle => { @@ -83,47 +93,64 @@ describe("style enum validation", () => { expect(result.edgeStyles.get(createEdgeType("A"))!.sourceArrowStyle).toBe( sourceArrowStyle, ); - expect(result.issues).toHaveLength(0); }); test("rejects an unknown arrow style", () => { - const result = parseStylingPayload({ + const issues = parseExpectingIssues({ vertices: {}, edges: { A: { sourceArrowStyle: "swoosh" } }, }); - expect(result.edgeStyles.has(createEdgeType("A"))).toBe(false); - expect(result.issues[0]).toMatchObject({ field: "sourceArrowStyle" }); + expect(issues[0]).toMatchObject({ + scope: "entry", + field: "sourceArrowStyle", + }); }); }); describe("parseStylingPayload", () => { - test("throws for non-object input", () => { - expect(() => parseStylingPayload(null)).toThrow(); - expect(() => parseStylingPayload("string")).toThrow(); - expect(() => parseStylingPayload(42)).toThrow(); - expect(() => parseStylingPayload(undefined)).toThrow(); + test("throws a general issue for non-object input", () => { + for (const input of [null, "string", 42, undefined]) { + const issues = parseExpectingIssues(input); + expect(issues[0].scope).toBe("general"); + } }); test("throws when vertices/edges keys are missing", () => { - expect(() => parseStylingPayload({})).toThrow(); - expect(() => parseStylingPayload({ vertices: {} })).toThrow(); - expect(() => parseStylingPayload({ edges: {} })).toThrow(); + expect(parseExpectingIssues({})[0].scope).toBe("general"); + expect(parseExpectingIssues({ vertices: {} })[0].scope).toBe("general"); + expect(parseExpectingIssues({ edges: {} })[0].scope).toBe("general"); }); - test("throws when vertices is not an object", () => { - expect(() => parseStylingPayload({ vertices: [], edges: {} })).toThrow(); + test("throws a general issue when vertices is not an object", () => { + const issues = parseExpectingIssues({ vertices: [], edges: {} }); + expect(issues[0]).toMatchObject({ scope: "general", location: "vertices" }); }); - test("throws when edges is not an object", () => { - expect(() => parseStylingPayload({ vertices: {}, edges: [] })).toThrow(); + test("throws a general issue when edges is not an object", () => { + const issues = parseExpectingIssues({ vertices: {}, edges: [] }); + expect(issues[0]).toMatchObject({ scope: "general", location: "edges" }); }); - test("parses empty vertices and edges with no issues", () => { + test("treats a non-object entry as a malformed entry issue", () => { + const issues = parseExpectingIssues({ + vertices: { A: 42 }, + edges: {}, + }); + expect(issues[0]).toStrictEqual({ + scope: "entry", + entityType: "vertex", + typeName: "A", + field: "(entry)", + value: 42, + message: expect.any(String), + }); + }); + + test("parses empty vertices and edges", () => { const result = parseStylingPayload({ vertices: {}, edges: {} }); expect(result).toStrictEqual({ vertexStyles: new Map(), edgeStyles: new Map(), - issues: [], }); }); @@ -147,7 +174,6 @@ describe("parseStylingPayload", () => { edges: {}, }); - expect(result.issues).toStrictEqual([]); expect(result.vertexStyles.size).toBe(1); const personStyle = result.vertexStyles.get(createVertexType("Person")); expect(personStyle).toStrictEqual({ @@ -187,7 +213,6 @@ describe("parseStylingPayload", () => { }); expect(result.vertexStyles.has(createVertexType("Airport"))).toBe(false); - expect(result.issues).toStrictEqual([]); }); test("parses valid edge entry with all fields", () => { @@ -211,7 +236,6 @@ describe("parseStylingPayload", () => { }, }); - expect(result.issues).toStrictEqual([]); expect(result.edgeStyles.size).toBe(1); const knowsStyle = result.edgeStyles.get(createEdgeType("knows")); expect(knowsStyle).toStrictEqual({ @@ -231,10 +255,10 @@ describe("parseStylingPayload", () => { }); }); - test("drops the whole entry when any known field is invalid", () => { - // Whole-entry validation: one bad field rejects the entry rather than - // leaving it half-styled. The good `color` is dropped along with it. - const result = parseStylingPayload({ + test("rejects the whole file when any known field is invalid", () => { + // Atomic validation: one bad field rejects the entire import, reported as + // an entry issue. Nothing is persisted. + const issues = parseExpectingIssues({ vertices: { Airport: { color: "#ff0000", @@ -245,15 +269,14 @@ describe("parseStylingPayload", () => { edges: {}, }); - expect(result.vertexStyles.has(createVertexType("Airport"))).toBe(false); - expect(result.issues).toStrictEqual([ - { - entityType: "vertex", - typeName: "Airport", - field: expect.any(String), - message: expect.any(String), - }, - ]); + expect(issues).toContainEqual({ + scope: "entry", + entityType: "vertex", + typeName: "Airport", + field: expect.any(String), + value: expect.anything(), + message: expect.any(String), + }); }); test("ignores unknown fields without reporting them", () => { @@ -269,7 +292,6 @@ describe("parseStylingPayload", () => { type: createVertexType("Person"), color: "#00ff00", }); - expect(result.issues).toStrictEqual([]); }); test("imports a same-version file from a newer build, ignoring its new fields", () => { @@ -287,7 +309,6 @@ describe("parseStylingPayload", () => { type: createVertexType("Person"), color: "#abc", }); - expect(result.issues).toStrictEqual([]); }); test("drops entries with no recognized fields", () => { @@ -299,7 +320,6 @@ describe("parseStylingPayload", () => { }); expect(result.vertexStyles.has(createVertexType("Ghost"))).toBe(false); - expect(result.issues).toStrictEqual([]); }); describe("icon allowlist", () => { @@ -311,7 +331,6 @@ describe("parseStylingPayload", () => { expect(result.vertexStyles.get(createVertexType("A"))!.iconUrl).toBe( "lucide:arrow-right", ); - expect(result.issues).toStrictEqual([]); }); test("accepts data:image/ URIs", () => { @@ -326,53 +345,58 @@ describe("parseStylingPayload", () => { }); test("rejects http:// URLs (no outbound requests from imports)", () => { - const result = parseStylingPayload({ + const issues = parseExpectingIssues({ vertices: { A: { icon: "http://example.com/icon.png" } }, edges: {}, }); - expect(result.vertexStyles.has(createVertexType("A"))).toBe(false); - expect(result.issues[0].field).toBe("icon"); + expect(issues[0]).toMatchObject({ scope: "entry", field: "icon" }); }); test("rejects https:// URLs (no outbound requests from imports)", () => { - const result = parseStylingPayload({ + const issues = parseExpectingIssues({ vertices: { A: { icon: "https://cdn.example.com/icon.svg" } }, edges: {}, }); - expect(result.vertexStyles.has(createVertexType("A"))).toBe(false); - expect(result.issues[0].field).toBe("icon"); + expect(issues[0]).toMatchObject({ scope: "entry", field: "icon" }); }); test("rejects bare icon names", () => { - const result = parseStylingPayload({ + const issues = parseExpectingIssues({ vertices: { A: { icon: "user" } }, edges: {}, }); - expect(result.vertexStyles.has(createVertexType("A"))).toBe(false); - expect(result.issues[0]).toStrictEqual({ + expect(issues[0]).toStrictEqual({ + scope: "entry", entityType: "vertex", typeName: "A", field: "icon", + value: "user", message: expect.stringContaining("allowlist"), }); }); + test("reports the rejected value", () => { + const issues = parseExpectingIssues({ + vertices: { A: { icon: "https://evil.example/x.svg" } }, + edges: {}, + }); + expect(issues[0].value).toBe("https://evil.example/x.svg"); + }); + test("rejects javascript: protocol", () => { - const result = parseStylingPayload({ + const issues = parseExpectingIssues({ vertices: { A: { icon: "javascript:alert(1)" } }, edges: {}, }); - expect(result.vertexStyles.has(createVertexType("A"))).toBe(false); - expect(result.issues[0].field).toBe("icon"); + expect(issues[0]).toMatchObject({ scope: "entry", field: "icon" }); }); test("rejects lucide: with invalid characters", () => { - const result = parseStylingPayload({ + const issues = parseExpectingIssues({ vertices: { A: { icon: "lucide:`, + ); + + expect(decoded).not.toContain(" { + const decoded = await renderCustomSvg( + ``, + ); + + expect(decoded).not.toContain("onload"); + expect(decoded).toContain(" but keeps the rest of the drawing", async () => { + const decoded = await renderCustomSvg( + `hi`, + ); + + expect(decoded).not.toContain("foreignObject"); + expect(decoded).toContain(" element", async () => { + const decoded = await renderCustomSvg( + ``, + ); + + expect(decoded).toContain(" without fetching it", async () => { + const externalHref = "http://external.example/icon.png"; + const decoded = await renderCustomSvg( + ``, + ); + + expect(decoded).toContain(externalHref); + expect(fetchMock).toBeCalledTimes(1); + expect(fetchMock).not.toBeCalledWith(externalHref); + }); + }); }); /** Wraps SVG string in another SVG element matching what is expected. */ From be5aae0528d324047b902d0e1883a2615e2f3dcb Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Tue, 7 Jul 2026 17:30:36 -0500 Subject: [PATCH 42/42] Seed shared styles before render in the conflict-prompt test The conflict test seeded sharedVertexStylesAtom after render, so the load action closed over the empty initial map until an async re-render flushed. On a loaded runner the upload dispatch could win that race, see no conflicts, and skip straight to the success dialog, timing out the conflict-prompt assertion. renderButton now seeds before render so the first render already reflects the existing style. --- .../routes/Settings/LoadStylesButton.test.tsx | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/graph-explorer/src/routes/Settings/LoadStylesButton.test.tsx b/packages/graph-explorer/src/routes/Settings/LoadStylesButton.test.tsx index 941209411..d99d03f15 100644 --- a/packages/graph-explorer/src/routes/Settings/LoadStylesButton.test.tsx +++ b/packages/graph-explorer/src/routes/Settings/LoadStylesButton.test.tsx @@ -7,7 +7,7 @@ import { describe, expect, test, vi } from "vitest"; import type { VertexPreferencesStorageModel } from "@/core/StateProvider/userPreferences"; import { TooltipProvider } from "@/components"; -import { getAppStore } from "@/core"; +import { type AppStore, getAppStore } from "@/core"; import { createVertexType, type VertexType } from "@/core/entities"; import { createFileEnvelope } from "@/core/fileEnvelope"; import { createQueryClient } from "@/core/queryClient"; @@ -22,8 +22,15 @@ vi.mock("@/utils/fileData", () => ({ saveFile: vi.fn(), })); -function renderButton() { +/** + * Renders the button, optionally seeding the shared-styles atoms first. The + * seed happens before render so the component's first render already reflects + * it — seeding after render leaves the load action closing over the stale + * initial value until an async re-render flushes, which races the upload. + */ +function renderButton(seed?: (store: AppStore) => void) { const store = getAppStore(); + seed?.(store); const queryClient = createQueryClient(); render( @@ -90,15 +97,16 @@ describe("LoadStylesButton", () => { test("prompts before overwriting an existing shared style, then completes on confirm", async () => { const user = userEvent.setup(); - const store = renderButton(); - store.set( - sharedVertexStylesAtom, - new Map([ - [ - createVertexType("Person"), - { type: createVertexType("Person"), color: "#old" }, - ], - ]), + const store = renderButton(store => + store.set( + sharedVertexStylesAtom, + new Map([ + [ + createVertexType("Person"), + { type: createVertexType("Person"), color: "#old" }, + ], + ]), + ), ); await user.upload(