diff --git a/AGENTS.md b/AGENTS.md index fb9bd94eb..97d79fce4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,11 @@ Correct behavior is necessary but not sufficient — structural quality is a har - Prefer named function syntax over anonymous arrow functions for module-level declarations (`function handleClick() {}`, not `const handleClick = () => {}`). Arrow functions inside a function body are fine. - Use an explicit type alias instead of `ReturnType` when one exists (e.g. `AppStore`, not `ReturnType`) - Prefer a branded type over a raw `string`/`number` whenever the value is used for a lookup or passed to a function expecting a value that represents a specific concept — an ID, a node/edge label, a type name, etc. Construct them with their creator function (e.g. `createVertexId()`); never cast a bare string. This makes "which kind of string is this" a compile-time guarantee. +- Prefer Zod at boundaries to enforce contract and strong typing - Don't change the VS Code setting `typescript.autoClosingTags` +- Prefer throwing upward over local error laundering +- Prefer named domain types over `Record` +- Do not encode uncertainty as adapters, defaults, optionals, spreads, or catch blocks. Resolve it into the owned contract. ## Git diff --git a/CONTEXT.md b/CONTEXT.md index f786449c7..d4205429c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -63,8 +63,22 @@ 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 → Shared 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: (3) **User Custom Styles** — per-type edits made in the style dialogs, (2) **Shared Styles** — loaded from a file via Settings, (1) **App Default Styles** — hardcoded in the codebase (`defaultVertexPreferences` / `defaultEdgePreferences`). The layers below User Custom Styles (1–2) collectively are **Default Styles** — what a per-type "Reset to Default" restores to (it clears the user's edit, revealing the Shared or App Default beneath). The verb is **customize**; the UI shorthand is **custom styles** / **shared styles**. +_Avoid_: Imported Default Styles, imported defaults (the layer is **Shared Styles**); 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 Custom Styles" in Settings) reveals Shared Styles beneath. + +**Shared Styles**: +The second-highest-precedence layer in the Styles Cascade (below User Custom, above App Default). Loaded from a styling file via the Settings → Styles screen. Stored in `sharedVertexStylesAtom` (key `"shared-vertex-styles"`) and `sharedEdgeStylesAtom` (key `"shared-edge-styles"`). Non-destructive to User Custom Styles — loading writes only this layer. Clearing these ("Reset Shared Styles" in Settings) falls through to App Default Styles. Named for their purpose: a user **saves** their styles to a file and others **load** it to get the same look. +_Avoid_: Imported Default Styles, imported defaults (renamed — "shared" names the purpose and reads better) + +**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. @@ -78,6 +92,18 @@ _Avoid_: Model, structure The on-disk JSON format a user gets when they export a Connection (`saveConfigurationToFile`), and which import consumes. It bundles the connection config with a snapshot of the Schema (`lastUpdate` is an ISO string on disk). A single Zod schema in `parseConnectionFile.ts` is the source of truth: both the writer and the importer target the same inferred type (`ExportedConnectionFile`). The writer assigns a `Date` for `lastUpdate` and `JSON.stringify` serializes it to the ISO string; the parser coerces it back via `z.coerce.date()`. The schema is lenient (every level is a `looseObject`), so unknown and legacy fields — styling, `__inferred`/`__matches` on prefixes, attribute `dataType` — pass through untouched. It is intentionally decoupled from the in-memory configuration and from the IndexedDB storage shape, so the wire format can evolve independently. On import it is split — the connection lands in `configurationAtom`, the schema in `schemaAtom`. _Avoid_: Configuration file (the wire format is not the in-memory or persisted shape) +**File Envelope**: +The shared `{ meta, data }` wrapper for typed JSON export files (`core/fileEnvelope/`). `meta.kind` discriminates the file type and `meta.version` carries the **Format Generation** as a **Wire Version** (see both terms); `parseFileEnvelope` guards both — rejecting a wrong-kind file and one whose version is newer than the build supports — before the caller validates `data` per kind. The write contract (`createFileEnvelope`) also stamps diagnostic `timestamp`/`source`/`sourceVersion`; the read schema keeps only `kind`/`version` and strips the rest (along with any unknown field). Consumers today: **styling-export** and **graph-export**. The **Exported Connection File** is the outlier — it predates the envelope and is a flat shape, not wrapped in one. +_Avoid_: Header, wrapper, metadata block + +**Format Generation**: +The integer identifying a File Envelope payload's schema version, bumped **only** on a breaking change (a renamed or removed field); additive changes ship as new optional fields and do not bump it. It is the value `parseFileEnvelope` guards (reject if newer than the build supports) and the consumer's `…ForVersion` switch dispatches on. Distinct from the app version (`sourceVersion`, diagnostic only). Each format owns one named constant for it (`GRAPH_EXPORT_VERSION`, `STYLING_EXPORT_VERSION`). +_Avoid_: Version (unqualified — ambiguous with the app version and the Wire Version) + +**Wire Version**: +The version value as literally written in a file's `meta.version`, which normalizes to the **Format Generation** on read (`EnvelopeVersion` = `number | "1.0"`). Usually equals the generation integer, but **graph-export** writes generation 1 as the decimal string `"1.0"` (`GRAPH_EXPORT_WIRE_VERSION`) so builds predating the envelope — which validate `version` as the literal `"1.0"` — can still import files this build writes. **styling-export** has no such installed base and writes the integer `1`. So a reader must not assume `meta.version` is an integer _on disk_ (it always is _after_ normalization). +_Avoid_: Storage version, on-disk version ("storage" means the IndexedDB/persisted shape; "on-disk" already names the whole wire format) + **Persistence Status**: The single, global state of whether the app's client-side data is safely written to IndexedDB — `idle | saving | failed`. One source of truth that the Persistence Status Indicator subscribes to. `idle` = nothing queued and everything durable (no separate "saved" state — visually identical to a fresh session). `saving` = at least one write queued or in flight, including retryable retries. `failed` = at least one terminal failure outstanding, carrying failure records for a drill-in detail view. Aggregated across all per-key write queues by precedence: any terminal → `failed`; else any in-flight → `saving`; else `idle`. A `failed` key clears on its next successful write; status returns to `idle` when no failure records remain. Deliberately **not** per-key: a failed write flips the whole status rather than naming which collection failed, because the user cannot act on an individual key (the storage layer retries on their behalf). Lives in a plain external store outside React/Jotai; the React edge bridges it via `useSyncExternalStore`. _Avoid_: Save state (ambiguous with Session) @@ -116,6 +142,7 @@ _Avoid_: Save-status indicator - "Configuration" was used to mean both **Connection** and the bundled object (connection + schema + Styles) — resolved: **Connection** is canonical, "Configuration" is legacy. - "User Preferences" / "preferences" was used for display customizations, but the code is mid-migration to "styles" (`userVertexStylesAtom` / `userEdgeStylesAtom`, `"user-vertex-styles"` / `"user-edge-styles"`) — resolved: **Styles** is canonical (with **Vertex Styles** / **Edge Styles** as instances); the `*Preferences*` code names are legacy being phased out. +- "Imported Default Styles" / "imported defaults" named the cascade's middle layer, and its file actions were "Import" / "Export" — resolved: the layer is **Shared Styles** ("shared" names the purpose and reads better than a bare adjective-noun), and the actions are **Load** / **Save** to match the house style used by the graph and configuration file features. "Default" now lives only in the cascade generally and the per-type "Reset to Default" button, not the layer name. Renamed end-to-end (atoms `shared*StylesAtom`, keys `"shared-*-styles"`) since the feature had not shipped — no migration. The core-styling plumbing keeps `import`-flavored names (`parseStylingFile`, `useApplyStylingImport`, `getStylingConflicts`) as an internal detail of the load action. - A Connection's data now has three distinct shapes that look similar but must not be conflated, each with its own explicit type: the **Exported Connection File** (`ExportedConnectionFile`, on-disk wire format), the in-memory merged configuration (`MergedConfiguration` — connection plus a live `Schema` with `lastUpdate` as a `Date`), and the persisted storage shape (`RawConfiguration` — connection only; the schema is stored separately in `schemaAtom`, never embedded). `mergeConfiguration` assembles a `MergedConfiguration` from a stored `RawConfiguration` and its active schema. Remaining work: `configurationAtom` still keys by `RawConfiguration`, to be migrated to a connection record. - "Node" means **Vertex** in code but is the preferred UI term for property graphs — resolved: use **Vertex** in code, "node" in UI copy. - "Attribute" vs "Property" — resolved: **Property** is canonical, "attribute" is legacy code term being phased out. diff --git a/docs/adr/20260624-shared-file-envelope.md b/docs/adr/20260624-shared-file-envelope.md new file mode 100644 index 000000000..6b4c1645f --- /dev/null +++ b/docs/adr/20260624-shared-file-envelope.md @@ -0,0 +1,80 @@ +# ADR — Shared file envelope for import/export formats + +- **Status:** Accepted +- **Date:** 2026-06-24 +- **Related:** ADR `styling-file-format` (first consumer beyond connection export). `saveConfigurationToFile` in `utils/saveConfigurationToFile.ts` (predecessor pattern that predates this envelope). + +## Context + +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 + 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 + +A shared **file envelope** primitive lives in `packages/graph-explorer/src/core/fileEnvelope/`. It provides: + +1. **Separate write and read contracts.** Every export writes the full metadata; the reader validates only the fields it acts on. + + ```ts + // Write contract — what createFileEnvelope stamps and what lands on disk: + meta: { kind: string, version: EnvelopeVersion, timestamp: string, source: string, sourceVersion: string } + // EnvelopeVersion = number | "1.0" — the wire form; see #4 for why "1.0" exists + + // Read contract — the only fields parseFileEnvelope validates and returns: + meta: { kind: string, version: number } // wire version normalized to the integer generation + data: T // payload — validated separately per kind + ``` + +2. **The read schema keeps only the load-bearing fields.** `kind` selects the expected file type and `version` selects the payload generation — those gate behavior. `timestamp`/`source`/`sourceVersion` are diagnostic only: nothing reads them back, so the read schema does not require or validate them and `z.object()` strips them (along with any unknown field). Consequences: a newer exporter that adds a `meta` field still imports cleanly (dropped, not rejected); a file that omits or malforms a diagnostic field still imports (it is not validated); and no field beyond `kind`/`version` survives into the parsed envelope. They remain part of the write contract because a human inspecting the file still wants them. + +3. **`kind` discriminates the file type.** Known kinds today: `"styling-export"`, `"graph-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 a single-integer format generation** of the payload schema, not the app version. The `sourceVersion` field carries the app version for diagnostics. It bumps **only on a breaking change** (a renamed or removed field). Additive changes ship as new optional fields and do **not** bump it — an older build simply ignores fields it doesn't know (see #5 and the styling ADR). There is therefore no "minor" concept. The read schema accepts either the integer generation or the `"1.0"` decimal string, normalizing the latter to `1`. Every other form — `"1.5"`, `"2.0"`, `"banana"`, `0` — fails structural parsing, so a malformed version is rejected at the schema level rather than by a later guard. + + **The version's on-disk _wire form_ is per-kind, and it is not always the integer.** The value a writer stamps (`EnvelopeVersion` = `z.input` of the version schema = `number | "1.0"`) is separate from the normalized integer generation the reader dispatches on: + + - **`graph-export`** has an installed base: builds predating this envelope validate `version` as the exact literal `"1.0"` (`z.literal("1.0")`). If new files wrote the integer `1`, those older builds would reject them — a silent forward-compatibility break in the direction the migration below did _not_ consider. So graph-export deliberately keeps writing the `"1.0"` decimal string at generation 1 (`GRAPH_EXPORT_WIRE_VERSION`), accepting a permanent decimal-string wart on that one format to preserve old-build read compatibility. Its generation integer (`GRAPH_EXPORT_VERSION = 1`) is a separate constant used only for the read guard and payload dispatch. + - **`styling-export`** is new and has no pre-integer installed base, so it writes the clean integer `1` (`STYLING_EXPORT_VERSION`), which serves as both wire form and generation. + + A future breaking **v2** of either kind writes the integer `2` — the decimal-string form is a generation-1 artifact of graph-export's history, not a scheme that continues. + +5. **`parseFileEnvelope` guards `kind` and `version`.** The caller passes an `EnvelopeExpectation` (`{ kind, supportedVersion }`). The envelope: + - rejects a mismatched `kind`, + - rejects a file whose `version` is newer than the build supports — turning "a file from a future Graph Explorer" into one clear error instead of a confusing partial import, + - accepts the same or an older generation; the per-kind payload parser ignores any unrecognized fields a newer build of the same generation may have added. + +6. **Helpers:** + - `createFileEnvelope(kind, version, data)` — stamps `timestamp`, `source` ("Graph Explorer"), and `sourceVersion` from the build constant. + - `parseFileEnvelope(blob, expectation)` — reads JSON, validates the outer envelope schema (including version format), guards kind + version, and returns the parsed `{ meta, data }` without validating the payload (caller validates `data` per `kind`). The normalized integer generation lives at `meta.version` for the consumer's version dispatch. + + Callers compose the envelope with their own file-save logic (e.g., `toJsonFileData` + `saveFile` from `utils/fileData`), and own a named generation constant (e.g. `STYLING_EXPORT_VERSION`) for the newest generation the build reads, so the bumpable value lives next to the payload schema. When a format's wire form differs from its generation integer (graph-export — see #4), it owns a second constant for the value written to disk (`GRAPH_EXPORT_WIRE_VERSION`). + +### Version dispatch in the consumer + +`parseFileEnvelope` exposes the validated integer generation at `meta.version`, and each consumer routes its payload through a `…ForVersion(version, data)` switch (`parseStylingPayloadForVersion`, `parseGraphExportPayloadForVersion`) that maps a generation to its parser. Today only `case 1` exists; the `default` throws `FileEnvelopeError`. This is deliberate: the envelope guard already rejects a generation newer than the build supports, so a supported-but-unhandled generation is a programming error, and the switch surfaces it loudly instead of letting the current schema silently strip renamed or retyped fields. When a breaking **v2** payload arrives, the consumer adds a `case 2` beside the existing one — the envelope stays a thin gate and 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 `{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 structurally identical to the envelope (same `kind`, same five meta fields, `version: "1.0"` on disk), so adopting the module is a pure refactor: files written before the migration still parse (the `"1.0"` string normalizes to `1`), and new files **keep writing `"1.0"`** so that builds predating the envelope — which validate `version` as the literal `"1.0"` — can still import files this build writes (see #4). Adopting `parseFileEnvelope` also upgraded the reader from an exact `z.literal("1.0")` match (which would have rejected any future generation) to the shared version guard, so this build reads both the `"1.0"` string and the integer form; only the wire form it _writes_ stays `"1.0"`. +- **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` + +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 and version-guarding for free — they only define their payload schema, `kind` string, and supported 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. +- A format's on-disk wire form can differ from its generation integer, and that choice belongs to the consumer, not the envelope. `graph-export` writes `"1.0"` to stay readable by pre-envelope builds; `styling-export` writes `1`. A future reader must not assume `meta.version` is always an integer on disk (it is, however, always an integer _after_ normalization). Do not "clean up" graph-export's `"1.0"` to an integer without accepting that older builds can no longer read new files. diff --git a/docs/adr/20260624-styling-file-format.md b/docs/adr/20260624-styling-file-format.md new file mode 100644 index 000000000..7f23933be --- /dev/null +++ b/docs/adr/20260624-styling-file-format.md @@ -0,0 +1,98 @@ +# ADR — Styling file format and atomic 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 shared 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`. The payload shape: + +```ts +type StylingExportPayload = { + vertices: Record; + edges: Record; +}; +``` + +`VertexStyleFileEntry` and `EdgeStyleFileEntry` are inferred from the entry schemas' `z.input` (pre-transform), so the file format has a single source of truth in `stylingParser.ts`. 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). `toVertexFileEntry` / `toEdgeFileEntry` produce these entries on export. + +### 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 any `image/*` subtype + +Any other value (bare names, `javascript:`, relative paths, non-image data URIs like `data:text/html`, **and `http(s)://` URLs**) fails validation, which rejects the whole file and reports one issue against that field. 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 known field, so a file supplying it has that key stripped silently and it never reaches storage. + +The image **subtype** is deliberately left open (any RFC-6838-shaped subtype: `svg+xml`, `png`, `bmp`, `x-icon`, …) rather than a fixed list. The uploader stores whatever `image/*` the browser reports (``), so a closed list would reject the app's own exports on re-import for any less-common type. This is not a weakening: the subtype is not a security boundary — SVG, the only script-capable type, is DOMPurify-sanitized at the render sink regardless of the declared subtype, and every other type renders as an inert raster ``. The one gate is `data:image/*;base64,` vs. a fetchable/executable scheme. **Do not narrow this to a "known-safe types" list** — it buys no security and silently breaks round-trip. The same allowlist is enforced at the upload seam (`isAllowedIconValue`), so what a user can upload and what import accepts come from one source and cannot drift. + +`iconImageType` is **not** a security boundary and is stored as a loose `string`, mirroring the storage model. It is descriptive metadata: the only consumer that reads it (`VertexIcon` / `renderNode`) does an exact match on `"image/svg+xml"` to decide whether to render through the inline-SVG path (which is DOMPurify-sanitized) versus the ``/raster path. Any other value simply takes the safer raster path, so constraining the field would not remove an XSS vector — the icon **data** is what matters, and that is guarded by the `icon` allowlist above. It is left unconstrained so that an icon uploaded with any browser-reported MIME type (the upload seam fills it from `file.type`) round-trips through export and re-import instead of rejecting the whole file. + +### Whole-file (atomic) parser contract + +The parser validates the **entire payload in one `safeParse`** by plugging the entry schemas straight into `z.record`. Import is atomic — the file imports in full or not at all. + +It is deliberately **not** a salvaging (per-field or per-entry) parser. Salvage only ever buys tolerance of _invalid_ data, which is not a goal for files the app itself produces — a bad value signals a corrupted or hand-edited file the user should fix, not one to import partially. The forward-compatibility that salvage might seem to offer (new entries, new optional fields from a newer build) is already served by `z.record` openness plus silent unknown-field stripping. So a single `safeParse` over the whole payload is both sufficient and simpler: one failure path, no hand-rolled validation loop, no dual throw-or-return error model. + +Contract: + +```ts +function parseStylingPayload(rawData: unknown): StylingParseResult; // throws StylingParseError on ANY invalid value + +type StylingParseResult = { + vertexStyles: Map; + edgeStyles: Map; +}; + +// StylingParseError.issues carries every offending location, already mapped for display. +type ImportIssue = GeneralImportIssue | EntryImportIssue; + +type GeneralImportIssue = { + scope: "general"; // a file-level structural problem (bad/missing container, non-payload input) + location: string; // e.g. "vertices" or "(file)" + value: unknown; + message: string; +}; + +type EntryImportIssue = { + scope: "entry"; + entityType: "vertex" | "edge"; + typeName: string; + field: string; // a field name, or "(entry)" when the whole entry is malformed + value: unknown; + message: string; +}; +``` + +- On any validation failure, throws `StylingParseError` carrying **all** issues (every Zod issue mapped, not just the first). Nothing is persisted. A successful return means the whole file is valid. +- Issues are classified by Zod issue **path depth**: a field failure (`["vertices", "Person", "color"]`) becomes an `EntryImportIssue`; a malformed entry (`["vertices", "Person"]`) becomes an `EntryImportIssue` with `field: "(entry)"`; a bad top-level container or non-payload input becomes a `GeneralImportIssue`. The path prefix supplies `entityType`/`typeName`/`field` directly — no separate per-entry loop assembles them. +- Unknown fields are **stripped silently** (Zod's default object behavior). No typo detection, no warning. This is the forward-compatibility mechanism: a file from a newer build of the same version carries extra fields the current build ignores, and new entry types are admitted by `z.record`. +- A `looseObject` is deliberately **not** used: it would let a file inject `iconUrl` (a remote URL) as a passthrough field and bypass the icon allowlist. Stripping unknowns closes that. +- Empty entries (no recognized fields after stripping) are dropped from the result — they would be no-ops. +- The envelope (`parseFileEnvelope`) and payload remain **separate** parse steps. The envelope gates `kind`/`version`/JSON validity as a one-message precondition (`FileEnvelopeError`); only a compatible envelope reaches payload validation. Merging them would run payload validation against the wrong file type and emit noisy field issues for what is really a "wrong file" error. + +### Export semantics + +Export produces the **effective merged view**: for each type that has either user or shared styling, the output is `{ ...sharedMap.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 **shared layer** (`sharedVertexStylesAtom` / `sharedEdgeStylesAtom`) — 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 shared layer rather than replacing it: each type in the file is set onto the current shared map, and types not present in the file are retained. When the file specifies a type that already has a shared style, that overlap is surfaced via `getStylingConflicts` and the user confirms before the overwrite proceeds. This lets a user assemble shared styles from several files while still being warned before any existing shared style is overwritten. + +## Consequences + +- **Atomic, not partial.** A file with any invalid value imports nothing and reports every offending location, so the user fixes the file and re-imports rather than landing in a partially-styled state they did not author. Because import either fully succeeds or fully fails, there is no "imported with warnings" middle state — the UI shows either a conflict prompt, a success, an envelope-level failure, or an invalid-values report. +- **Forward-compatible without minor versions.** Unknown fields are stripped in both `meta` and payload entries. A newer exporter that adds an optional field (e.g., `glow`) produces a file that older importers consume cleanly, ignoring the field — so additive changes never bump the version (see the shared-envelope ADR). +- **No XSS surface.** Icon URLs are allowlisted; only a validated `icon` becomes the stored `iconUrl`, and any `iconUrl` supplied directly in the file is stripped, so the allowlist cannot be bypassed. SVG content fetched from URLs is sanitized by DOMPurify at the render sink (separate from this parser). +- **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 rename happens in the entry schema's `.transform()`, not spread across consumers. +- **Coverage stays high.** The parser and icon validation are contract-tested by driving every accepted shape, line, and arrow value through `parseStylingPayload`, asserting whole-file rejection (with correctly-scoped issues) on a bad field, and asserting unknown fields (including `iconUrl`) are stripped without error. Any field addition or rename 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..831f1a640 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. **Shared styles** — 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. Shared styles fill in where you haven't customized. + +### Save styles to share + +Saves your current effective styles (custom and shared, merged) to `graph-explorer.styles.json`. Loading this file on another machine or browser reproduces your full visual configuration. + +### Load shared styles + +Loads a styling file and merges it into your shared styles. Your custom styles are not affected — they continue to take priority. If there are no conflicts the file is applied immediately; if the file contains types that already have shared styles, you'll see a confirmation listing the types that will be replaced before anything changes. Types not in the file are left unchanged. The page shows an indicator of how many types currently have shared styles. + +Loading is all-or-nothing: if the file contains any invalid value, nothing is loaded and you'll see a report listing every offending type and field so you can fix the file and load it again. Icons must be Lucide references (`lucide:`) or base64-encoded data URIs; remote URLs are rejected. Unrecognized or extra fields are ignored silently (this is how a file from a newer version still loads), so a file whose entries contain only unrecognized fields loads nothing and reports that no styles were found. + +### Reset custom styles + +Clears all your per-type style customizations. After this, types will show their shared styles (if any) or the app defaults. This cannot be undone — consider saving first. + +### Reset shared styles + +Removes all shared styles. Your custom styles remain unaffected. + ## 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/AlertDialog.tsx b/packages/graph-explorer/src/components/AlertDialog.tsx index 01928401a..26c693416 100644 --- a/packages/graph-explorer/src/components/AlertDialog.tsx +++ b/packages/graph-explorer/src/components/AlertDialog.tsx @@ -60,7 +60,7 @@ function AlertDialogContent({ data-slot="alert-dialog-content" data-size={size} className={cn( - "group/alert-dialog-content bg-popover text-popover-foreground ring-foreground/10 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 grid max-h-full w-full gap-6 overflow-y-auto rounded-xl p-6 shadow-lg ring-1 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg", + "group/alert-dialog-content bg-popover text-popover-foreground ring-foreground/10 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 flex max-h-full w-full flex-col gap-6 overflow-hidden rounded-xl p-6 shadow-lg ring-1 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg", className, )} {...props} @@ -86,6 +86,21 @@ function AlertDialogHeader({ ); } +/** + * The scrollable region of a dialog. Header and footer sit outside it and stay + * fixed; only this body scrolls when its content overflows. `min-h-0` lets it + * shrink below its content so `overflow-y-auto` engages inside the flex column. + */ +function AlertDialogBody({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + function AlertDialogFooter({ className, ...props @@ -189,6 +204,7 @@ function AlertDialogCancel({ export { AlertDialog, AlertDialogAction, + AlertDialogBody, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, 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, ), + /** + * Shared vertex styles, loaded from a styling file. The `shared-` prefix marks + * the shared layer of the `--styles` set — the fallback beneath + * a user's own edits. + */ + atomWithLocalForage( + "shared-vertex-styles", + new Map(), + reconcileMapByKey, + ), + /** Shared edge styles, loaded from a styling file. */ + atomWithLocalForage( + "shared-edge-styles", + new Map(), + reconcileMapByKey, + ), atomWithLocalForage("graph-view-layout", defaultGraphViewLayout), /** Stores the graph session data for each connection. */ atomWithLocalForage>( @@ -127,6 +144,8 @@ export { schemaAtom, userVertexStylesAtom, userEdgeStylesAtom, + sharedVertexStylesAtom, + sharedEdgeStylesAtom, 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..3792bacdf 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 { + sharedEdgeStylesAtom, + sharedVertexStylesAtom, + 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. Shared styles — `sharedVertexStylesAtom` / `sharedEdgeStylesAtom` + * (storage keys `shared-vertex-styles` / `shared-edge-styles`), loaded + * from a styling 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 styling + * 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 > shared > app defaults. */ export const vertexPreferencesAtom = atom(get => { - const vertexStyles = get(userVertexStylesAtom); + const userStyles = get(userVertexStylesAtom); + const sharedStyles = get(sharedVertexStylesAtom); return { get(type: VertexType) { - return createVertexPreference(type, vertexStyles.get(type)); + return createVertexPreference( + type, + sharedStyles.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 > shared > app defaults. */ export const edgePreferencesAtom = atom(get => { - const edgeStyles = get(userEdgeStylesAtom); + const userStyles = get(userEdgeStylesAtom); + const sharedStyles = get(sharedEdgeStylesAtom); return { get(type: EdgeType) { - return createEdgePreference(type, edgeStyles.get(type)); + return createEdgePreference( + type, + sharedStyles.get(type), + userStyles.get(type), + ); }, }; }); -/** Combines the stored user preferences with the defined default values. */ +/** Combines the cascade layers: app defaults < shared < user. */ export function createVertexPreference( type: VertexType, - stored?: VertexPreferencesStorageModel, + shared?: VertexPreferencesStorageModel, + user?: VertexPreferencesStorageModel, ): VertexPreferences { return { type, ...defaultVertexPreferences, - ...stored, + ...shared, + ...user, } as const; } -/** Combines the stored user preferences with the defined default values. */ +/** Combines the cascade layers: app defaults < shared < user. */ export function createEdgePreference( type: EdgeType, - stored?: EdgePreferencesStorageModel, -) { + shared?: EdgePreferencesStorageModel, + user?: EdgePreferencesStorageModel, +): EdgePreferences { return { type, ...defaultEdgePreferences, - ...stored, - }; + ...shared, + ...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/__fixtures__/graph-export-v1-decimal-string.json b/packages/graph-explorer/src/core/fileEnvelope/__fixtures__/graph-export-v1-decimal-string.json new file mode 100644 index 000000000..5677fcd08 --- /dev/null +++ b/packages/graph-explorer/src/core/fileEnvelope/__fixtures__/graph-export-v1-decimal-string.json @@ -0,0 +1,17 @@ +{ + "meta": { + "kind": "graph-export", + "version": "1.0", + "timestamp": "2026-06-25T14:35:09.186Z", + "source": "Graph Explorer", + "sourceVersion": "3.1.0" + }, + "data": { + "connection": { + "dbUrl": "https://example.cluster-abc.us-west-2.neptune.amazonaws.com:8182", + "queryEngine": "gremlin" + }, + "vertices": ["1", "2", 3], + "edges": ["10", "11"] + } +} diff --git a/packages/graph-explorer/src/core/fileEnvelope/__fixtures__/styling-export-v1-integer.json b/packages/graph-explorer/src/core/fileEnvelope/__fixtures__/styling-export-v1-integer.json new file mode 100644 index 000000000..9f3143e4b --- /dev/null +++ b/packages/graph-explorer/src/core/fileEnvelope/__fixtures__/styling-export-v1-integer.json @@ -0,0 +1,28 @@ +{ + "meta": { + "kind": "styling-export", + "version": 1, + "timestamp": "2026-06-30T18:30:00.000Z", + "source": "Graph Explorer", + "sourceVersion": "3.2.0" + }, + "data": { + "vertices": { + "airport": { + "displayNameAttribute": "code", + "icon": "lucide:anchor", + "iconImageType": "image/svg+xml", + "color": "#e66412" + }, + "country": { "color": "#e612b8" } + }, + "edges": { + "route": { + "lineThickness": 1, + "labelColor": "#eef4ff", + "labelBackgroundOpacity": 1, + "lineColor": "#0c4a6e" + } + } + } +} 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..0c4abe310 --- /dev/null +++ b/packages/graph-explorer/src/core/fileEnvelope/fileEnvelope.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, test } from "vitest"; + +import { + createFileEnvelope, + type EnvelopeExpectation, + type FileEnvelope, + parseFileEnvelope, +} from "./fileEnvelope"; + +const stylingExpectation: EnvelopeExpectation = { + kind: "styling-export", + supportedVersion: 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: {} }; + const envelope = createFileEnvelope("styling-export", 1, data); + + expect(envelope.meta.kind).toBe("styling-export"); + expect(envelope.meta.version).toBe(1); + 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, + { foo: "bar" }, + ); + const blob = blobOf(envelope); + + // The read schema keeps only the load-bearing fields; the diagnostic + // timestamp/source/sourceVersion are stripped. + return expect( + parseFileEnvelope(blob, stylingExpectation), + ).resolves.toStrictEqual({ + meta: { kind: "styling-export", version: 1 }, + data: { foo: "bar" }, + }); + }); + + test("keeps only the load-bearing meta fields, dropping diagnostics and unknowns", () => { + const envelope = { + meta: { + kind: "styling-export", + version: "1.0", + timestamp: "2026-06-24T00:00:00.000Z", + source: "Graph Explorer", + sourceVersion: "9.9.9", + exportedBy: "future-field", + }, + data: { hello: "world" }, + }; + + return expect( + parseFileEnvelope(blobOf(envelope), stylingExpectation), + ).resolves.toStrictEqual({ + meta: { kind: "styling-export", version: 1 }, + data: { hello: "world" }, + }); + }); + + test("throws for invalid JSON", () => { + const blob = new Blob(["not json"], { type: "application/json" }); + return expect(parseFileEnvelope(blob, stylingExpectation)).rejects.toThrow( + "File is not valid JSON", + ); + }); + + test("throws when meta is missing", () => { + 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, stylingExpectation)).rejects.toThrow( + "envelope structure", + ); + }); + + 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("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"/, + ); + }); + + test("normalizes the legacy '1.0' version string to the integer 1", async () => { + const blob = blobOf({ + meta: { + kind: "styling-export", + version: "1.0", + timestamp: "x", + source: "x", + sourceVersion: "x", + }, + data: { vertices: {}, edges: {} }, + }); + const result = await parseFileEnvelope(blob, stylingExpectation); + expect(result.meta.version).toBe(1); + }); + + test("rejects a newer generation as too new", () => { + const blob = blobOf({ + meta: { + kind: "styling-export", + version: 2, + timestamp: "x", + source: "x", + sourceVersion: "x", + }, + data: { vertices: {}, edges: {} }, + }); + return expect(parseFileEnvelope(blob, stylingExpectation)).rejects.toThrow( + /newer version of Graph Explorer/, + ); + }); + + test("rejects an unparseable version string as malformed structure", () => { + 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( + /envelope structure/, + ); + }); + + test("rejects a non-integer version as malformed structure", () => { + const blob = blobOf({ + meta: { + kind: "styling-export", + version: "1.5", + timestamp: "x", + source: "x", + sourceVersion: "x", + }, + data: { vertices: {}, edges: {} }, + }); + return expect(parseFileEnvelope(blob, stylingExpectation)).rejects.toThrow( + /envelope structure/, + ); + }); + + test("rejects a sub-1 version as malformed structure", () => { + const blob = blobOf({ + meta: { + kind: "styling-export", + version: 0, + timestamp: "x", + source: "x", + sourceVersion: "x", + }, + data: { vertices: {}, edges: {} }, + }); + return expect(parseFileEnvelope(blob, stylingExpectation)).rejects.toThrow( + /envelope structure/, + ); + }); +}); 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..9d484a106 --- /dev/null +++ b/packages/graph-explorer/src/core/fileEnvelope/fileEnvelope.ts @@ -0,0 +1,165 @@ +import { z } from "zod"; + +import { LABELS } from "@/utils/constants"; + +/** + * The `"1.0"` decimal-string encoding of generation 1. `graph-export` still + * writes it so builds that predate the integer switch — which validate + * `version` as the literal `"1.0"` — keep importing files this build writes. + * It is normalized to `1` on read. See the shared-file-envelope ADR. + */ +const DECIMAL_VERSION_STRING = "1.0"; + +/** + * Version as it appears in a file: either the integer generation or the + * `"1.0"` decimal string (the wire form `graph-export` gen 1 writes). + * Normalized to the integer generation. Lives in the parse schema so a + * malformed version fails structural parsing, not a later guard. + */ +const versionSchema = z + .union([z.literal(DECIMAL_VERSION_STRING), z.int().min(1)]) + .transform(version => (version === DECIMAL_VERSION_STRING ? 1 : version)); + +/** + * The version value a writer may stamp into a file — the pre-normalization + * input of {@link versionSchema}: an integer generation, or the `"1.0"` + * decimal string for `graph-export` gen 1's old-build compatibility. Derived + * from the schema so the write and read contracts cannot drift apart. + */ +export type EnvelopeVersion = z.input; + +/** + * The full metadata every export writes. `timestamp`/`source`/`sourceVersion` + * are diagnostic only — they are part of the write contract but nothing reads + * them back, so the read schema below does not require them. + */ +export type FileEnvelopeMeta = { + kind: string; + version: EnvelopeVersion; + timestamp: string; + source: string; + sourceVersion: string; +}; + +export type FileEnvelope = { + meta: FileEnvelopeMeta; + data: T; +}; + +/** + * The read contract: only the fields the reader acts on. `kind` selects the + * expected file type and `version` selects the payload generation; the + * diagnostic fields are ignored (and stripped) rather than validated, so a file + * that omits or malforms them still imports. A malformed `version` fails here, + * at structural parse time. + */ +const parsedMetaSchema = z.object({ + kind: z.string(), + version: versionSchema, +}); + +const fileEnvelopeSchema = z.object({ + meta: parsedMetaSchema, + data: z.unknown(), +}); + +/** + * A validated envelope. `meta.version` is the normalized integer generation + * (the field on disk may be the legacy `"1.0"` string); consumers switch on it + * to pick their per-generation payload parser. + */ +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; + } +} + +/** + * Filename convention for saved Graph Explorer documents: `..json`, + * e.g. `graph-explorer.styles.json`, `graph-explorer.config.json`, + * `.connection.json`, `..graph.json`. The + * `..` segment makes files self-describing so a human can tell them apart + * in an OS file dialog — where every kind otherwise shows as a generic `.json`. + * + * It is a naming convention only: loads validate by content (the envelope + * `kind` here, or a backup's structure), never by filename, so a renamed file + * still loads and files saved before this convention are unaffected. + */ +export function createFileEnvelope( + kind: string, + version: EnvelopeVersion, + data: T, +): FileEnvelope { + return { + meta: { + kind, + version, + timestamp: new Date().toISOString(), + source: LABELS.APP_NAME, + sourceVersion: __GRAPH_EXP_VERSION__, + }, + data, + }; +} + +export type EnvelopeExpectation = { + /** The `kind` discriminator this caller knows how to read. */ + kind: string; + /** + * The newest format generation this build understands. The version is a + * single integer that bumps only on a breaking change — additive changes are + * made as optional fields and do not bump it. A file from the same or an + * older generation imports; a newer one is rejected as too new. + */ + supportedVersion: number; +}; + +/** + * Reads and validates the outer envelope, then guards `kind` and `version` + * against what the caller supports. Throws {@link FileEnvelopeError} for + * invalid JSON, a malformed envelope (including an unrecognized version), the + * wrong `kind`, or a 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(); + 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, + ); + } + + const { meta } = result.data; + + if (meta.kind !== expectation.kind) { + throw new FileEnvelopeError( + `Expected a "${expectation.kind}" file, but got "${meta.kind}"`, + ); + } + + if (meta.version > expectation.supportedVersion) { + 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; +} diff --git a/packages/graph-explorer/src/core/fileEnvelope/goldenFiles.test.ts b/packages/graph-explorer/src/core/fileEnvelope/goldenFiles.test.ts new file mode 100644 index 000000000..b1e0f1723 --- /dev/null +++ b/packages/graph-explorer/src/core/fileEnvelope/goldenFiles.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment happy-dom +import { describe, expect, test } from "vitest"; + +import { createEdgeType, createVertexType } from "@/core/entities"; +import { parseStylingFile } from "@/core/styling"; +import { parseExportedGraph } from "@/modules/GraphViewer/exportedGraph"; + +import graphExportDecimalString from "./__fixtures__/graph-export-v1-decimal-string.json?raw"; +import stylingExportInteger from "./__fixtures__/styling-export-v1-integer.json?raw"; + +/** + * GOLDEN FILES — REAL EXPORTED FORMATS + * + * Each fixture in `__fixtures__/` is a byte-for-byte file as a shipped build + * wrote it, loaded here with `?raw` so the test imports the exact on-disk bytes + * (not a re-serialized object). The version integer in `fileEnvelope.ts` means + * nothing on its own; these are what actually prove a current build still + * imports every generation we have ever written — including graph-export's + * `"1.0"` decimal-string encoding, the wire form it still writes today. + * + * DO NOT edit a fixture to make a test pass. A fixture is a historical artifact; + * if a current build can no longer read it, that is a backward-compatibility + * break to fix in the parser (add a migration / version case), not in the file. + * When a new generation ships, ADD a fixture — never mutate an existing one. + */ + +function asFile(contents: string, name: string): File { + return new File([contents], name, { type: "application/json" }); +} + +describe("golden styling-export files import on the current build", () => { + test("styling-export-v1-integer.json", async () => { + const parsed = await parseStylingFile( + asFile(stylingExportInteger, "styling-export-v1-integer.json"), + ); + + expect(parsed.vertexStyles.get(createVertexType("airport"))).toStrictEqual({ + type: createVertexType("airport"), + displayNameAttribute: "code", + iconUrl: "lucide:anchor", + iconImageType: "image/svg+xml", + color: "#e66412", + }); + expect(parsed.vertexStyles.get(createVertexType("country"))).toStrictEqual({ + type: createVertexType("country"), + color: "#e612b8", + }); + expect(parsed.edgeStyles.get(createEdgeType("route"))).toStrictEqual({ + type: createEdgeType("route"), + lineThickness: 1, + labelColor: "#eef4ff", + labelBackgroundOpacity: 1, + lineColor: "#0c4a6e", + }); + }); +}); + +describe("golden graph-export files import on the current build", () => { + test("graph-export-v1-decimal-string.json", async () => { + const parsed = await parseExportedGraph( + asFile(graphExportDecimalString, "graph-export-v1-decimal-string.json"), + ); + + expect(parsed.connection).toStrictEqual({ + dbUrl: "https://example.cluster-abc.us-west-2.neptune.amazonaws.com:8182", + queryEngine: "gremlin", + }); + expect(parsed.vertices).toStrictEqual(new Set(["1", "2", 3])); + expect(parsed.edges).toStrictEqual(new Set(["10", "11"])); + }); +}); 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..25a2668e9 --- /dev/null +++ b/packages/graph-explorer/src/core/fileEnvelope/index.ts @@ -0,0 +1,10 @@ +export { + createFileEnvelope, + type EnvelopeExpectation, + type EnvelopeVersion, + 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..5dba2533a --- /dev/null +++ b/packages/graph-explorer/src/core/styling/index.ts @@ -0,0 +1,24 @@ +export { + isAllowedIconValue, + parseStylingPayload, + parseStylingPayloadForVersion, + STYLING_EXPORT_KIND, + STYLING_EXPORT_VERSION, + StylingParseError, + toEdgeFileEntry, + toVertexFileEntry, + type EdgeStyleFileEntry, + type EntryImportIssue, + type GeneralImportIssue, + type ImportIssue, + type StylingExportPayload, + type StylingParseResult, + type VertexStyleFileEntry, +} from "./stylingParser"; +export { + 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 new file mode 100644 index 000000000..c69a918d1 --- /dev/null +++ b/packages/graph-explorer/src/core/styling/roundTrip.test.ts @@ -0,0 +1,595 @@ +// @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 { + sharedEdgeStylesAtom, + sharedVertexStylesAtom, + userEdgeStylesAtom, + userVertexStylesAtom, +} from "@/core/StateProvider/storageAtoms"; +import { renderHookWithJotai } from "@/utils/testing"; + +import { + parseStylingFile, + useApplyStylingImport, + useExportStylingFile, +} 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(() => + useApplyStylingImport(), + ); + const parseOut = await parseStylingFile(file); + + importResult.current(parseOut); + + const sharedVertices = store.get(sharedVertexStylesAtom); + expect(sharedVertices.get(createVertexType("airport"))).toStrictEqual({ + type: createVertexType("airport"), + displayNameAttribute: "code", + iconUrl: "lucide:anchor", + iconImageType: "image/svg+xml", + color: "#e66412", + }); + expect(sharedVertices.get(createVertexType("country"))).toStrictEqual({ + type: createVertexType("country"), + color: "#e612b8", + }); + + const sharedEdges = store.get(sharedEdgeStylesAtom); + expect(sharedEdges.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(() => + useApplyStylingImport(), + ); + const parseOut = await parseStylingFile(file); + + importResult.current(parseOut); + + const shared = store.get(sharedVertexStylesAtom); + expect( + shared.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( + shared.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( + shared.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( + shared.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(() => + useApplyStylingImport(), + ); + const parseOut = await parseStylingFile(file); + + importResult.current(parseOut); + + const shared = store.get(sharedVertexStylesAtom); + expect(shared.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(() => + useApplyStylingImport(), + ); + const parseOut = await parseStylingFile(file); + + importResult.current(parseOut); + + const shared = store.get(sharedVertexStylesAtom); + expect(shared.get(createVertexType("PngNode"))).toStrictEqual({ + type: createVertexType("PngNode"), + iconUrl: pngDataUri, + iconImageType: "image/png", + color: "#111", + }); + expect(shared.get(createVertexType("JpegNode"))).toStrictEqual({ + type: createVertexType("JpegNode"), + iconUrl: jpegDataUri, + iconImageType: "image/jpeg", + color: "#222", + }); + }); + + test("a BMP data-URI icon survives round-trip", async () => { + // The uploader accepts any `image/*` the browser reports (`accept="image/*"`) + // and stores the resulting data URI verbatim, so an uncommon-but-valid image + // type like BMP must round-trip through export and re-import rather than + // rejecting the whole file. This exercises a real `data:image/bmp` icon + // value against the allowlist, not just a BMP-labeled `iconImageType`. + const bmpDataUri = "data:image/bmp;base64,Qk0eAAAAAAAAABoAAAAMAAAAAQAB"; + + const store = getAppStore(); + store.set( + userVertexStylesAtom, + new Map([ + [ + createVertexType("BmpNode"), + { + type: createVertexType("BmpNode"), + iconUrl: bmpDataUri, + iconImageType: "image/bmp", + color: "#444", + }, + ], + ]), + ); + + const { result: exportResult } = renderHookWithJotai(() => + useExportStylingFile(), + ); + const payload = exportResult.current.getExportPayload(); + + expect(payload.vertices["BmpNode"].icon).toBe(bmpDataUri); + + const file = envelopeToFile(payload); + store.set(userVertexStylesAtom, new Map()); + + const { result: importResult } = renderHookWithJotai(() => + useApplyStylingImport(), + ); + const parseOut = await parseStylingFile(file); + + importResult.current(parseOut); + + const shared = store.get(sharedVertexStylesAtom); + expect(shared.get(createVertexType("BmpNode"))).toStrictEqual({ + type: createVertexType("BmpNode"), + iconUrl: bmpDataUri, + iconImageType: "image/bmp", + color: "#444", + }); + }); + + 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"; + + 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()); + + // 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 shared = store.get(sharedVertexStylesAtom); + expect(shared.has(createVertexType("HttpsNode"))).toBe(false); + expect(shared.has(createVertexType("HttpNode"))).toBe(false); + }); + + 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(() => + useApplyStylingImport(), + ); + const parseOut = await parseStylingFile(file); + + importResult.current(parseOut); + + const shared = store.get(sharedVertexStylesAtom); + expect(shared.get(createVertexType("LucideType"))!.iconUrl).toBe( + "lucide:user", + ); + expect(shared.get(createVertexType("SvgDataType"))!.iconUrl).toBe( + "data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=", + ); + expect(shared.get(createVertexType("PngDataType"))!.iconUrl).toBe( + "data:image/png;base64,iVBORw0KGgo=", + ); + expect(shared.get(createVertexType("NoIconType"))).toStrictEqual({ + type: createVertexType("NoIconType"), + color: "#abc", + shape: "diamond", + }); + }); + + test("importing a full real-world styling file succeeds without issues", async () => { + const fileContent = JSON.stringify({ + meta: { + kind: "styling-export", + version: 1, + timestamp: "2026-06-25T14:35:09.186Z", + source: "Graph Explorer", + sourceVersion: "3.2.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(() => useApplyStylingImport()); + const parseOut = await parseStylingFile(file); + + result.current(parseOut); + + const store = getAppStore(); + const shared = store.get(sharedVertexStylesAtom); + + expect( + shared.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(shared.get(createVertexType("airport"))).toStrictEqual({ + type: createVertexType("airport"), + displayNameAttribute: "code", + iconUrl: "lucide:anchor", + iconImageType: "image/svg+xml", + color: "#e66412", + }); + + expect(shared.get(createVertexType("country"))).toStrictEqual({ + type: createVertexType("country"), + color: "#e612b8", + }); + + const sharedEdges = store.get(sharedEdgeStylesAtom); + expect(sharedEdges.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, 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..3084307f6 --- /dev/null +++ b/packages/graph-explorer/src/core/styling/stylingParser.test.ts @@ -0,0 +1,551 @@ +import { describe, expect, test } from "vitest"; + +import { createVertexType, createEdgeType } from "@/core/entities"; +import { FileEnvelopeError } from "@/core/fileEnvelope"; + +import { + parseStylingPayload, + parseStylingPayloadForVersion, + 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 = [ + "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); + }); + + test("rejects an unknown shape, listing the valid options", () => { + const issues = parseExpectingIssues({ + vertices: { A: { shape: "blob" } }, + edges: {}, + }); + expect(issues[0]).toMatchObject({ scope: "entry", field: "shape" }); + // Zod's default enum message enumerates the accepted values, which is more + // useful to the user than a generic "not a valid option". + expect(issues[0].message).toContain("ellipse"); + }); + + 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, + ); + }); + + 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, + ); + }); + + test("rejects an unknown arrow style", () => { + const issues = parseExpectingIssues({ + vertices: {}, + edges: { A: { sourceArrowStyle: "swoosh" } }, + }); + expect(issues[0]).toMatchObject({ + scope: "entry", + field: "sourceArrowStyle", + }); + }); +}); + +describe("parseStylingPayload", () => { + 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(parseExpectingIssues({})[0].scope).toBe("general"); + expect(parseExpectingIssues({ vertices: {} })[0].scope).toBe("general"); + expect(parseExpectingIssues({ edges: {} })[0].scope).toBe("general"); + }); + + 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 a general issue when edges is not an object", () => { + const issues = parseExpectingIssues({ vertices: {}, edges: [] }); + expect(issues[0]).toMatchObject({ scope: "general", location: "edges" }); + }); + + 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, + // Zod's default type message names both the expected and received types. + message: expect.stringContaining("received number"), + }); + }); + + test("parses empty vertices and edges", () => { + const result = parseStylingPayload({ vertices: {}, edges: {} }); + expect(result).toStrictEqual({ + vertexStyles: new Map(), + edgeStyles: new Map(), + }); + }); + + 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.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("ignores iconUrl as it is not a file field, preventing allowlist bypass", () => { + // The file format uses `icon`; `iconUrl` is the internal storage name. A + // file supplying `iconUrl` directly (e.g. a remote URL to dodge the icon + // allowlist) is stripped silently and never reaches storage. + const result = parseStylingPayload({ + vertices: { Airport: { iconUrl: "http://evil.example/x.svg" } }, + edges: {}, + }); + + expect(result.vertexStyles.has(createVertexType("Airport"))).toBe(false); + }); + + 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.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("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", + shape: "invalid-shape", + backgroundOpacity: "not-a-number", + }, + }, + edges: {}, + }); + + 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", () => { + const result = parseStylingPayload({ + vertices: { + Person: { colour: "#ff0000", colr: "blue", color: "#00ff00" }, + }, + edges: {}, + }); + + // Known `color` survives; the misspelled `colour`/`colr` are stripped. + expect(result.vertexStyles.get(createVertexType("Person"))).toStrictEqual({ + type: createVertexType("Person"), + color: "#00ff00", + }); + }); + + test("imports a same-version file from a newer build, ignoring its new fields", () => { + // Forward compat: additive changes ship as new optional fields without + // bumping the version, so an older build sees the same version with extra + // keys it does not know. It keeps what it recognizes and ignores the rest. + const result = parseStylingPayload({ + vertices: { + Person: { color: "#abc", glow: true, animationMs: 300 }, + }, + edges: {}, + }); + + expect(result.vertexStyles.get(createVertexType("Person"))).toStrictEqual({ + type: createVertexType("Person"), + color: "#abc", + }); + }); + + test("drops entries with no recognized fields", () => { + const result = parseStylingPayload({ + vertices: { + Ghost: { bogus: "value" }, + }, + edges: {}, + }); + + expect(result.vertexStyles.has(createVertexType("Ghost"))).toBe(false); + }); + + 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", + ); + }); + + // The uploader accepts any `image/*` the browser reports (`accept="image/*"`) + // and stores the resulting `data:` URI verbatim, so the allowlist must admit + // any image subtype — not just a fixed set — or the app rejects its own + // exports on re-import. These cover the common and the uncommon-but-valid. + test.each([ + "data:image/svg+xml;base64,PHN2Zz4=", + "data:image/png;base64,iVBORw0KGgo=", + "data:image/bmp;base64,Qk0eAAAA", + "data:image/x-icon;base64,AAABAAEAEBA=", + ])("accepts the data:image URI %s", dataUri => { + const result = parseStylingPayload({ + vertices: { A: { icon: dataUri } }, + edges: {}, + }); + expect(result.vertexStyles.get(createVertexType("A"))!.iconUrl).toBe( + dataUri, + ); + }); + + test.each([ + "data:text/html;base64,PHNjcmlwdD4=", + "data:application/json;base64,e30=", + ])("rejects non-image data URIs like %s", icon => { + const issues = parseExpectingIssues({ + vertices: { A: { icon } }, + edges: {}, + }); + expect(issues[0]).toMatchObject({ scope: "entry", field: "icon" }); + }); + + test("rejects http:// URLs (no outbound requests from imports)", () => { + const issues = parseExpectingIssues({ + vertices: { A: { icon: "http://example.com/icon.png" } }, + edges: {}, + }); + expect(issues[0]).toMatchObject({ scope: "entry", field: "icon" }); + }); + + test("rejects https:// URLs (no outbound requests from imports)", () => { + const issues = parseExpectingIssues({ + vertices: { A: { icon: "https://cdn.example.com/icon.svg" } }, + edges: {}, + }); + expect(issues[0]).toMatchObject({ scope: "entry", field: "icon" }); + }); + + test("rejects bare icon names", () => { + const issues = parseExpectingIssues({ + vertices: { A: { icon: "user" } }, + edges: {}, + }); + 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 issues = parseExpectingIssues({ + vertices: { A: { icon: "javascript:alert(1)" } }, + edges: {}, + }); + expect(issues[0]).toMatchObject({ scope: "entry", field: "icon" }); + }); + + test("rejects lucide: with invalid characters", () => { + const issues = parseExpectingIssues({ + vertices: { A: { icon: "lucide:`. + */ +const ICON_VALUE_PATTERN = + /^(lucide:[a-z0-9-]+$|data:image\/[a-z0-9.+-]+;base64,)/; + +const safeIconValue = z.string().regex(ICON_VALUE_PATTERN, { + error: + "value does not match the allowlist (lucide: or data:image/*;base64,)", +}); + +/** + * Whether a value passes the icon allowlist — the single gate shared by the + * import parser (above) and the upload seam, so the accepted set has one source + * of truth instead of drifting between the two. + */ +export function isAllowedIconValue(value: string): boolean { + return ICON_VALUE_PATTERN.test(value); +} + +/** + * One vertex entry. Unknown fields are stripped (Zod's default), so a file with + * extra keys imports without error and without storing them — in particular an + * injected `iconUrl` is dropped, never bypassing the `icon` allowlist. The + * `icon`→`iconUrl` rename to the storage model happens in `.transform()`, so it + * stays at this seam. + */ +const vertexEntrySchema = z + .object({ + icon: safeIconValue.optional(), + // Loose `string`, matching storage. The upload seam fills this from the + // browser's `file.type` (any `image/*` the OS reports), so a fixed enum + // would reject valid uploads on round-trip. It is not a security boundary: + // the icon data is guarded by `safeIconValue`, and the only consumer that + // reads this field does an exact match on `"image/svg+xml"` to choose the + // inline-SVG render path — which is DOMPurify-sanitized — so any other + // value simply takes the safer ``/raster path. + iconImageType: z.string().optional(), + color: z.string().optional(), + displayLabel: z.string().optional(), + displayNameAttribute: z.string().optional(), + longDisplayNameAttribute: z.string().optional(), + shape: z.enum(SHAPE_STYLES).optional(), + backgroundOpacity: z.number().optional(), + borderWidth: z.number().optional(), + borderColor: z.string().optional(), + borderStyle: z.enum(LINE_STYLES).optional(), + }) + .transform( + ({ icon, ...rest }): Omit => + icon !== undefined ? { ...rest, iconUrl: icon } : rest, + ); + +const edgeEntrySchema = z.object({ + displayLabel: z.string().optional(), + displayNameAttribute: z.string().optional(), + labelColor: z.string().optional(), + labelBackgroundOpacity: z.number().optional(), + labelBorderColor: z.string().optional(), + labelBorderStyle: z.enum(LINE_STYLES).optional(), + labelBorderWidth: z.number().optional(), + lineColor: z.string().optional(), + lineThickness: z.number().optional(), + lineStyle: z.enum(LINE_STYLES).optional(), + sourceArrowStyle: z.enum(ARROW_STYLES).optional(), + targetArrowStyle: z.enum(ARROW_STYLES).optional(), +}); + +// --- File-format types --- + +/** + * The on-disk shape of a vertex/edge entry — inferred from the import schema's + * input so the file format has a single source of truth. The vertex entry uses + * `icon`; the `icon`→`iconUrl` rename to the storage model happens in the + * schema's `.transform()`, so {@link z.input} (pre-transform) is the file shape. + */ +export type VertexStyleFileEntry = SimplifyDeep< + z.input +>; +export type EdgeStyleFileEntry = SimplifyDeep>; + +export type StylingExportPayload = { + vertices: Record; + edges: Record; +}; + +export function toVertexFileEntry( + model: VertexPreferencesStorageModel, +): VertexStyleFileEntry { + const { type: _type, iconUrl, ...rest } = model; + // The file format uses `icon`; storage uses `iconUrl`. Every other field maps + // straight across, so this rename is the only transformation on the way out. + return iconUrl !== undefined ? { ...rest, icon: iconUrl } : rest; +} + +export function toEdgeFileEntry( + model: EdgePreferencesStorageModel, +): EdgeStyleFileEntry { + const { type: _type, ...rest } = model; + return rest; +} + +// --- Top-level structural schema --- + +/** + * The whole file is validated in one pass: the entry schemas plug straight into + * `z.record`, so a single invalid field produces a Zod issue whose `path` + * (`["vertices", "Person", "color"]`) carries the entity, type, and field + * needed to report it. + */ +const stylingPayloadSchema = z.object({ + vertices: z.record(z.string().transform(createVertexType), vertexEntrySchema), + edges: z.record(z.string().transform(createEdgeType), edgeEntrySchema), +}); + +// --- Parser --- + +/** + * Parses a styling payload (the `data` from the envelope). Import is atomic: on + * any validation failure this throws {@link StylingParseError} carrying every + * offending location, and nothing is persisted. On success, unknown fields are + * stripped silently and entries with no recognized fields are dropped. + */ +export function parseStylingPayload(rawData: unknown): StylingParseResult { + const result = stylingPayloadSchema.safeParse(rawData, { reportInput: true }); + if (!result.success) { + throw new StylingParseError(result.error.issues.map(toImportIssue)); + } + return { + vertexStyles: toStyleMap(result.data.vertices), + edgeStyles: toStyleMap(result.data.edges), + }; +} + +/** + * Selects the payload parser for a styling file's format generation. Today only + * generation 1 exists; a future breaking change adds its `case` here alongside + * the old one. Routing through an explicit switch means a generation with no + * parser fails loudly instead of being mis-parsed by the current schema (which + * would silently strip renamed or retyped fields). The envelope's version guard + * already rejects a generation newer than this build supports, so the `default` + * is reached only when a supported generation is left unhandled here — a + * programming error, surfaced rather than swallowed. + */ +export function parseStylingPayloadForVersion( + version: number, + rawData: unknown, +): StylingParseResult { + switch (version) { + case 1: + return parseStylingPayload(rawData); + default: + throw new FileEnvelopeError( + `No styling parser for format generation ${version}`, + ); + } +} + +/** + * Brands each entry with its `type` and drops entries with no recognized + * fields (an object of only unknown keys parses to `{}`). + */ +function toStyleMap( + records: Record, +): Map { + const styles = new Map(); + for (const [type, fields] of typedEntries(records)) { + if (Object.keys(fields).length === 0) { + continue; + } + styles.set(type, { type, ...fields }); + } + return styles; +} + +/** + * Maps a Zod issue to an {@link ImportIssue}, classifying by path depth. A + * field failure has the full `vertices`/`edges` → type → field shape; a + * malformed entry stops at the type (reported with `field: "(entry)"`); a bad + * top-level container is a general, file-level issue. The rejected `value` and + * human `message` come straight from Zod — `reportInput` populates `input`, and + * Zod's default messages (or a schema-level `error`) already read well. + */ +function toImportIssue(issue: z.core.$ZodIssue): ImportIssue { + const [section, typeName, ...fieldPath] = issue.path; + const value = issue.input; + const message = issue.message; + + if (section !== "vertices" && section !== "edges") { + return { scope: "general", location: "(file)", value, message }; + } + if (typeName === undefined) { + return { scope: "general", location: String(section), value, message }; + } + return { + scope: "entry", + entityType: section === "vertices" ? "vertex" : "edge", + typeName: String(typeName), + field: fieldPath.length > 0 ? fieldPath.join(".") : "(entry)", + value, + message, + }; +} diff --git a/packages/graph-explorer/src/core/styling/useStylingImportExport.test.ts b/packages/graph-explorer/src/core/styling/useStylingImportExport.test.ts new file mode 100644 index 000000000..3c6be981c --- /dev/null +++ b/packages/graph-explorer/src/core/styling/useStylingImportExport.test.ts @@ -0,0 +1,376 @@ +// @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 { + sharedVertexStylesAtom, + sharedEdgeStylesAtom, + userVertexStylesAtom, +} from "@/core/StateProvider/storageAtoms"; +import { renderHookWithJotai } from "@/utils/testing"; + +import { + getStylingConflicts, + parseStylingFile, + useApplyStylingImport, + useExportStylingFile, +} from "./useStylingImportExport"; + +vi.mock("@/utils/fileData", () => ({ + toJsonFileData: vi.fn(), + fromFileToJson: vi.fn(), + saveFile: vi.fn(), +})); + +describe("styling import", () => { + test("parseStylingFile + applyImport writes styles to shared atoms", async () => { + const { result } = renderHookWithJotai(() => useApplyStylingImport()); + + const file = createStylingFile({ + vertices: { + Person: { color: "#ff0000", shape: "star" }, + Airport: { color: "#00ff00" }, + }, + edges: { + route: { lineColor: "#0000ff" }, + }, + }); + + const parsed = await parseStylingFile(file); + result.current(parsed); + + const store = getAppStore(); + const vertexStyles = store.get(sharedVertexStylesAtom); + expect(vertexStyles.get(createVertexType("Person"))).toStrictEqual({ + type: createVertexType("Person"), + color: "#ff0000", + shape: "star", + }); + expect(vertexStyles.get(createVertexType("Airport"))).toStrictEqual({ + type: createVertexType("Airport"), + color: "#00ff00", + }); + + const edgeStyles = store.get(sharedEdgeStylesAtom); + expect(edgeStyles.get(createEdgeType("route"))).toStrictEqual({ + type: createEdgeType("route"), + lineColor: "#0000ff", + }); + }); + + test("does not modify user atoms on import", async () => { + const store = getAppStore(); + store.set( + userVertexStylesAtom, + new Map([ + [ + createVertexType("Person"), + { type: createVertexType("Person"), color: "#111" }, + ], + ]), + ); + + const { result } = renderHookWithJotai(() => useApplyStylingImport()); + + const file = createStylingFile({ + vertices: { Person: { color: "#999" } }, + edges: {}, + }); + + const parsed = await parseStylingFile(file); + result.current(parsed); + + const userStyles = store.get(userVertexStylesAtom); + expect(userStyles.get(createVertexType("Person"))?.color).toBe("#111"); + }); + + test("merges new import with existing shared styles", async () => { + const store = getAppStore(); + store.set( + sharedVertexStylesAtom, + new Map([ + [ + createVertexType("OldType"), + { type: createVertexType("OldType"), color: "#old" }, + ], + ]), + ); + + const { result } = renderHookWithJotai(() => useApplyStylingImport()); + + const file = createStylingFile({ + vertices: { NewType: { color: "#new" } }, + edges: {}, + }); + + const parsed = await parseStylingFile(file); + result.current(parsed); + + const vertexStyles = store.get(sharedVertexStylesAtom); + expect(vertexStyles.has(createVertexType("OldType"))).toBe(true); + expect(vertexStyles.has(createVertexType("NewType"))).toBe(true); + }); + + test("getStylingConflicts identifies overlapping keys", () => { + const sharedVertexStyles = new Map< + VertexType, + VertexPreferencesStorageModel + >([ + [ + createVertexType("Person"), + { type: createVertexType("Person"), color: "#existing" }, + ], + ]); + const sharedEdgeStyles = new Map([ + [ + createEdgeType("knows"), + { type: createEdgeType("knows"), lineColor: "#old" }, + ], + ]); + + const parsed = { + vertexStyles: new Map([ + [ + createVertexType("Person"), + { type: createVertexType("Person"), color: "#new" }, + ], + [ + createVertexType("Airport"), + { type: createVertexType("Airport"), color: "#fresh" }, + ], + ]), + edgeStyles: new Map([ + [ + createEdgeType("knows"), + { type: createEdgeType("knows"), lineColor: "#new" }, + ], + ]), + }; + + const conflicts = getStylingConflicts( + parsed, + sharedVertexStyles, + sharedEdgeStyles, + ); + expect(conflicts).toStrictEqual({ + vertices: ["Person"], + edges: ["knows"], + }); + }); + + test("getStylingConflicts returns empty when no overlap", () => { + const parsed = { + vertexStyles: new Map([ + [ + createVertexType("Brand New"), + { type: createVertexType("Brand New"), color: "#aaa" }, + ], + ]), + edgeStyles: new Map(), + }; + + const conflicts = getStylingConflicts( + parsed, + new Map(), + new Map(), + ); + expect(conflicts).toStrictEqual({ vertices: [], edges: [] }); + }); + + test("throws for invalid file", async () => { + const file = new File(["not json"], "bad.json", { + type: "application/json", + }); + + await expect(parseStylingFile(file)).rejects.toThrow( + "File is not valid JSON", + ); + }); + + test("throws for wrong kind", async () => { + const envelope = { + meta: { + kind: "connection-export", + version: "1.0", + timestamp: "2026-01-01T00:00:00Z", + source: "Graph Explorer", + sourceVersion: "3.2.0", + }, + data: { vertices: {}, edges: {} }, + }; + const file = new File([JSON.stringify(envelope)], "conn.json", { + type: "application/json", + }); + + await expect(parseStylingFile(file)).rejects.toThrow( + 'Expected a "styling-export" file, but got "connection-export"', + ); + }); + + test("throws when the file was made by a newer generation", async () => { + const envelope = { + meta: { + kind: "styling-export", + version: 2, + 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(parseStylingFile(file)).rejects.toThrow( + /newer version of Graph Explorer/, + ); + }); +}); + +describe("useExportStylingFile", () => { + test("exports merged user+shared styles with user winning", () => { + const store = getAppStore(); + store.set( + sharedVertexStylesAtom, + new Map([ + [ + createVertexType("Person"), + { + type: createVertexType("Person"), + color: "#shared", + shape: "star", + }, + ], + ]), + ); + store.set( + userVertexStylesAtom, + new Map([ + [ + createVertexType("Person"), + { type: createVertexType("Person"), color: "#user" }, + ], + ]), + ); + store.set( + sharedEdgeStylesAtom, + new Map([ + [ + createEdgeType("route"), + { type: createEdgeType("route"), lineColor: "#edge-shared" }, + ], + ]), + ); + + const { result } = renderHookWithJotai(() => useExportStylingFile()); + + const payload = result.current.getExportPayload(); + + expect(payload.vertices).toStrictEqual({ + Person: { color: "#user", shape: "star" }, + }); + expect(payload.edges).toStrictEqual({ + route: { lineColor: "#edge-shared" }, + }); + }); + + test("exports types only present in user layer", () => { + const store = getAppStore(); + store.set( + userVertexStylesAtom, + new Map([ + [ + createVertexType("City"), + { type: createVertexType("City"), color: "#city" }, + ], + ]), + ); + + const { result } = renderHookWithJotai(() => useExportStylingFile()); + const payload = result.current.getExportPayload(); + + expect(payload.vertices).toStrictEqual({ City: { color: "#city" } }); + expect(payload.edges).toStrictEqual({}); + }); + + test("exports types only present in shared layer", () => { + const store = getAppStore(); + store.set( + sharedEdgeStylesAtom, + new Map([ + [ + createEdgeType("likes"), + { type: createEdgeType("likes"), lineStyle: "dashed" }, + ], + ]), + ); + + const { result } = renderHookWithJotai(() => useExportStylingFile()); + const payload = result.current.getExportPayload(); + + expect(payload.vertices).toStrictEqual({}); + expect(payload.edges).toStrictEqual({ likes: { lineStyle: "dashed" } }); + }); + + test("returns empty payload when no styles exist", () => { + const { result } = renderHookWithJotai(() => useExportStylingFile()); + const payload = result.current.getExportPayload(); + + expect(payload).toStrictEqual({ vertices: {}, edges: {} }); + }); + + test("renames iconUrl to icon in the file format", () => { + const store = getAppStore(); + store.set( + userVertexStylesAtom, + new Map([ + [ + createVertexType("Airport"), + { + type: createVertexType("Airport"), + iconUrl: "lucide:plane", + iconImageType: "image/svg+xml", + color: "#123", + }, + ], + ]), + ); + + const { result } = renderHookWithJotai(() => useExportStylingFile()); + const payload = result.current.getExportPayload(); + + expect(payload.vertices["Airport"]).toStrictEqual({ + icon: "lucide:plane", + iconImageType: "image/svg+xml", + color: "#123", + }); + expect("iconUrl" in payload.vertices["Airport"]).toBe(false); + }); +}); + +function createStylingFile(data: { + vertices: Record>; + edges: Record>; +}) { + const envelope = { + meta: { + kind: "styling-export", + version: 1, + timestamp: "2026-06-24T00:00:00.000Z", + source: "Graph Explorer", + sourceVersion: "3.2.0", + }, + data, + }; + return new File([JSON.stringify(envelope)], "styles.json", { + type: "application/json", + }); +} diff --git a/packages/graph-explorer/src/core/styling/useStylingImportExport.ts b/packages/graph-explorer/src/core/styling/useStylingImportExport.ts new file mode 100644 index 000000000..680ddff7c --- /dev/null +++ b/packages/graph-explorer/src/core/styling/useStylingImportExport.ts @@ -0,0 +1,139 @@ +import { useAtomValue, useSetAtom } from "jotai"; + +import type { EdgeType, VertexType } from "@/core/entities"; +import type { + EdgePreferencesStorageModel, + VertexPreferencesStorageModel, +} from "@/core/StateProvider/userPreferences"; + +import { parseFileEnvelope } from "@/core/fileEnvelope"; +import { + sharedEdgeStylesAtom, + sharedVertexStylesAtom, + userEdgeStylesAtom, + userVertexStylesAtom, +} from "@/core/StateProvider/storageAtoms"; + +import type { StylingParseResult } from "./stylingParser"; +import type { + EdgeStyleFileEntry, + StylingExportPayload, + VertexStyleFileEntry, +} from "./stylingParser"; + +import { + parseStylingPayloadForVersion, + STYLING_EXPORT_KIND, + STYLING_EXPORT_VERSION, + toEdgeFileEntry, + toVertexFileEntry, +} from "./stylingParser"; + +export type ImportConflicts = { + vertices: string[]; + edges: string[]; +}; + +/** + * Parses a styling export file. Throws {@link FileEnvelopeError} if the file is + * not valid JSON, lacks the envelope structure, is the wrong kind, or was + * created by a newer version; and {@link StylingParseError} (carrying the + * offending locations) if any payload value is invalid. Import is atomic — a + * successful parse means the whole file is valid. + */ +export async function parseStylingFile( + file: File, +): Promise { + const envelope = await parseFileEnvelope(file, { + kind: STYLING_EXPORT_KIND, + supportedVersion: STYLING_EXPORT_VERSION, + }); + return parseStylingPayloadForVersion(envelope.meta.version, envelope.data); +} + +/** + * The types in `parsed` that already have a shared style. These are the entries + * a load would overwrite, so the caller can warn before applying. + */ +export function getStylingConflicts( + parsed: StylingParseResult, + sharedVertexStyles: Map, + sharedEdgeStyles: Map, +): ImportConflicts { + const vertices: string[] = []; + const edges: string[] = []; + for (const type of parsed.vertexStyles.keys()) { + if (sharedVertexStyles.has(type)) { + vertices.push(type); + } + } + for (const type of parsed.edgeStyles.keys()) { + if (sharedEdgeStyles.has(type)) { + edges.push(type); + } + } + return { vertices, edges }; +} + +/** + * Merges a parsed styling file into the shared-styles layer, leaving user + * customizations untouched. + */ +export function useApplyStylingImport() { + const setSharedVertexStyles = useSetAtom(sharedVertexStylesAtom); + const setSharedEdgeStyles = useSetAtom(sharedEdgeStylesAtom); + + return function applyImport(parsed: StylingParseResult): void { + setSharedVertexStyles(prev => { + const merged = new Map(prev); + for (const [type, style] of parsed.vertexStyles) { + merged.set(type, style); + } + return merged; + }); + setSharedEdgeStyles(prev => { + const merged = new Map(prev); + for (const [type, style] of parsed.edgeStyles) { + merged.set(type, style); + } + return merged; + }); + }; +} + +export function useExportStylingFile() { + const userVertexStyles = useAtomValue(userVertexStylesAtom); + const userEdgeStyles = useAtomValue(userEdgeStylesAtom); + const sharedVertexStyles = useAtomValue(sharedVertexStylesAtom); + const sharedEdgeStyles = useAtomValue(sharedEdgeStylesAtom); + + function getExportPayload(): StylingExportPayload { + const vertices: Record = {}; + + const allVertexTypes = new Set([ + ...sharedVertexStyles.keys(), + ...userVertexStyles.keys(), + ]); + for (const type of allVertexTypes) { + const shared = sharedVertexStyles.get(type); + const user = userVertexStyles.get(type); + vertices[type] = toVertexFileEntry({ type, ...shared, ...user }); + } + + const edges: Record = {}; + + const allEdgeTypes = new Set([ + ...sharedEdgeStyles.keys(), + ...userEdgeStyles.keys(), + ]); + for (const type of allEdgeTypes) { + const shared = sharedEdgeStyles.get(type); + const user = userEdgeStyles.get(type); + edges[type] = toEdgeFileEntry({ type, ...shared, ...user }); + } + + return { vertices, edges }; + } + + return { getExportPayload }; +} 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..0d2f3bf58 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.test.ts +++ b/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.test.ts @@ -25,11 +25,23 @@ import { createFileSafeTimestamp, type ExportedGraphConnection, type ExportedGraphFile, - exportedGraphSchema, isMatchingConnection, parseExportedGraph, + parseGraphExportPayloadForVersion, } from "./exportedGraph"; +/** + * Wraps an exported-graph object in a Blob, as the file entry point expects. + * Accepts a loosened `meta` so tests can build deliberately off-contract files + * (wrong kind, malformed version) without per-call casts. + */ +function toGraphFileBlob(graph: { + meta: Record; + data: unknown; +}): Blob { + return new Blob([JSON.stringify(graph)], { type: "application/json" }); +} + describe("createExportedGraph", () => { let timestamp: Date; let appVersion: string; @@ -60,7 +72,7 @@ describe("createExportedGraph", () => { const expectedMeta = { kind: "graph-export", version: "1.0", - timestamp: timestamp, + timestamp: timestamp.toISOString(), source: "Graph Explorer", sourceVersion: appVersion, } satisfies ExportedGraphFile["meta"]; @@ -73,6 +85,18 @@ describe("createExportedGraph", () => { expect(graph.data.edges).toEqual(edgeIds); }); + it("stamps the generation-1 version as the '1.0' decimal string", () => { + // Builds that predate the integer version switch validate `meta.version` + // as the literal `"1.0"`. Writing the integer `1` would make files this + // build exports fail to import on those older builds, so generation 1 must + // stay on the wire as the decimal string. + const connection = createRandomConnectionWithId(); + + const graph = createExportedGraph([], [], connection); + + expect(graph.meta.version).toBe("1.0"); + }); + it("should create an exported graph with empty vertices and edges", () => { const connection = createRandomConnectionWithId(); const expectedConnection = createExportedConnection(connection); @@ -91,7 +115,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", () => { @@ -114,16 +138,6 @@ describe("createExportedGraph", () => { expect(graph.meta.kind).toBe("graph-export"); }); - it("should set meta version to 1.0", () => { - const vertexIds = createArray(2, () => createRandomVertexId()); - const edgeIds = createArray(2, () => createRandomEdgeId()); - const connection = createRandomConnectionWithId(); - - const graph = createExportedGraph(vertexIds, edgeIds, connection); - - expect(graph.meta.version).toBe("1.0"); - }); - it("should set meta source to Graph Explorer", () => { const vertexIds = createArray(2, () => createRandomVertexId()); const edgeIds = createArray(2, () => createRandomEdgeId()); @@ -184,7 +198,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 +209,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 +218,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 +231,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 +244,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 +263,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 +286,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 +300,87 @@ 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(); + const wrongKind = { + ...exportedGraph, + meta: { ...exportedGraph.meta, kind: "styling-export" }, + }; + + await expect( + parseExportedGraph(toGraphFileBlob(wrongKind)), + ).rejects.toThrow(/Expected a "graph-export" file/); + }); + + it("should reject a file from a newer generation", async () => { const exportedGraph = createRandomExportedGraph(); - expect(exportedGraphSchema.safeParse(exportedGraph).success).toBeTruthy(); + const tooNew = { + ...exportedGraph, + meta: { ...exportedGraph.meta, version: 2 }, + }; + + await expect(parseExportedGraph(toGraphFileBlob(tooNew))).rejects.toThrow( + /newer version of Graph Explorer/, + ); }); - it("should validate exported graph with empty vertices and edges", () => { + it("should reject a non-integer version as malformed", async () => { const exportedGraph = createRandomExportedGraph(); - exportedGraph.data.vertices = []; - exportedGraph.data.edges = []; - expect(exportedGraphSchema.safeParse(exportedGraph).success).toBeTruthy(); + const malformed = { + ...exportedGraph, + meta: { ...exportedGraph.meta, version: "1.5" }, + }; + + await expect( + parseExportedGraph(toGraphFileBlob(malformed)), + ).rejects.toThrow(/expected envelope structure/); }); - it("should not validate exported graph schema with invalid vertices", () => { + it("should accept the legacy '1.0' version string", async () => { const exportedGraph = createRandomExportedGraph(); - exportedGraph.data.vertices = [false, true] as any; - expect(exportedGraphSchema.safeParse(exportedGraph).success).toBeFalsy(); + const legacy = { + ...exportedGraph, + meta: { ...exportedGraph.meta, version: "1.0" }, + }; + + const parsed = await parseExportedGraph(toGraphFileBlob(legacy)); + expect(parsed.vertices).toEqual(new Set(exportedGraph.data.vertices)); }); - it("should not validate exported graph schema with invalid edges", () => { + it("dispatches generation 1 to the current parser", () => { const exportedGraph = createRandomExportedGraph(); - exportedGraph.data.edges = [false, true] as any; - expect(exportedGraphSchema.safeParse(exportedGraph).success).toBeFalsy(); + const parsed = parseGraphExportPayloadForVersion(1, exportedGraph.data); + expect(parsed).toEqual(exportedGraph.data); }); - it("should not validate exported graph with different meta kind value", () => { + it("throws loudly for a generation with no parser", () => { const exportedGraph = createRandomExportedGraph(); - exportedGraph.meta.kind = "not-graph-export" as any; - expect(exportedGraphSchema.safeParse(exportedGraph).success).toBeFalsy(); + expect(() => + parseGraphExportPayloadForVersion(2, exportedGraph.data), + ).toThrow(/No graph export parser for format generation 2/); + }); + + it("should reject a malformed payload", async () => { + const exportedGraph = createRandomExportedGraph(); + const malformed = { + ...exportedGraph, + data: { ...exportedGraph.data, vertices: [false, true] as any }, + }; + + await expect( + parseExportedGraph(toGraphFileBlob(malformed)), + ).rejects.toThrow(); + }); + + 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..68929ab30 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,48 @@ 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, + type EnvelopeVersion, + FileEnvelopeError, + parseFileEnvelope, +} from "@/core/fileEnvelope"; +import { escapeString, logger } from "@/utils"; + +/** The envelope `kind` discriminator for graph export files. */ +export const GRAPH_EXPORT_KIND = "graph-export"; + +/** + * Format generation. A single integer that bumps only on a breaking change + * (renamed or removed fields). Additive changes are made as optional fields and + * do not bump it. This is the newest generation this build can read, and the + * value the version dispatch switches on after the envelope normalizes the + * wire form. + */ +export const GRAPH_EXPORT_VERSION = 1; + +/** + * The version value written into new graph-export files. Generation 1 is + * stamped as the `"1.0"` decimal string rather than the integer `1` so that + * builds predating the integer switch — which validate `version` as the + * literal `"1.0"` — can still import files this build writes. The envelope + * normalizes both forms to {@link GRAPH_EXPORT_VERSION} on read. See the + * shared-file-envelope ADR. + */ +export const GRAPH_EXPORT_WIRE_VERSION: EnvelopeVersion = "1.0"; + +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 +64,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_WIRE_VERSION, { + connection: createExportedConnection(connection), + vertices: vertexIds, + edges: edgeIds, + }); } /** Creates an exported connection object from the given connection config. */ @@ -73,14 +86,50 @@ export function createExportedConnection( }; } -export async function parseExportedGraph(data: unknown) { - const parsed = await exportedGraphSchema.parseAsync(data); +/** + * Selects the payload parser for a graph export file's format generation. Today + * only generation 1 exists; a future breaking change adds its `case` here + * alongside the old one. Routing through an explicit switch means a generation + * with no parser fails loudly instead of being mis-parsed by the current schema. + * The envelope's version guard already rejects a generation newer than this + * build supports, so the `default` is reached only when a supported generation + * is left unhandled here — a programming error, surfaced rather than swallowed. + */ +export function parseGraphExportPayloadForVersion( + version: number, + rawData: unknown, +): GraphExportPayload { + switch (version) { + case 1: + return graphExportPayloadSchema.parse(rawData); + default: + throw new FileEnvelopeError( + `No graph export parser for format generation ${version}`, + ); + } +} + +/** + * Reads a graph export file: validates the shared envelope (kind + format + * generation), 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, + supportedVersion: GRAPH_EXPORT_VERSION, + }); + const payload = parseGraphExportPayloadForVersion( + envelope.meta.version, + envelope.data, + ); - const connection = parsed.data.connection; + 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 +140,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) diff --git a/packages/graph-explorer/src/modules/GraphViewer/renderNode.test.ts b/packages/graph-explorer/src/modules/GraphViewer/renderNode.test.ts index a11dbf696..5ae269e44 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/renderNode.test.ts +++ b/packages/graph-explorer/src/modules/GraphViewer/renderNode.test.ts @@ -217,6 +217,71 @@ describe("renderNode", () => { expect(decodedSvg).toContain(" { + async function renderCustomSvg(svgContent: string) { + fetchMock.mockResolvedValue(new Response(svgContent)); + const node: VertexIconConfig = { + type: createRandomVertexType(), + color: createRandomColor(), + iconUrl: createRandomName("iconUrl"), + iconImageType: "image/svg+xml", + }; + const result = await renderNode(client, node); + return decodeSvg(result); + } + + it("strips a `, + ); + + 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. */ diff --git a/packages/graph-explorer/src/modules/GraphViewer/renderNode.tsx b/packages/graph-explorer/src/modules/GraphViewer/renderNode.tsx index 5850b8a02..27cbb9607 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/renderNode.tsx +++ b/packages/graph-explorer/src/modules/GraphViewer/renderNode.tsx @@ -3,6 +3,7 @@ import { type QueryClient, queryOptions, } from "@tanstack/react-query"; +import DOMPurify from "dompurify"; import type { VertexTypeConfig } from "@/core"; @@ -33,7 +34,10 @@ const iconQueryOptions = (url: string) => } logger.debug("Fetching icon", url); const response = await fetch(url); - return await response.text(); + const text = await response.text(); + return DOMPurify.sanitize(text, { + USE_PROFILES: { svg: true, svgFilters: true }, + }); }, placeholderData: keepPreviousData, staleTime: Infinity, diff --git a/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx b/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx index 78073c414..b37d4c3f8 100644 --- a/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx +++ b/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx @@ -35,6 +35,7 @@ import { type ShapeStyle, useVertexStyling, } from "@/core/StateProvider/userPreferences"; +import { isAllowedIconValue } from "@/core/styling"; import useTranslations from "@/hooks/useTranslations"; import { parseNumberSafely } from "@/utils"; import { @@ -119,6 +120,12 @@ function Content({ vertexType }: { vertexType: VertexType }) { } try { const result = await file2Base64(file); + if (!isAllowedIconValue(result)) { + toast.error("Invalid file", { + description: "Choose an image file (the icon must be an image).", + }); + return; + } setVertexStyle({ iconUrl: result, iconImageType: file.type }); } catch (error) { console.error("Unable to convert uploaded image to base64: ", error); diff --git a/packages/graph-explorer/src/routes/Settings/LoadStylesButton.test.tsx b/packages/graph-explorer/src/routes/Settings/LoadStylesButton.test.tsx new file mode 100644 index 000000000..d99d03f15 --- /dev/null +++ b/packages/graph-explorer/src/routes/Settings/LoadStylesButton.test.tsx @@ -0,0 +1,227 @@ +// @vitest-environment happy-dom +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Suspense } from "react"; +import { describe, expect, test, vi } from "vitest"; + +import type { VertexPreferencesStorageModel } from "@/core/StateProvider/userPreferences"; + +import { TooltipProvider } from "@/components"; +import { type AppStore, getAppStore } from "@/core"; +import { createVertexType, type VertexType } from "@/core/entities"; +import { createFileEnvelope } from "@/core/fileEnvelope"; +import { createQueryClient } from "@/core/queryClient"; +import { sharedVertexStylesAtom } from "@/core/StateProvider/storageAtoms"; +import { TestProvider } from "@/utils/testing"; + +import LoadStylesButton from "./LoadStylesButton"; + +vi.mock("@/utils/fileData", () => ({ + toJsonFileData: vi.fn(), + fromFileToJson: vi.fn(), + saveFile: vi.fn(), +})); + +/** + * 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( + + + + + , + ); + return store; +} + +/** Builds a styling-export file with the given payload data. */ +function stylingFile(data: unknown) { + const envelope = createFileEnvelope("styling-export", 1, data); + return new File([JSON.stringify(envelope)], "graph-explorer.styles.json", { + type: "application/json", + }); +} + +/** The hidden file input the FileButton renders. */ +function fileInput() { + // eslint-disable-next-line testing-library/no-node-access + return document.querySelector('input[type="file"]')!; +} + +describe("LoadStylesButton", () => { + test("applies immediately and reports counts when there are no conflicts", async () => { + const user = userEvent.setup(); + const store = renderButton(); + + await user.upload( + fileInput(), + stylingFile({ + vertices: { Person: { color: "#abc" }, Airport: { color: "#def" } }, + edges: {}, + }), + ); + + expect(await screen.findByText("Styles Loaded")).toBeInTheDocument(); + // Node-only load omits the zero edge side. + expect(screen.getByText("Loaded 2 node styles.")).toBeInTheDocument(); + expect( + store.get(sharedVertexStylesAtom).get(createVertexType("Person")), + ).toStrictEqual({ type: createVertexType("Person"), color: "#abc" }); + }); + + test("joins both sides of the count message when a file loads vertices and edges", async () => { + const user = userEvent.setup(); + renderButton(); + + await user.upload( + fileInput(), + stylingFile({ + vertices: { Person: { color: "#abc" } }, + edges: { knows: { lineColor: "#def" } }, + }), + ); + + expect(await screen.findByText("Styles Loaded")).toBeInTheDocument(); + expect( + screen.getByText("Loaded 1 node style and 1 edge style."), + ).toBeInTheDocument(); + }); + + test("prompts before overwriting an existing shared style, then completes on confirm", async () => { + const user = userEvent.setup(); + const store = renderButton(store => + store.set( + sharedVertexStylesAtom, + new Map([ + [ + createVertexType("Person"), + { type: createVertexType("Person"), color: "#old" }, + ], + ]), + ), + ); + + await user.upload( + fileInput(), + stylingFile({ vertices: { Person: { color: "#new" } }, edges: {} }), + ); + + // Conflict prompt first — nothing applied yet. + expect( + await screen.findByText("Replace 1 existing shared style?"), + ).toBeInTheDocument(); + expect( + store.get(sharedVertexStylesAtom).get(createVertexType("Person"))?.color, + ).toBe("#old"); + + await user.click(screen.getByRole("button", { name: "Load & Replace" })); + + expect(await screen.findByText("Styles Loaded")).toBeInTheDocument(); + expect( + store.get(sharedVertexStylesAtom).get(createVertexType("Person"))?.color, + ).toBe("#new"); + }); + + test("rejects the whole file and lists issues when a value is invalid", async () => { + const user = userEvent.setup(); + const store = renderButton(); + + await user.upload( + fileInput(), + stylingFile({ vertices: { Person: { shape: "blob" } }, edges: {} }), + ); + + expect(await screen.findByText("Load Failed")).toBeInTheDocument(); + expect(screen.getByText("Person")).toBeInTheDocument(); + // Nothing persisted. + expect(store.get(sharedVertexStylesAtom).size).toBe(0); + }); + + test("reports when a valid file contains no recognized styles", async () => { + const user = userEvent.setup(); + renderButton(); + + await user.upload( + fileInput(), + stylingFile({ vertices: { Ghost: { bogus: "x" } }, edges: {} }), + ); + + expect(await screen.findByText("No Styles Found")).toBeInTheDocument(); + }); + + test("surfaces an envelope-level error for the wrong file kind", async () => { + const user = userEvent.setup(); + renderButton(); + + // A JSON file that carries the wrong envelope kind — content validation, + // not the filename or picker filter, must reject it. + const wrongKind = createFileEnvelope("connection-export", 1, { + vertices: {}, + edges: {}, + }); + await user.upload( + fileInput(), + new File([JSON.stringify(wrongKind)], "graph-explorer.styles.json", { + type: "application/json", + }), + ); + + expect(await screen.findByText("Invalid file")).toBeInTheDocument(); + expect( + screen.getByText(/Expected a "styling-export" file/), + ).toBeInTheDocument(); + }); + + test("closes the dialog when the result is dismissed", async () => { + const user = userEvent.setup(); + renderButton(); + + await user.upload( + fileInput(), + stylingFile({ vertices: { Person: { color: "#abc" } }, edges: {} }), + ); + + expect(await screen.findByText("Styles Loaded")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Close" })); + + expect(screen.queryByText("Styles Loaded")).not.toBeInTheDocument(); + }); + + test("never reveals an ancestor Suspense fallback while loading", async () => { + const user = userEvent.setup(); + const store = getAppStore(); + render( + + + page loading
}> + + + + , + ); + + let fallbackSeen = false; + const observer = new MutationObserver(() => { + if (screen.queryByText("page loading")) fallbackSeen = true; + }); + observer.observe(document.body, { childList: true, subtree: true }); + + await user.upload( + fileInput(), + stylingFile({ vertices: { Person: { color: "#abc" } }, edges: {} }), + ); + await screen.findByText("Styles Loaded"); + observer.disconnect(); + + expect(fallbackSeen).toBe(false); + }); +}); diff --git a/packages/graph-explorer/src/routes/Settings/LoadStylesButton.tsx b/packages/graph-explorer/src/routes/Settings/LoadStylesButton.tsx new file mode 100644 index 000000000..0583025c1 --- /dev/null +++ b/packages/graph-explorer/src/routes/Settings/LoadStylesButton.tsx @@ -0,0 +1,458 @@ +import { useAtomValue } from "jotai"; +import { AlertTriangleIcon, CheckCircleIcon, UploadIcon } from "lucide-react"; +import { startTransition, useActionState } from "react"; + +import type { + EntryImportIssue, + GeneralImportIssue, + ImportConflicts, + ImportIssue, + StylingParseResult, +} from "@/core/styling"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogBody, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, + Button, + FileButton, + Group, + GroupHeader, + GroupItem, + GroupTitle, +} from "@/components"; +import { + sharedEdgeStylesAtom, + sharedVertexStylesAtom, +} from "@/core/StateProvider/storageAtoms"; +import { + getStylingConflicts, + parseStylingFile, + StylingParseError, + useApplyStylingImport, +} from "@/core/styling"; +import { logger } from "@/utils"; +import { + createDisplayError, + type DisplayError, +} from "@/utils/createDisplayError"; + +/** + * What the load dialog is showing. `closed` is the resting state — the dialog + * is hidden. Confirming a conflict advances the state in place (`conflicts` → + * terminal) without closing the dialog, so the result replaces the prompt + * seamlessly. + * + * Loading is atomic: a file with any invalid value never reaches `conflicts` or + * a terminal state — it surfaces as `invalid` with the full list of offending + * locations. `failed` is reserved for envelope-level errors (wrong file, too + * new), which are a single message with no per-field detail. A structurally + * valid file that carries no recognized styles lands on `empty`, not `complete`. + */ +type DialogState = + | { kind: "closed" } + | { + kind: "conflicts"; + parsed: StylingParseResult; + conflicts: ImportConflicts; + } + | { kind: "failed"; error: DisplayError } + | { kind: "invalid"; issues: ImportIssue[] } + | { kind: "empty" } + | { kind: "complete"; vertexCount: number; edgeCount: number }; + +type LoadAction = + | { type: "submitFile"; file: File } + | { type: "confirmConflicts" } + | { type: "dismiss" }; + +export default function LoadStylesButton() { + const applyImport = useApplyStylingImport(); + const sharedVertexStyles = useAtomValue(sharedVertexStylesAtom); + const sharedEdgeStyles = useAtomValue(sharedEdgeStylesAtom); + + // The action is a reducer that may await and perform the load side effect, + // since it runs as an event rather than as a pure reducer. React tracks + // `isPending` across the await, so there is no manual async state to keep in + // sync — `state` is the single source of truth for what the dialog shows. + async function runLoad( + state: DialogState, + action: LoadAction, + ): Promise { + switch (action.type) { + case "submitFile": + try { + const parsed = await parseStylingFile(action.file); + const conflicts = getStylingConflicts( + parsed, + sharedVertexStyles, + sharedEdgeStyles, + ); + // Conflicts must be confirmed before overwriting; otherwise apply now. + if (hasConflicts(conflicts)) { + return { kind: "conflicts", parsed, conflicts }; + } + applyImport(parsed); + return terminalState(parsed); + } catch (error) { + // Expected failures become state, not throws — an uncaught throw would + // escape to the nearest error boundary instead of the dialog. + return toErrorState(error); + } + // Confirming the overwrite advances the same parsed file to its terminal + // outcome in place. This transition replaces the old `confirmed` flag. + case "confirmConflicts": + if (state.kind !== "conflicts") { + return state; + } + applyImport(state.parsed); + return terminalState(state.parsed); + case "dismiss": + return { kind: "closed" }; + } + } + + const [state, dispatchAction, isPending] = useActionState(runLoad, { + kind: "closed", + }); + + // The action awaits, so every dispatch must run inside a transition — else + // React both warns and lets the pending update reveal the nearest Suspense + // fallback (the whole Settings page). Wrapping here makes every call site + // transition-safe by construction. + function dispatch(action: LoadAction) { + startTransition(() => dispatchAction(action)); + } + + function renderState() { + switch (state.kind) { + case "conflicts": + return ( + dispatch({ type: "confirmConflicts" })} + /> + ); + case "failed": + return ; + case "invalid": + return ; + case "empty": + return ; + case "complete": + return ( + + ); + case "closed": + return null; + } + } + + return ( + <> + file && dispatch({ type: "submitFile", file })} + accept="application/json" + asChild + > + + + + !o && dispatch({ type: "dismiss" })} + > + {renderState()} + + + ); +} + +function hasConflicts(conflicts: ImportConflicts): boolean { + return conflicts.vertices.length + conflicts.edges.length > 0; +} + +function terminalState(parsed: StylingParseResult): DialogState { + const vertexCount = parsed.vertexStyles.size; + const edgeCount = parsed.edgeStyles.size; + return vertexCount + edgeCount === 0 + ? { kind: "empty" } + : { kind: "complete", vertexCount, edgeCount }; +} + +function toErrorState(error: unknown): DialogState { + if (error instanceof StylingParseError) { + return { kind: "invalid", issues: error.issues }; + } + logger.error("Load failed", error); + return { kind: "failed", error: createDisplayError(error) }; +} + +function ConflictContent({ + conflicts, + onConfirm, +}: { + conflicts: ImportConflicts; + onConfirm: () => void; +}) { + const conflictCount = conflicts.vertices.length + conflicts.edges.length; + return ( + + + + + + + {`Replace ${conflictCount} existing shared ${conflictCount === 1 ? "style" : "styles"}?`} + + + The loaded file will overwrite these existing shared styles. New types + will be added alongside them. This cannot be undone, so consider + saving first. + + + + + Cancel + {/* A plain button, not AlertDialogAction: confirming advances the phase + in place rather than closing the dialog. */} + + + + ); +} + +function LoadFailedContent({ error }: { error: DisplayError }) { + return ( + + + + + + {error.title} + {error.message} + + + Close + + + ); +} + +function LoadInvalidContent({ issues }: { issues: ImportIssue[] }) { + return ( + + + + + + Load Failed + + The file was not loaded because it contains invalid values. Fix the + values below and try again. + + + + + Close + + + ); +} + +function LoadEmptyContent() { + return ( + + + + + + No Styles Found + + The file was read successfully but contained no styles to load. + + + + Close + + + ); +} + +function LoadCompleteContent({ + vertexCount, + edgeCount, +}: { + vertexCount: number; + edgeCount: number; +}) { + return ( + + + + + + Styles Loaded + + {`Loaded ${formatLoadedCounts(vertexCount, edgeCount)}.`} + + + + Close + + + ); +} + +/** + * Summarizes what was loaded, omitting a zero side so a node-only load reads + * "2 node styles" rather than "2 node styles and 0 edge styles". The empty case + * is handled by its own phase, so at least one count is non-zero. + */ +function formatLoadedCounts(vertexCount: number, edgeCount: number): string { + const clauses: string[] = []; + if (vertexCount > 0) { + clauses.push(formatStyleCount(vertexCount, "node")); + } + if (edgeCount > 0) { + clauses.push(formatStyleCount(edgeCount, "edge")); + } + return clauses.join(" and "); +} + +/** e.g. `formatStyleCount(2, "node")` → `"2 node styles"`. */ +function formatStyleCount(count: number, entity: string): string { + return `${count} ${entity} ${count === 1 ? "style" : "styles"}`; +} + +function ConflictLists({ conflicts }: { conflicts: ImportConflicts }) { + const { vertices, edges } = conflicts; + return ( + + {vertices.length > 0 ? ( + + ) : null} + {edges.length > 0 ? ( + + ) : null} + + ); +} + +function ConflictGroup({ label, types }: { label: string; types: string[] }) { + return ( + + + {label} + + {types.toSorted().map(type => ( + + {type} + + ))} + + ); +} + +function IssuesList({ issues }: { issues: ImportIssue[] }) { + const general = issues.filter(issue => issue.scope === "general"); + const entries = issues.filter(issue => issue.scope === "entry"); + const vertices = entries.filter(issue => issue.entityType === "vertex"); + const edges = entries.filter(issue => issue.entityType === "edge"); + return ( + + {general.length > 0 ? : null} + {vertices.length > 0 ? ( + + ) : null} + {edges.length > 0 ? ( + + ) : null} + + ); +} + +function GeneralIssuesGroup({ issues }: { issues: GeneralImportIssue[] }) { + return ( + + + File structure + + {issues.map((issue, i) => ( + +
+ {issue.location}: {issue.message} +
+
+ ))} +
+ ); +} + +function EntryIssuesGroup({ + label, + issues, +}: { + label: string; + issues: EntryImportIssue[]; +}) { + return ( + + + {label} + + {issues.map((issue, i) => ( + +
+ + {issue.typeName} + + {" → "} + {issue.field}: {issue.message} +
+
+ value:{" "} + {formatIssueValue(issue.value)} +
+
+ ))} +
+ ); +} + +function formatIssueValue(value: unknown): string { + if (typeof value === "string") { + return `"${value}"`; + } + if (value === undefined) { + return "undefined"; + } + return JSON.stringify(value); +} diff --git a/packages/graph-explorer/src/routes/Settings/ResetCustomStylesButton.tsx b/packages/graph-explorer/src/routes/Settings/ResetCustomStylesButton.tsx new file mode 100644 index 000000000..ec90a4cf6 --- /dev/null +++ b/packages/graph-explorer/src/routes/Settings/ResetCustomStylesButton.tsx @@ -0,0 +1,65 @@ +import { useSetAtom } from "jotai"; +import { Trash2Icon } from "lucide-react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, + AlertDialogTrigger, + Button, +} from "@/components"; +import { + userEdgeStylesAtom, + userVertexStylesAtom, +} from "@/core/StateProvider/storageAtoms"; + +export default function ResetCustomStylesButton() { + const setUserVertexStyles = useSetAtom(userVertexStylesAtom); + const setUserEdgeStyles = useSetAtom(userEdgeStylesAtom); + + function resetCustomStyles() { + setUserVertexStyles(new Map()); + setUserEdgeStyles(new Map()); + } + + return ( + + + + + + + + + + Reset Your Styles + + + This will clear all the styles you've set yourself. Shared + styles will remain. Consider saving first. + + This cannot be undone. + + + + Cancel + + Reset Your Styles + + + + + ); +} diff --git a/packages/graph-explorer/src/routes/Settings/ResetSharedStylesButton.tsx b/packages/graph-explorer/src/routes/Settings/ResetSharedStylesButton.tsx new file mode 100644 index 000000000..c66921c8d --- /dev/null +++ b/packages/graph-explorer/src/routes/Settings/ResetSharedStylesButton.tsx @@ -0,0 +1,65 @@ +import { useSetAtom } from "jotai"; +import { Trash2Icon } from "lucide-react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, + AlertDialogTrigger, + Button, +} from "@/components"; +import { + sharedEdgeStylesAtom, + sharedVertexStylesAtom, +} from "@/core/StateProvider/storageAtoms"; + +export default function ResetSharedStylesButton() { + const setSharedVertexStyles = useSetAtom(sharedVertexStylesAtom); + const setSharedEdgeStyles = useSetAtom(sharedEdgeStylesAtom); + + function resetSharedStyles() { + setSharedVertexStyles(new Map()); + setSharedEdgeStyles(new Map()); + } + + return ( + + + + + + + + + + Reset Shared Styles + + + This will remove all shared styles. Your styles will remain. You + can load a file again at any time; consider saving first. + + This cannot be undone. + + + + Cancel + + Reset Shared Styles + + + + + ); +} diff --git a/packages/graph-explorer/src/routes/Settings/SaveStylesButton.test.tsx b/packages/graph-explorer/src/routes/Settings/SaveStylesButton.test.tsx new file mode 100644 index 000000000..199e22494 --- /dev/null +++ b/packages/graph-explorer/src/routes/Settings/SaveStylesButton.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Suspense } from "react"; +import { describe, expect, test, vi } from "vitest"; + +import { TooltipProvider } from "@/components"; +import { getAppStore } from "@/core"; +import { createQueryClient } from "@/core/queryClient"; +import { saveFile } from "@/utils/fileData"; +import { TestProvider } from "@/utils/testing"; + +import SaveStylesButton from "./SaveStylesButton"; + +vi.mock("@/utils/fileData", () => ({ + toJsonFileData: vi.fn(() => new Blob(["{}"], { type: "application/json" })), + fromFileToJson: vi.fn(), + saveFile: vi.fn(), +})); + +function renderButton() { + const store = getAppStore(); + const queryClient = createQueryClient(); + render( + + + + + , + ); + return store; +} + +function saveButton() { + return screen.getByRole("button", { name: "Save to File" }); +} + +describe("SaveStylesButton", () => { + test("writes the styles file and shows no dialog on success", async () => { + const user = userEvent.setup(); + vi.mocked(saveFile).mockResolvedValue(undefined); + renderButton(); + + await user.click(saveButton()); + + expect(saveFile).toHaveBeenCalledWith( + expect.any(Blob), + "graph-explorer.styles.json", + "Graph Explorer styles", + ); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + }); + + test("treats a dismissed save picker as a cancel, not a failure", async () => { + const user = userEvent.setup(); + const abort = new Error("The user aborted a request."); + abort.name = "AbortError"; + vi.mocked(saveFile).mockRejectedValue(abort); + renderButton(); + + await user.click(saveButton()); + + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + }); + + test("surfaces a humane error when the file cannot be saved", async () => { + const user = userEvent.setup(); + vi.mocked(saveFile).mockRejectedValue(new Error("disk full")); + renderButton(); + + await user.click(saveButton()); + + expect(await screen.findByText("Something went wrong")).toBeInTheDocument(); + }); + + test("closes the dialog when the failure is dismissed", async () => { + const user = userEvent.setup(); + vi.mocked(saveFile).mockRejectedValue(new Error("disk full")); + renderButton(); + + await user.click(saveButton()); + expect(await screen.findByText("Something went wrong")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Close" })); + + expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument(); + }); + + test("never reveals an ancestor Suspense fallback while saving", async () => { + const user = userEvent.setup(); + // Hold the save open so a non-transitioned dispatch would have time to + // reveal the boundary while pending. + vi.mocked(saveFile).mockImplementation( + () => new Promise(resolve => setTimeout(resolve, 20)), + ); + const store = getAppStore(); + render( + + + page loading}> + + + + , + ); + + let fallbackSeen = false; + const observer = new MutationObserver(() => { + if (screen.queryByText("page loading")) fallbackSeen = true; + }); + observer.observe(document.body, { childList: true, subtree: true }); + + await user.click(saveButton()); + await vi.waitFor(() => expect(saveFile).toHaveBeenCalled()); + observer.disconnect(); + + expect(fallbackSeen).toBe(false); + }); +}); diff --git a/packages/graph-explorer/src/routes/Settings/SaveStylesButton.tsx b/packages/graph-explorer/src/routes/Settings/SaveStylesButton.tsx new file mode 100644 index 000000000..0bb95748f --- /dev/null +++ b/packages/graph-explorer/src/routes/Settings/SaveStylesButton.tsx @@ -0,0 +1,126 @@ +import { DownloadIcon, TriangleAlertIcon } from "lucide-react"; +import { startTransition, useActionState } from "react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, + Button, +} from "@/components"; +import { createFileEnvelope } from "@/core/fileEnvelope"; +import { + STYLING_EXPORT_KIND, + STYLING_EXPORT_VERSION, + useExportStylingFile, +} from "@/core/styling"; +import { isCancellationError, logger } from "@/utils"; +import { + createDisplayError, + type DisplayError, +} from "@/utils/createDisplayError"; +import { saveFile, toJsonFileData } from "@/utils/fileData"; + +/** + * What the save dialog is showing. `closed` is the resting state — the dialog is + * hidden, which covers both a successful save and a dismissed save picker (a + * cancel, not a failure). `failed` carries a display-ready error for anything + * that actually went wrong while writing the file. + */ +type DialogState = { kind: "closed" } | { kind: "failed"; error: DisplayError }; + +type SaveAction = { type: "save" } | { type: "dismiss" }; + +export default function SaveStylesButton() { + const { getExportPayload } = useExportStylingFile(); + + // The action is a reducer that awaits the file-save side effect, since it runs + // as an event rather than as a pure reducer. React tracks `isPending` across + // the await, so `state` is the single source of truth for what the dialog + // shows. + async function runSave( + _state: DialogState, + action: SaveAction, + ): Promise { + switch (action.type) { + case "save": + try { + const payload = getExportPayload(); + const envelope = createFileEnvelope( + STYLING_EXPORT_KIND, + STYLING_EXPORT_VERSION, + payload, + ); + await saveFile( + toJsonFileData(envelope), + "graph-explorer.styles.json", + "Graph Explorer styles", + ); + return { kind: "closed" }; + } catch (error) { + // Dismissing the save picker is a cancel, not a failure — stay closed. + if (isCancellationError(error)) { + return { kind: "closed" }; + } + logger.error("Save failed", error); + return { kind: "failed", error: createDisplayError(error) }; + } + case "dismiss": + return { kind: "closed" }; + } + } + + const [state, dispatchAction, isPending] = useActionState(runSave, { + kind: "closed", + }); + + // The action awaits, so every dispatch must run inside a transition — else + // React both warns and lets the pending update reveal the nearest Suspense + // fallback (the whole Settings page). Wrapping here makes every call site + // transition-safe by construction. + function dispatch(action: SaveAction) { + startTransition(() => dispatchAction(action)); + } + + return ( + <> + + !o && dispatch({ type: "dismiss" })} + > + {state.kind === "failed" ? ( + + ) : null} + + + ); +} + +function SaveFailedContent({ error }: { error: DisplayError }) { + return ( + + + + + + {error.title} + {error.message} + + + Close + + + ); +} diff --git a/packages/graph-explorer/src/routes/Settings/SettingsRoot.tsx b/packages/graph-explorer/src/routes/Settings/SettingsRoot.tsx index 31fa01c3b..4722655f8 100644 --- a/packages/graph-explorer/src/routes/Settings/SettingsRoot.tsx +++ b/packages/graph-explorer/src/routes/Settings/SettingsRoot.tsx @@ -1,4 +1,4 @@ -import { CogIcon, InfoIcon } from "lucide-react"; +import { CogIcon, InfoIcon, SwatchBookIcon } from "lucide-react"; import { type PropsWithChildren, Suspense } from "react"; import { NavLink, Outlet, type To } from "react-router"; @@ -62,6 +62,12 @@ function SideBar() { General +
  • + + + 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..028d50931 --- /dev/null +++ b/packages/graph-explorer/src/routes/Settings/SettingsStyles.tsx @@ -0,0 +1,127 @@ +import { useAtomValue } from "jotai"; +import { SwatchBookIcon, TriangleAlertIcon } from "lucide-react"; + +import { + Group, + GroupHeader, + GroupItem, + GroupMedia, + GroupTitle, + LabelledSetting, + SettingsPageDescription, + SettingsPageHeader, + SettingsPageIcon, + SettingsPageTitle, + SettingsPage, +} from "@/components"; +import { + sharedEdgeStylesAtom, + sharedVertexStylesAtom, +} from "@/core/StateProvider/storageAtoms"; + +import LoadStylesButton from "./LoadStylesButton"; +import ResetCustomStylesButton from "./ResetCustomStylesButton"; +import ResetSharedStylesButton from "./ResetSharedStylesButton"; +import SaveStylesButton from "./SaveStylesButton"; + +export default function SettingsStyles() { + const sharedVertexStyles = useAtomValue(sharedVertexStylesAtom); + const sharedEdgeStyles = useAtomValue(sharedEdgeStylesAtom); + + return ( + + + + + + Styles + + Share your node and edge styles with others, or reset them back to + defaults. Your styles and shared styles are tracked separately. + + + + + + Style Sharing + + + + + + + + + + + + + + + + + + + + Danger Zone + + + + + + + + + + + + + + ); +} + +function SharedStylesStatus({ + vertexCount, + edgeCount, +}: { + vertexCount: number; + edgeCount: number; +}) { + if (vertexCount === 0 && edgeCount === 0) return null; + + const parts: string[] = []; + if (vertexCount > 0) { + parts.push(`${vertexCount} node`); + } + if (edgeCount > 0) { + parts.push(`${edgeCount} edge`); + } + + return ( +
    +
    + +
    +

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

    +
    + ); +} 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/createDisplayError.test.ts b/packages/graph-explorer/src/utils/createDisplayError.test.ts index c6652f08d..65dcd24ca 100644 --- a/packages/graph-explorer/src/utils/createDisplayError.test.ts +++ b/packages/graph-explorer/src/utils/createDisplayError.test.ts @@ -1,6 +1,8 @@ // @vitest-environment happy-dom import { z } from "zod"; +import { FileEnvelopeError } from "@/core/fileEnvelope"; + import { createDisplayError } from "./createDisplayError"; import { NetworkError } from "./NetworkError"; import { ServerConnectionError } from "./ServerConnectionError"; @@ -207,6 +209,18 @@ describe("createDisplayError", () => { }); }); + it("Should surface a file envelope error message under an Invalid file title", () => { + const result = createDisplayError( + new FileEnvelopeError( + 'Expected a "styling-export" file, but got "connection-export"', + ), + ); + expect(result).toStrictEqual({ + title: "Invalid file", + message: 'Expected a "styling-export" file, but got "connection-export"', + }); + }); + it("Should handle zod validation errors", () => { const schema = z.object({ name: z.string(), age: z.number() }); const result = createDisplayError( diff --git a/packages/graph-explorer/src/utils/createDisplayError.ts b/packages/graph-explorer/src/utils/createDisplayError.ts index 25df2c5d6..b3d33bdca 100644 --- a/packages/graph-explorer/src/utils/createDisplayError.ts +++ b/packages/graph-explorer/src/utils/createDisplayError.ts @@ -1,5 +1,7 @@ import { ZodError } from "zod"; +import { FileEnvelopeError } from "@/core/fileEnvelope"; + import { extractErrorMessage } from "./extractErrorMessage"; import { isCancellationError } from "./isCancellationError"; import { NetworkError } from "./NetworkError"; @@ -134,6 +136,11 @@ export function createDisplayError(error: any): DisplayError { }; } + if (error instanceof FileEnvelopeError) { + // The message is already written for humans (wrong kind, too new, not JSON). + return { title: "Invalid file", message: error.message }; + } + if (error instanceof ZodError) { return { title: "Unrecognized Result Format", diff --git a/packages/graph-explorer/src/utils/fileData.test.ts b/packages/graph-explorer/src/utils/fileData.test.ts index 70e646417..1948e7d96 100644 --- a/packages/graph-explorer/src/utils/fileData.test.ts +++ b/packages/graph-explorer/src/utils/fileData.test.ts @@ -1,5 +1,10 @@ // @vitest-environment happy-dom -import { fromFileToJson, toCsvFileData, toJsonFileData } from "./fileData"; +import { + fromFileToJson, + saveFile, + toCsvFileData, + toJsonFileData, +} from "./fileData"; describe("toJsonFileData", () => { test("should create a JSON blob", () => { @@ -27,3 +32,39 @@ describe("fromFileToJson", () => { expect(result).toStrictEqual(data); }); }); + +describe("saveFile", () => { + test("labels the save picker's file type with the given description", async () => { + const write = vi.fn(); + const close = vi.fn(); + const createWritable = vi.fn().mockResolvedValue({ write, close }); + const showSaveFilePicker = vi.fn().mockResolvedValue({ createWritable }); + vi.stubGlobal("showSaveFilePicker", showSaveFilePicker); + + await saveFile(toJsonFileData({}), "example.styles.json", "Example styles"); + + expect(showSaveFilePicker).toHaveBeenCalledWith({ + suggestedName: "example.styles.json", + types: [ + { + description: "Example styles", + accept: { "application/json": [".json"] }, + }, + ], + }); + }); + + test("defaults the description to JSON when none is given", async () => { + const createWritable = vi + .fn() + .mockResolvedValue({ write: vi.fn(), close: vi.fn() }); + const showSaveFilePicker = vi.fn().mockResolvedValue({ createWritable }); + vi.stubGlobal("showSaveFilePicker", showSaveFilePicker); + + await saveFile(toJsonFileData({}), "example.json"); + + expect(showSaveFilePicker.mock.calls[0][0].types[0].description).toBe( + "JSON", + ); + }); +}); diff --git a/packages/graph-explorer/src/utils/fileData.ts b/packages/graph-explorer/src/utils/fileData.ts index fed9444ce..27779b984 100644 --- a/packages/graph-explorer/src/utils/fileData.ts +++ b/packages/graph-explorer/src/utils/fileData.ts @@ -22,17 +22,26 @@ export async function fromFileToJson(blob: Blob) { * * If the browser does not support the native file save dialog, it will fall back * to using the `file-saver` library. + * + * `description` labels the file type in the native save picker (e.g. "Graph + * Explorer styles"), helping the user recognize what they are saving. It has no + * effect on the `file-saver` fallback, which cannot set a picker label. */ -export async function saveFile(file: Blob, defaultFileName: string) { +export async function saveFile( + file: Blob, + defaultFileName: string, + description = "JSON", +) { if (!("showSaveFilePicker" in window)) { saveAs(file, defaultFileName); + return; } const fileHandle = await window.showSaveFilePicker({ suggestedName: defaultFileName, types: [ { - description: "JSON", + description, accept: { "application/json": [".json"] }, }, ], diff --git a/packages/graph-explorer/src/utils/index.ts b/packages/graph-explorer/src/utils/index.ts index 44fef6c49..b450aeefc 100644 --- a/packages/graph-explorer/src/utils/index.ts +++ b/packages/graph-explorer/src/utils/index.ts @@ -20,3 +20,4 @@ export * from "./numbers"; export * from "./isCancellationError"; export * from "./isVisible"; export * from "./parseNumberSafely"; +export * from "./typedEntries"; diff --git a/packages/graph-explorer/src/utils/saveConfigurationToFile.test.ts b/packages/graph-explorer/src/utils/saveConfigurationToFile.test.ts index 3db288bb8..739c78da6 100644 --- a/packages/graph-explorer/src/utils/saveConfigurationToFile.test.ts +++ b/packages/graph-explorer/src/utils/saveConfigurationToFile.test.ts @@ -44,7 +44,7 @@ describe("saveConfigurationToFile", () => { expect(saveAsMock).toHaveBeenCalledTimes(1); const [blob, filename] = saveAsMock.mock.calls[0]; - expect(filename).toBe(`${config.displayLabel}.json`); + expect(filename).toBe(`${config.displayLabel}.connection.json`); expect(blob).toBeInstanceOf(Blob); expect((blob as Blob).type).toBe("application/json"); }); @@ -55,7 +55,7 @@ describe("saveConfigurationToFile", () => { saveConfigurationToFile(config); const [, filename] = saveAsMock.mock.calls[0]; - expect(filename).toBe(`${config.id}.json`); + expect(filename).toBe(`${config.id}.connection.json`); }); it("should include connection with default queryEngine if not provided", async () => { diff --git a/packages/graph-explorer/src/utils/saveConfigurationToFile.ts b/packages/graph-explorer/src/utils/saveConfigurationToFile.ts index 978265c5a..97c0b7271 100644 --- a/packages/graph-explorer/src/utils/saveConfigurationToFile.ts +++ b/packages/graph-explorer/src/utils/saveConfigurationToFile.ts @@ -25,7 +25,7 @@ const saveConfigurationToFile = (config: ConfigurationContextProps) => { }; const fileToSave = toJsonFileData(exportableConfig); - saveAs(fileToSave, `${exportableConfig.displayLabel}.json`); + saveAs(fileToSave, `${exportableConfig.displayLabel}.connection.json`); }; export default saveConfigurationToFile; diff --git a/packages/graph-explorer/src/utils/typedEntries.test.ts b/packages/graph-explorer/src/utils/typedEntries.test.ts new file mode 100644 index 000000000..d87c4e49a --- /dev/null +++ b/packages/graph-explorer/src/utils/typedEntries.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "vitest"; + +import { typedEntries } from "./typedEntries"; + +describe("typedEntries", () => { + test("returns the same pairs as Object.entries", () => { + const obj = { a: 1, b: 2 }; + expect(typedEntries(obj)).toStrictEqual([ + ["a", 1], + ["b", 2], + ]); + }); + + test("returns an empty array for an empty object", () => { + expect(typedEntries({})).toStrictEqual([]); + }); +}); diff --git a/packages/graph-explorer/src/utils/typedEntries.ts b/packages/graph-explorer/src/utils/typedEntries.ts new file mode 100644 index 000000000..ecab395af --- /dev/null +++ b/packages/graph-explorer/src/utils/typedEntries.ts @@ -0,0 +1,10 @@ +/** + * `Object.entries` that preserves the key type rather than widening to + * `string`. Sound only when the object has no extra keys beyond `keyof T` — + * use at boundaries you control, not on values that may carry unknown keys. + */ +export function typedEntries( + obj: T, +): [keyof T, T[keyof T]][] { + return Object.entries(obj) as [keyof T, T[keyof T]][]; +} 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