diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 191fe3b7..f69ec723 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -32,7 +32,8 @@ }, "mounts": [ "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind", - "source=fileglancer-pixi,target=${containerWorkspaceFolder}/.pixi,type=volume" + "source=fileglancer-pixi,target=${containerWorkspaceFolder}/.pixi,type=volume", + "source=/groups/scicompsoft/home/truhlara/gh-repos/capability-manifest,target=/workspaces/capability-manifest,type=bind" ], "remoteEnv": { "NODE_OPTIONS": "--max-old-space-size=4096", diff --git a/CLAUDE.md b/CLAUDE.md index fcd4de6a..ecfdf6fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,6 +169,22 @@ Key settings: - `db_url`: Database connection string - SSL certificates for HTTPS mode +## Viewers Configuration + +Fileglancer uses a manifest-based viewer configuration system. Each viewer is defined by a **capability manifest** (a YAML file describing the viewer's name, URL template, and capabilities). The config file lists manifest URLs and optional overrides. + +- **Configuration file**: `frontend/src/config/viewers.config.yaml` -- lists viewers by `manifest_url` with optional `instance_template_url`, `label`, and `logo` overrides +- **Manifest files**: `frontend/public/viewers/*.yaml` -- capability manifest YAML files defining each viewer's identity and supported features +- **Compatibility**: Handled by the `@bioimagetools/capability-manifest` library, which checks dataset metadata against manifest capabilities at runtime +- **Documentation**: See `docs/ViewersConfiguration.md` + +To customize viewers: + +1. Edit `frontend/src/config/viewers.config.yaml` (add/remove `manifest_url` entries, override URLs or labels) +2. Rebuild application: `pixi run node-build` + +The default configuration includes Neuroglancer, Avivator, OME-Zarr Validator, and Vol-E viewers. The config file is bundled at build time. + ## Pixi Environments - `default`: Standard development diff --git a/docs/Development.md b/docs/Development.md index 0ca21579..f7c6608f 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -43,16 +43,16 @@ By default, Fileglancer provides access to each user's home directory without re ```yaml file_share_mounts: - - "~/" # User's home directory (default) + - "~/" # User's home directory (default) ``` You can add additional file share paths by editing your `config.yaml`: ```yaml file_share_mounts: - - "~/" # User's home directory - - "/groups/scicomp/data" # Shared data directory - - "/opt/data" # Another shared directory + - "~/" # User's home directory + - "/groups/scicomp/data" # Shared data directory + - "/opt/data" # Another shared directory ``` **How Home Directories Work:** @@ -66,6 +66,20 @@ file_share_mounts: Instead of using the `file_share_mounts` setting, you can configure file share paths in the database. This is useful for production deployments where you want centralized management of file share paths. To use the paths in the database, set `file_share_mounts: []`. See [fileglancer-janelia](https://github.com/JaneliaSciComp/fileglancer-janelia) for an example of populating the file share paths in the database, using a private wiki source. +### Viewers Configuration + +Fileglancer supports dynamic configuration of OME-Zarr viewers through `viewers.config.yaml`. This allows you to customize which viewers are available in your deployment and configure custom viewer URLs. + +**Quick Setup:** + +1. Edit the configuration file at `frontend/src/config/viewers.config.yaml` to enable/disable viewers or customize URLs + +2. Rebuild the application: `pixi run node-build` or use watch mode in development: `pixi run dev-watch` + +**Note:** The configuration file is bundled at build time, so changes require rebuilding the application. The default configuration includes Neuroglancer, Avivator, OME-Zarr Validator, and Vol-E viewers. + +For detailed configuration options, examples, and documentation on adding custom viewers, see [ViewersConfiguration.md](ViewersConfiguration.md). + ### Feature Flags Optional features can be enabled via Vite environment variables. Create or edit `frontend/.env`: diff --git a/docs/ViewersConfiguration.md b/docs/ViewersConfiguration.md new file mode 100644 index 00000000..d31111ef --- /dev/null +++ b/docs/ViewersConfiguration.md @@ -0,0 +1,252 @@ +# Viewers Configuration Guide + +Fileglancer supports dynamic configuration of OME-Zarr viewers. This allows administrators to customize which viewers are available in their deployment, override viewer URLs, and control how compatibility is determined. + +## Overview + +The viewer system is built on capability manifests: + +- **`viewers.config.yaml`**: Configuration file listing viewers and their manifest URLs +- **Capability manifest files**: YAML files describing each viewer's name, URL template, and capabilities +- **`@bioimagetools/capability-manifest`**: Library that loads manifests and checks dataset compatibility +- **`ViewersContext`**: React context that provides viewer information to the application + +Each viewer is defined by a **capability manifest** hosted at a URL. The configuration file simply lists manifest URLs and optional overrides. At runtime, the manifests are fetched, and the `@bioimagetools/capability-manifest` library determines which viewers are compatible with a given dataset based on the manifest's declared capabilities. + +## Quick Start + +1. Edit the configuration file at `frontend/src/config/viewers.config.yaml` +2. Rebuild the application: `pixi run node-build` + +## Configuration File + +### Location + +`frontend/src/config/viewers.config.yaml` + +**Important:** This file is bundled at build time. Changes require rebuilding the application. + +### Structure + +The configuration file has a single top-level key, `viewers`, containing a list of viewer entries. Each entry requires a `manifest_url` and supports optional overrides. + +#### Viewer Entry Fields + +| Field | Required | Description | +| ----------------------- | -------- | -------------------------------------------------------------------------------- | +| `manifest_url` | Yes | URL to a capability manifest YAML file | +| `instance_template_url` | No | Override the viewer's `template_url` from the manifest | +| `label` | No | Custom tooltip text (defaults to "View in {Name}") | +| `logo` | No | Filename of logo in `frontend/src/assets/` (defaults to `{normalized_name}.png`) | + +### Default Configuration + +The default `viewers.config.yaml` configures four viewers: + +```yaml +viewers: + - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/neuroglancer.yaml" + + - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vizarr.yaml" + instance_template_url: "https://janeliascicomp.github.io/viv/" + + - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/validator.yaml" + label: "View in OME-Zarr Validator" + + - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vole.yaml" + label: "View in Vol-E" +``` + +## Capability Manifest Files + +Manifest files describe a viewer's identity and capabilities. The default manifests are stored in `frontend/public/viewers/` and are hosted via GitHub. You can host your own manifest files anywhere accessible via URL. + +### Manifest Structure + +A manifest has two sections: `viewer` (identity) and `capabilities` (what the viewer supports). + +#### Example: `neuroglancer.yaml` + +```yaml +viewer: + name: "Neuroglancer" + version: "2.41.2" + repo: "https://github.com/google/neuroglancer" + template_url: https://neuroglancer-demo.appspot.com/#!{"layers":[{"name":"image","source":"{DATA_URL}","type":"image"}]} + +capabilities: + ome_zarr_versions: [0.4, 0.5] + compression_codecs: ["blosc", "zstd", "zlib", "lz4", "gzip"] + rfcs_supported: [] + axes: true + scale: true + translation: true + channels: true + timepoints: true + labels: false + hcs_plates: false + bioformats2raw_layout: false + omero_metadata: false +``` + +### Viewer Section + +| Field | Description | +| -------------- | -------------------------------------------------------------- | +| `name` | Display name for the viewer | +| `version` | Viewer version | +| `repo` | Repository URL | +| `template_url` | URL template with `{DATA_URL}` placeholder for the dataset URL | + +### Capabilities Section + +| Field | Type | Description | +| ----------------------- | -------- | ------------------------------------------------------------ | +| `ome_zarr_versions` | number[] | Supported OME-Zarr specification versions | +| `compression_codecs` | string[] | Supported compression codecs (e.g., "blosc", "zstd", "gzip") | +| `rfcs_supported` | string[] | Additional RFCs supported | +| `axes` | boolean | Whether axis names and units are respected | +| `scale` | boolean | Whether scaling factors on multiscales are respected | +| `translation` | boolean | Whether translation factors on multiscales are respected | +| `channels` | boolean | Whether multiple channels are supported | +| `timepoints` | boolean | Whether multiple timepoints are supported | +| `labels` | boolean | Whether labels are loaded when available | +| `hcs_plates` | boolean | Whether HCS plates are loaded when available | +| `bioformats2raw_layout` | boolean | Whether bioformats2raw layout is handled | +| `omero_metadata` | boolean | Whether OMERO metadata is used (e.g., channel colors) | + +## URL Templates and `{DATA_URL}` Placeholder + +The `{DATA_URL}` placeholder in a manifest's `template_url` (or a config entry's `instance_template_url`) is replaced at runtime with the actual dataset URL. Internally, `{DATA_URL}` is normalized to `{dataLink}` for consistency with the rest of the application. + +For example, given this manifest `template_url`: + +``` +https://neuroglancer-demo.appspot.com/#!{"layers":[{"name":"image","source":"{DATA_URL}","type":"image"}]} +``` + +When a user clicks the viewer link for a dataset at `https://example.com/data.zarr`, the final URL becomes: + +``` +https://neuroglancer-demo.appspot.com/#!{"layers":[{"name":"image","source":"https://example.com/data.zarr","type":"image"}]} +``` + +## Configuration Examples + +### Minimal: single viewer + +```yaml +viewers: + - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/neuroglancer.yaml" +``` + +### Override a viewer's URL + +Use `instance_template_url` to point to a custom deployment of a viewer while still using its manifest for capability matching: + +```yaml +viewers: + - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vizarr.yaml" + instance_template_url: "https://my-avivator-instance.example.com/?image_url={dataLink}" + logo: avivator.png +``` + +### Add a custom viewer + +To add a new viewer, create a capability manifest YAML file, host it at a URL, and reference it in the config: + +1. Create a manifest file (e.g., `my-viewer.yaml`): + +```yaml +viewer: + name: "My Viewer" + version: "1.0.0" + repo: "https://github.com/example/my-viewer" + template_url: "https://viewer.example.com/?data={DATA_URL}" + +capabilities: + ome_zarr_versions: [0.4, 0.5] + compression_codecs: ["blosc", "gzip"] + rfcs_supported: [] + axes: true + scale: true + translation: true + channels: true + timepoints: false + labels: false + hcs_plates: false + bioformats2raw_layout: false + omero_metadata: false +``` + +2. Host the manifest at an accessible URL (e.g., in your own `frontend/public/viewers/` directory, on GitHub, or any web server). + +3. Reference it in `viewers.config.yaml`: + +```yaml +viewers: + - manifest_url: "https://example.com/manifests/my-viewer.yaml" + label: "Open in My Viewer" +``` + +4. Optionally, add a logo file at `frontend/src/assets/myviewer.png` (the normalized name, lowercase with non-alphanumeric characters removed). + +## How Compatibility Works + +The `@bioimagetools/capability-manifest` library handles all compatibility checking. When a user views an OME-Zarr dataset: + +1. The application reads the dataset's metadata (OME-Zarr version, axes, codecs, etc.) +2. For each registered viewer, the library's `isCompatible()` function compares the dataset metadata against the manifest's declared capabilities +3. Only viewers whose capabilities match the dataset are shown to the user + +This replaces the previous system where `valid_ome_zarr_versions` was a global config setting and custom viewers used simple version matching. Now all compatibility logic is driven by the detailed capabilities declared in each viewer's manifest. + +## Adding Custom Viewer Logos + +Logo resolution follows this order: + +1. **Custom logo specified**: If you provide a `logo` field in the config entry, that filename is looked up in `frontend/src/assets/` +2. **Convention-based**: If no `logo` is specified, the system looks for `frontend/src/assets/{normalized_name}.png`, where the normalized name is the viewer's name lowercased with non-alphanumeric characters removed +3. **Fallback**: If neither is found, `frontend/src/assets/fallback_logo.png` is used + +### Examples + +**Using the naming convention (recommended):** + +```yaml +viewers: + - manifest_url: "https://example.com/manifests/neuroglancer.yaml" + # Logo automatically resolves to @/assets/neuroglancer.png +``` + +Just add `frontend/src/assets/neuroglancer.png` -- no config needed. + +**Using a custom logo filename:** + +```yaml +viewers: + - manifest_url: "https://example.com/manifests/vizarr.yaml" + logo: "avivator.png" # Uses @/assets/avivator.png +``` + +## Development + +When developing with custom configurations: + +1. Edit `frontend/src/config/viewers.config.yaml` +2. Rebuild frontend: `pixi run node-build` or use watch mode: `pixi run dev-watch` +3. Check the browser console for viewer initialization messages + +### Validation + +The configuration is validated at build time using Zod schemas (see `frontend/src/config/viewersConfig.ts`). Validation enforces: + +- The `viewers` array must contain at least one entry +- Each entry must have a valid `manifest_url` (a properly formed URL) +- Optional fields (`instance_template_url`, `label`, `logo`) must be strings if present + +At runtime, manifests that fail to load are skipped with a warning. If a viewer has no `template_url` (neither from its manifest nor from `instance_template_url` in the config), it is also skipped. + +## Copy URL Tool + +The "Copy data URL" tool is always available when a data URL exists, regardless of viewer configuration. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c322152a..0364c8ab 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,12 +9,15 @@ "version": "2.4.0", "license": "BSD-3-Clause", "dependencies": { + "@bioimagetools/capability-manifest": "^0.3.3", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-query": "^5.90.2", "@tanstack/react-table": "^8.21.3", + "@types/js-yaml": "^4.0.9", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^5.0.0", "autoprefixer": "^10.4.21", + "js-yaml": "^4.1.1", "loglevel": "^1.9.2", "npm-run-all2": "^7.0.2", "ome-zarr.js": "^0.0.17", @@ -31,7 +34,8 @@ "react-syntax-highlighter": "^16.1.0", "shepherd.js": "^14.5.1", "tailwindcss": "^3.4.17", - "zarrita": "^0.5.1" + "zarrita": "^0.5.1", + "zod": "^4.3.6" }, "devDependencies": { "@eslint/css": "^0.8.1", @@ -398,6 +402,15 @@ "node": ">=18" } }, + "node_modules/@bioimagetools/capability-manifest": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@bioimagetools/capability-manifest/-/capability-manifest-0.3.3.tgz", + "integrity": "sha512-McaIsgGyrxRxdQbDmek8On7PeSFA47pYOrfSudvd0d+VtZXX0VCYzq4RmJswVT+h19Bi4b4vTIinhJE0ACsCwA==", + "license": "ISC", + "dependencies": { + "js-yaml": "^4.1.1" + } + }, "node_modules/@emnapi/core": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", @@ -1825,6 +1838,12 @@ "@types/unist": "*" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2437,7 +2456,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -5863,7 +5881,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -11103,6 +11120,15 @@ "numcodecs": "^0.3.2" } }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 12a3c0ba..f7769b4a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,12 +30,15 @@ "test": "vitest" }, "dependencies": { + "@bioimagetools/capability-manifest": "^0.3.3", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-query": "^5.90.2", "@tanstack/react-table": "^8.21.3", + "@types/js-yaml": "^4.0.9", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^5.0.0", "autoprefixer": "^10.4.21", + "js-yaml": "^4.1.1", "loglevel": "^1.9.2", "npm-run-all2": "^7.0.2", "ome-zarr.js": "^0.0.17", @@ -52,7 +55,8 @@ "react-syntax-highlighter": "^16.1.0", "shepherd.js": "^14.5.1", "tailwindcss": "^3.4.17", - "zarrita": "^0.5.1" + "zarrita": "^0.5.1", + "zod": "^4.3.6" }, "devDependencies": { "@eslint/css": "^0.8.1", diff --git a/frontend/public/viewers/neuroglancer.yaml b/frontend/public/viewers/neuroglancer.yaml new file mode 100644 index 00000000..9b395a8e --- /dev/null +++ b/frontend/public/viewers/neuroglancer.yaml @@ -0,0 +1,41 @@ +viewer: + name: "Neuroglancer" + version: "2.41.2" + repo: "https://github.com/google/neuroglancer" + template_url: https://neuroglancer-demo.appspot.com/#!{"layers":[{"name":"image","source":"{DATA_URL}","type":"image"}]} + +capabilities: + # Enumeration of OME-Zarr versions that can be loaded + ome_zarr_versions: [0.4, 0.5] + + compression_codecs: ["blosc", "zstd", "zlib", "lz4", "gzip"] + + # Which additional RFCs are supported? + rfcs_supported: [] + + # Are axis names and units respected? + axes: true + + # Are scaling factors respected on multiscales? + scale: true + + # Are translation factors respected on multiscales (including subpixel offsets for lower scale levels)? + translation: true + + # Does the tool support multiple channels? + channels: true + + # Does the tool support multiple timepoints? + timepoints: true + + # Are labels loaded when available? + labels: false + + # Are HCS plates loaded when available? + hcs_plates: false + + # Does the viewer handle multiple images in a bioformats2raw layout? + bioformats2raw_layout: false + + # Is the OMERO metadata used to e.g. color the channels? + omero_metadata: false diff --git a/frontend/public/viewers/validator.yaml b/frontend/public/viewers/validator.yaml new file mode 100644 index 00000000..8a3ee897 --- /dev/null +++ b/frontend/public/viewers/validator.yaml @@ -0,0 +1,41 @@ +viewer: + name: 'Validator' + version: '1.0.0' + repo: 'https://github.com/ome/ome-ngff-validator' + template_url: 'https://ome.github.io/ome-ngff-validator/?source={DATA_URL}' + +capabilities: + # Enumeration of OME-Zarr versions that can be loaded + ome_zarr_versions: [0.4, 0.5] + + compression_codecs: [] + + # Which additional RFCs are supported? + rfcs_supported: [] + + # Are axis names and units respected? + axes: true + + # Are scaling factors respected on multiscales? + scale: true + + # Are translation factors respected on multiscales (including subpixel offsets for lower scale levels)? + translation: true + + # Does the tool support multiple channels? + channels: true + + # Does the tool support multiple timepoints? + timepoints: true + + # Are labels loaded when available? + labels: true + + # Are HCS plates loaded when available? + hcs_plates: true + + # Does the viewer handle multiple images in a bioformats2raw layout? + bioformats2raw_layout: false + + # Is the OMERO metadata used to e.g. color the channels? + omero_metadata: true diff --git a/frontend/public/viewers/vizarr.yaml b/frontend/public/viewers/vizarr.yaml new file mode 100644 index 00000000..3098d390 --- /dev/null +++ b/frontend/public/viewers/vizarr.yaml @@ -0,0 +1,41 @@ +viewer: + name: "Avivator" + version: "0.16.1" + repo: "https://github.com/hms-dbmi/viv" + template_url: "https://avivator.gehlenborglab.org/?image_url={DATA_URL}" + +capabilities: + # Enumeration of OME-Zarr versions that can be loaded + ome_zarr_versions: [0.4] + + compression_codecs: ["blosc", "gzip"] + + # Which additional RFCs are supported? + rfcs_supported: [] + + # Are axis names and units respected? + axes: true + + # Are scaling factors respected on multiscales? + scale: true + + # Are translation factors respected on multiscales (including subpixel offsets for lower scale levels)? + translation: true + + # Does the tool support multiple channels? + channels: true + + # Does the tool support multiple timepoints? + timepoints: true + + # Are labels loaded when available? + labels: false + + # Are HCS plates loaded when available? + hcs_plates: false + + # Does the viewer handle multiple images in a bioformats2raw layout? + bioformats2raw_layout: false + + # Is the OMERO metadata used to e.g. color the channels? + omero_metadata: true diff --git a/frontend/public/viewers/vole.yaml b/frontend/public/viewers/vole.yaml new file mode 100644 index 00000000..fdd67803 --- /dev/null +++ b/frontend/public/viewers/vole.yaml @@ -0,0 +1,41 @@ +viewer: + name: 'Vol-E' + version: '1.0.0' + repo: 'https://github.com/allen-cell-animated/volume-viewer' + template_url: 'https://volumeviewer.allencell.org/viewer?url={DATA_URL}' + +capabilities: + # Enumeration of OME-Zarr versions that can be loaded + ome_zarr_versions: [0.4, 0.5] + + compression_codecs: [] + + # Which additional RFCs are supported? + rfcs_supported: [] + + # Are axis names and units respected? + axes: true + + # Are scaling factors respected on multiscales? + scale: true + + # Are translation factors respected on multiscales (including subpixel offsets for lower scale levels)? + translation: true + + # Does the tool support multiple channels? + channels: true + + # Does the tool support multiple timepoints? + timepoints: true + + # Are labels loaded when available? + labels: false + + # Are HCS plates loaded when available? + hcs_plates: false + + # Does the viewer handle multiple images in a bioformats2raw layout? + bioformats2raw_layout: false + + # Is the OMERO metadata used to e.g. color the channels? + omero_metadata: false diff --git a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx new file mode 100644 index 00000000..48901abe --- /dev/null +++ b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx @@ -0,0 +1,389 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import { render, screen } from '@/__tests__/test-utils'; +import DataToolLinks from '@/components/ui/BrowsePage/DataToolLinks'; +import type { OpenWithToolUrls, PendingToolKey } from '@/hooks/useZarrMetadata'; +import { ViewersProvider } from '@/contexts/ViewersContext'; + +// Mock logger to capture console warnings +const mockLogger = vi.hoisted(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() +})); + +vi.mock('@/logger', () => ({ + default: mockLogger +})); + +// Mock capability manifest to avoid network requests in tests +const mockCapabilityManifest = vi.hoisted(() => ({ + loadManifestsFromUrls: vi.fn(), + isCompatible: vi.fn() +})); + +vi.mock('@bioimagetools/capability-manifest', () => mockCapabilityManifest); + +const mockOpenWithToolUrls: OpenWithToolUrls = { + copy: 'http://localhost:3000/test/copy/url', + validator: 'http://localhost:3000/test/validator/url', + neuroglancer: 'http://localhost:3000/test/neuroglancer/url', + vole: 'http://localhost:3000/test/vole/url', + vizarr: 'http://localhost:3000/test/vizarr/url' +}; + +// Helper component to wrap DataToolLinks with ViewersProvider +function TestDataToolLinksComponent({ + urls = mockOpenWithToolUrls, + onToolClick = vi.fn() +}: { + urls?: OpenWithToolUrls | null; + onToolClick?: (toolKey: PendingToolKey) => Promise; +}) { + return ( + + + + ); +} + +// Wrapper function for rendering with proper route context +function renderDataToolLinks( + urls?: OpenWithToolUrls | null, + onToolClick?: (toolKey: PendingToolKey) => Promise +) { + return render( + , + { initialEntries: ['/browse/test_fsp/test_file'] } + ); +} + +describe('DataToolLinks - Error Scenarios', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Default mock: return empty Map (no manifests loaded) + mockCapabilityManifest.loadManifestsFromUrls.mockResolvedValue(new Map()); + mockCapabilityManifest.isCompatible.mockReturnValue(false); + }); + + describe('Invalid YAML syntax', () => { + it('should log error when YAML parsing fails in ViewersContext', async () => { + // This test verifies that the ViewersContext logs errors appropriately + // The actual YAML parsing error is tested in the ViewersContext initialization + + // Import the parseViewersConfig function to test it directly + const { parseViewersConfig } = await import('@/config/viewersConfig'); + + const invalidYaml = 'viewers:\n - manifest_url: test\n invalid: [[['; + + expect(() => parseViewersConfig(invalidYaml)).toThrow( + /Failed to parse viewers configuration YAML/ + ); + }); + + it('should still render when ViewersContext fails to initialize', async () => { + // When ViewersContext fails to initialize, it sets error state + // and logs to console. The component should still render but with empty viewers. + renderDataToolLinks(); + + await waitFor( + () => { + // The component should still be initialized (to prevent hanging) + // but viewers may be empty if there was an error + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); + }); + + describe('Missing required fields', () => { + it('should throw error when viewer lacks required manifest_url field', async () => { + const { parseViewersConfig } = await import('@/config/viewersConfig'); + + const configMissingManifestUrl = ` +viewers: + - label: Custom Label + # Missing manifest_url +`; + + expect(() => parseViewersConfig(configMissingManifestUrl)).toThrow( + /Each viewer must have a "manifest_url" field/ + ); + }); + + it('should throw error when viewers array is empty', async () => { + const { parseViewersConfig } = await import('@/config/viewersConfig'); + + const configEmptyViewers = ` +viewers: [] +`; + + expect(() => parseViewersConfig(configEmptyViewers)).toThrow( + /"viewers" must contain at least one viewer/ + ); + }); + }); +}); + +describe('DataToolLinks - Edge Cases', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Mock loadManifestsFromUrls to return Map with manifests + // URLs must match those in viewers.config.yaml + mockCapabilityManifest.loadManifestsFromUrls.mockResolvedValue( + new Map([ + [ + '/viewers/neuroglancer.yaml', + { + viewer: { + name: 'Neuroglancer', + template_url: 'https://neuroglancer.com/#!{DATA_URL}' + } + } + ], + [ + '/viewers/vizarr.yaml', + { + viewer: { + name: 'Avivator', + template_url: 'https://vizarr.com/?url={DATA_URL}' + } + } + ] + ]) + ); + + // Mock isCompatible to return true for all viewers + mockCapabilityManifest.isCompatible.mockReturnValue(true); + }); + + describe('Logo rendering in components', () => { + it('should render viewer logos in component', async () => { + // Test that viewers with known logo files render correctly in the component + renderDataToolLinks(); + + await waitFor( + () => { + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Check that images are rendered + const images = screen.getAllByRole('img'); + + // Check for neuroglancer logo (known viewer with logo) + const neuroglancerLogo = images.find( + img => img.getAttribute('alt') === 'View in Neuroglancer' + ); + expect(neuroglancerLogo).toBeTruthy(); + expect(neuroglancerLogo?.getAttribute('src')).toContain('neuroglancer'); + + // Check for avivator logo (name for viewer in vizarr.yaml) + const vizarrLogo = images.find( + img => img.getAttribute('alt') === 'View in Avivator' + ); + expect(vizarrLogo).toBeTruthy(); + expect(vizarrLogo?.getAttribute('src')).toContain('avivator'); + }); + }); + + describe('Custom viewer compatibility', () => { + it('should exclude viewer URL when set to null in OpenWithToolUrls', async () => { + const urls: OpenWithToolUrls = { + copy: 'http://localhost:3000/copy', + neuroglancer: 'http://localhost:3000/neuroglancer', + customviewer: null // Custom viewer not compatible (explicitly null) + }; + + renderDataToolLinks(urls); + + await waitFor( + () => { + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Should have neuroglancer logo and copy icon + const images = screen.getAllByRole('img'); + expect(images.length).toBeGreaterThanOrEqual(2); + + // Check for neuroglancer logo + const neuroglancerLogo = images.find( + img => img.getAttribute('alt') === 'View in Neuroglancer' + ); + expect(neuroglancerLogo).toBeTruthy(); + + // Check for copy icon + const copyIcon = images.find( + img => img.getAttribute('alt') === 'Copy URL icon' + ); + expect(copyIcon).toBeTruthy(); + }); + }); + + describe('Component behavior with null urls', () => { + it('should render nothing when urls is null', () => { + renderDataToolLinks(null); + + // Component should not render when urls is null + expect(screen.queryByText('Test Tools')).not.toBeInTheDocument(); + }); + }); +}); + +describe('DataToolLinks - Expected Behavior', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Mock loadManifestsFromUrls to return Map with manifests + // URLs must match those in viewers.config.yaml + mockCapabilityManifest.loadManifestsFromUrls.mockResolvedValue( + new Map([ + [ + '/viewers/neuroglancer.yaml', + { + viewer: { + name: 'Neuroglancer', + template_url: 'https://neuroglancer.com/#!{DATA_URL}' + } + } + ], + [ + '/viewers/vizarr.yaml', + { + viewer: { + name: 'Avivator', + template_url: 'https://vizarr.com/?url={DATA_URL}' + } + } + ] + ]) + ); + + // Mock isCompatible to return true for all viewers + mockCapabilityManifest.isCompatible.mockReturnValue(true); + }); + + describe('Component behavior with valid viewers', () => { + it('should render valid viewer icons and copy icon', async () => { + renderDataToolLinks(); + + await waitFor( + () => { + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Should render copy icon at minimum + const images = screen.getAllByRole('img'); + const copyIcon = images.find( + img => img.getAttribute('alt') === 'Copy URL icon' + ); + expect(copyIcon).toBeTruthy(); + + // Should also have viewer logos + expect(images.length).toBeGreaterThan(1); + }); + + it('should call onToolClick when copy icon is clicked', async () => { + const onToolClick = vi.fn(async () => {}); + renderDataToolLinks(undefined, onToolClick); + + await waitFor( + () => { + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Click the copy icon (always present) + const images = screen.getAllByRole('img'); + const copyIcon = images.find( + img => img.getAttribute('alt') === 'Copy URL icon' + ); + expect(copyIcon).toBeTruthy(); + + const copyButton = copyIcon!.closest('button'); + expect(copyButton).toBeTruthy(); + + copyButton!.click(); + + await waitFor(() => { + expect(onToolClick).toHaveBeenCalledWith('copy'); + }); + }); + + it('should render multiple viewer logos when URLs are provided', async () => { + renderDataToolLinks(); + + await waitFor( + () => { + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + const images = screen.getAllByRole('img'); + + // Should have neuroglancer, vizarr, and copy icons at minimum + expect(images.length).toBeGreaterThanOrEqual(3); + + // Verify specific logos are present + const neuroglancerLogo = images.find( + img => img.getAttribute('alt') === 'View in Neuroglancer' + ); + const vizarrLogo = images.find( + img => img.getAttribute('alt') === 'View in Avivator' + ); + + expect(neuroglancerLogo).toBeTruthy(); + expect(vizarrLogo).toBeTruthy(); + }); + }); + + describe('Tooltip behavior', () => { + it('should show "Copy data URL" tooltip by default', async () => { + renderDataToolLinks(); + + await waitFor( + () => { + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // The copy button should have the correct aria-label + const copyButton = screen.getByLabelText('Copy data URL'); + expect(copyButton).toBeInTheDocument(); + }); + + it('should show viewer tooltip labels', async () => { + renderDataToolLinks(); + + await waitFor( + () => { + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Viewer buttons should have correct aria-labels from their config + const neuroglancerButton = screen.getByAltText('View in Neuroglancer'); + expect(neuroglancerButton).toBeInTheDocument(); + + const vizarrButton = screen.getByAltText('View in Avivator'); + expect(vizarrButton).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/__tests__/test-utils.tsx b/frontend/src/__tests__/test-utils.tsx index a54d0a43..f3bcce0c 100644 --- a/frontend/src/__tests__/test-utils.tsx +++ b/frontend/src/__tests__/test-utils.tsx @@ -15,6 +15,7 @@ import { TicketProvider } from '@/contexts/TicketsContext'; import { ProfileContextProvider } from '@/contexts/ProfileContext'; import { ExternalBucketProvider } from '@/contexts/ExternalBucketContext'; import { ServerHealthProvider } from '@/contexts/ServerHealthContext'; +import { ViewersProvider } from '@/contexts/ViewersContext'; import ErrorFallback from '@/components/ErrorFallback'; interface CustomRenderOptions extends Omit { @@ -40,13 +41,15 @@ const Browse = ({ children }: { children: ReactNode }) => { - - - - {children} - - - + + + + + {children} + + + + diff --git a/frontend/src/__tests__/unitTests/viewerLogos.test.ts b/frontend/src/__tests__/unitTests/viewerLogos.test.ts new file mode 100644 index 00000000..e22d216c --- /dev/null +++ b/frontend/src/__tests__/unitTests/viewerLogos.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { getViewerLogo } from '@/config/viewerLogos'; +import fallback_logo from '@/assets/fallback_logo.png'; + +describe('getViewerLogo', () => { + describe('Existing logo files', () => { + it('should return logo path for viewer with existing logo file', () => { + const neuroglancerLogo = getViewerLogo('neuroglancer'); + expect(neuroglancerLogo).toBeTruthy(); + expect(neuroglancerLogo).not.toBe(fallback_logo); + }); + + it('should return logo path for avivator', () => { + const avivatorLogo = getViewerLogo('avivator'); + expect(avivatorLogo).toBeTruthy(); + expect(avivatorLogo).not.toBe(fallback_logo); + }); + + it('should return logo path for validator', () => { + const validatorLogo = getViewerLogo('validator'); + expect(validatorLogo).toBeTruthy(); + expect(validatorLogo).not.toBe(fallback_logo); + }); + + it('should return logo path for vole', () => { + const voleLogo = getViewerLogo('vol-e'); + expect(voleLogo).toBeTruthy(); + expect(voleLogo).not.toBe(fallback_logo); + }); + }); + + describe('Custom logo paths', () => { + it('should return logo when custom logo path exists', () => { + // Using an existing logo file as a custom path + const customLogo = getViewerLogo('any-name', 'neuroglancer.png'); + expect(customLogo).toBeTruthy(); + expect(customLogo).not.toBe(fallback_logo); + }); + + it('should return fallback when custom logo path does not exist', () => { + const nonExistentCustomLogo = getViewerLogo('test', 'nonexistent.png'); + expect(nonExistentCustomLogo).toBe(fallback_logo); + }); + }); + + describe('Fallback logo handling', () => { + it('should return fallback logo when viewer logo file does not exist', () => { + const nonExistentViewerLogo = getViewerLogo('nonexistent_viewer'); + expect(nonExistentViewerLogo).toBe(fallback_logo); + }); + + it('should return fallback logo for custom_viewer without logo file', () => { + const customViewerLogo = getViewerLogo('custom_viewer'); + expect(customViewerLogo).toBe(fallback_logo); + }); + + it('should return fallback logo for unknown viewer names', () => { + const unknownLogo = getViewerLogo('unknown_test_viewer_xyz'); + expect(unknownLogo).toBe(fallback_logo); + }); + }); + + describe('Case handling', () => { + it('should handle lowercase viewer names', () => { + const logo = getViewerLogo('neuroglancer'); + expect(logo).toBeTruthy(); + expect(logo).not.toBe(fallback_logo); + }); + + it('should convert uppercase to lowercase for logo lookup', () => { + // getViewerLogo converts to lowercase, so 'NEUROGLANCER' -> 'neuroglancer.png' + const logo = getViewerLogo('NEUROGLANCER'); + expect(logo).toBeTruthy(); + expect(logo).not.toBe(fallback_logo); + }); + + it('should handle mixed case viewer names', () => { + const logo = getViewerLogo('NeuroGlancer'); + expect(logo).toBeTruthy(); + expect(logo).not.toBe(fallback_logo); + }); + }); + + describe('Edge cases', () => { + it('should handle empty string viewer name', () => { + const emptyLogo = getViewerLogo(''); + expect(emptyLogo).toBe(fallback_logo); + }); + + it('should handle viewer names with special characters', () => { + const specialLogo = getViewerLogo('viewer-with-dashes'); + expect(specialLogo).toBe(fallback_logo); + }); + + it('should handle viewer names with underscores', () => { + const underscoreLogo = getViewerLogo('viewer_with_underscores'); + expect(underscoreLogo).toBe(fallback_logo); + }); + }); +}); diff --git a/frontend/src/__tests__/unitTests/viewersConfig.test.ts b/frontend/src/__tests__/unitTests/viewersConfig.test.ts new file mode 100644 index 00000000..3277ccdb --- /dev/null +++ b/frontend/src/__tests__/unitTests/viewersConfig.test.ts @@ -0,0 +1,369 @@ +import { describe, it, expect } from 'vitest'; +import { parseViewersConfig } from '@/config/viewersConfig'; + +describe('parseViewersConfig', () => { + describe('Valid configurations', () => { + it('should parse config with single manifest_url viewer', () => { + const yaml = ` +viewers: + - manifest_url: https://example.com/neuroglancer.yaml +`; + const result = parseViewersConfig(yaml); + + expect(result.viewers).toHaveLength(1); + expect(result.viewers[0].manifest_url).toBe( + 'https://example.com/neuroglancer.yaml' + ); + expect(result.viewers[0].instance_template_url).toBeUndefined(); + expect(result.viewers[0].label).toBeUndefined(); + expect(result.viewers[0].logo).toBeUndefined(); + }); + + it('should parse config with multiple viewers', () => { + const yaml = ` +viewers: + - manifest_url: https://example.com/neuroglancer.yaml + - manifest_url: https://example.com/avivator.yaml + - manifest_url: https://example.com/validator.yaml +`; + const result = parseViewersConfig(yaml); + + expect(result.viewers).toHaveLength(3); + expect(result.viewers[0].manifest_url).toBe( + 'https://example.com/neuroglancer.yaml' + ); + expect(result.viewers[1].manifest_url).toBe( + 'https://example.com/avivator.yaml' + ); + expect(result.viewers[2].manifest_url).toBe( + 'https://example.com/validator.yaml' + ); + }); + + it('should parse config with all optional fields', () => { + const yaml = ` +viewers: + - manifest_url: https://example.com/viewer.yaml + instance_template_url: https://example.com/viewer?url={dataLink} + label: Custom Viewer Label + logo: custom-logo.png +`; + const result = parseViewersConfig(yaml); + + expect(result.viewers).toHaveLength(1); + expect(result.viewers[0].manifest_url).toBe( + 'https://example.com/viewer.yaml' + ); + expect(result.viewers[0].instance_template_url).toBe( + 'https://example.com/viewer?url={dataLink}' + ); + expect(result.viewers[0].label).toBe('Custom Viewer Label'); + expect(result.viewers[0].logo).toBe('custom-logo.png'); + }); + + it('should parse config with manifest_url only (no optional fields)', () => { + const yaml = ` +viewers: + - manifest_url: https://example.com/simple-viewer.yaml +`; + const result = parseViewersConfig(yaml); + + expect(result.viewers).toHaveLength(1); + expect(result.viewers[0]).toEqual({ + manifest_url: 'https://example.com/simple-viewer.yaml' + }); + }); + }); + + describe('Invalid YAML syntax', () => { + it('should throw error for malformed YAML', () => { + const invalidYaml = 'viewers:\n - manifest_url: test\n invalid: [[['; + + expect(() => parseViewersConfig(invalidYaml)).toThrow( + /Failed to parse viewers configuration YAML/ + ); + }); + + it('should throw error for non-object YAML (string)', () => { + const invalidYaml = 'just a string'; + + expect(() => parseViewersConfig(invalidYaml)).toThrow( + /Configuration must have a "viewers" field/ + ); + }); + + it('should throw error for non-object YAML (number)', () => { + const invalidYaml = '123'; + + expect(() => parseViewersConfig(invalidYaml)).toThrow( + /Configuration must have a "viewers" field/ + ); + }); + + it('should throw error for non-object YAML (array)', () => { + const invalidYaml = '[1, 2, 3]'; + + expect(() => parseViewersConfig(invalidYaml)).toThrow( + /Configuration must have a "viewers" field/ + ); + }); + + it('should throw error for empty YAML', () => { + const invalidYaml = ''; + + expect(() => parseViewersConfig(invalidYaml)).toThrow( + /Configuration must have a "viewers" field/ + ); + }); + + it('should throw error for null YAML', () => { + const invalidYaml = 'null'; + + expect(() => parseViewersConfig(invalidYaml)).toThrow( + /Configuration must have a "viewers" field/ + ); + }); + }); + + describe('Missing required fields', () => { + it('should throw error when viewers array is missing', () => { + const yaml = ` +name: some-config +other_field: value +`; + + expect(() => parseViewersConfig(yaml)).toThrow( + /Configuration must have a "viewers" field containing an array/ + ); + }); + + it('should throw error when viewer is missing manifest_url', () => { + const yaml = ` +viewers: + - label: Custom Label + logo: custom.png +`; + + expect(() => parseViewersConfig(yaml)).toThrow( + /Each viewer must have a "manifest_url" field/ + ); + }); + + it('should throw error when viewers array is empty', () => { + const yaml = ` +viewers: [] +`; + + expect(() => parseViewersConfig(yaml)).toThrow( + /"viewers" must contain at least one viewer/ + ); + }); + }); + + describe('Invalid field types', () => { + it('should throw error when manifest_url is not a string', () => { + const yaml = ` +viewers: + - manifest_url: 123 +`; + + expect(() => parseViewersConfig(yaml)).toThrow( + /Each viewer must have a "manifest_url" field/ + ); + }); + + it('should throw error when manifest_url is not a valid URL or absolute path', () => { + const yaml = ` +viewers: + - manifest_url: not-a-valid-url +`; + + expect(() => parseViewersConfig(yaml)).toThrow( + /"manifest_url" must be a valid URL or an absolute path starting with \// + ); + }); + + it('should throw error when label is not a string', () => { + const yaml = ` +viewers: + - manifest_url: https://example.com/viewer.yaml + label: 123 +`; + + expect(() => parseViewersConfig(yaml)).toThrow( + /"label" must be a string/ + ); + }); + + it('should throw error when logo is not a string', () => { + const yaml = ` +viewers: + - manifest_url: https://example.com/viewer.yaml + logo: 123 +`; + + expect(() => parseViewersConfig(yaml)).toThrow(/"logo" must be a string/); + }); + + it('should throw error when instance_template_url is not a string', () => { + const yaml = ` +viewers: + - manifest_url: https://example.com/viewer.yaml + instance_template_url: 123 +`; + + expect(() => parseViewersConfig(yaml)).toThrow( + /"instance_template_url" must be a string/ + ); + }); + + it('should throw error when viewer entry is not an object (string in array)', () => { + const yaml = ` +viewers: + - just-a-string +`; + + expect(() => parseViewersConfig(yaml)).toThrow( + /Each viewer must be an object with a "manifest_url" field/ + ); + }); + + it('should throw error when viewer entry is not an object (number in array)', () => { + const yaml = ` +viewers: + - 123 +`; + + expect(() => parseViewersConfig(yaml)).toThrow( + /Each viewer must be an object with a "manifest_url" field/ + ); + }); + + it('should throw error when viewers is not an array', () => { + const yaml = ` +viewers: not-an-array +`; + + expect(() => parseViewersConfig(yaml)).toThrow( + /Configuration must have a "viewers" field containing an array/ + ); + }); + }); + + describe('Edge cases', () => { + it('should handle single viewer with only manifest_url', () => { + const yaml = ` +viewers: + - manifest_url: https://example.com/viewer.yaml +`; + + const result = parseViewersConfig(yaml); + + expect(result.viewers).toHaveLength(1); + expect(result.viewers[0]).toEqual({ + manifest_url: 'https://example.com/viewer.yaml' + }); + }); + + it('should preserve all valid optional fields in output', () => { + const yaml = ` +viewers: + - manifest_url: https://example.com/viewer.yaml + instance_template_url: https://example.com/viewer?url={dataLink} + label: Custom Label + logo: custom.png +`; + + const result = parseViewersConfig(yaml); + + expect(result.viewers[0]).toEqual({ + manifest_url: 'https://example.com/viewer.yaml', + instance_template_url: 'https://example.com/viewer?url={dataLink}', + label: 'Custom Label', + logo: 'custom.png' + }); + }); + + it('should strip/ignore unknown fields', () => { + const yaml = ` +viewers: + - manifest_url: https://example.com/viewer.yaml + unknown_field: some-value + another_unknown: 123 +`; + + const result = parseViewersConfig(yaml); + + // Zod should strip unknown fields + expect(result.viewers[0]).toEqual({ + manifest_url: 'https://example.com/viewer.yaml' + }); + expect(result.viewers[0]).not.toHaveProperty('unknown_field'); + expect(result.viewers[0]).not.toHaveProperty('another_unknown'); + }); + + it('should accept http and https URLs', () => { + const yaml = ` +viewers: + - manifest_url: http://example.com/viewer.yaml + - manifest_url: https://example.com/viewer.yaml +`; + + const result = parseViewersConfig(yaml); + + expect(result.viewers).toHaveLength(2); + expect(result.viewers[0].manifest_url).toBe( + 'http://example.com/viewer.yaml' + ); + expect(result.viewers[1].manifest_url).toBe( + 'https://example.com/viewer.yaml' + ); + }); + + it('should accept absolute paths starting with /', () => { + const yaml = ` +viewers: + - manifest_url: /viewers/neuroglancer.yaml + - manifest_url: /viewers/vizarr.yaml +`; + + const result = parseViewersConfig(yaml); + + expect(result.viewers).toHaveLength(2); + expect(result.viewers[0].manifest_url).toBe('/viewers/neuroglancer.yaml'); + expect(result.viewers[1].manifest_url).toBe('/viewers/vizarr.yaml'); + }); + + it('should handle URL with special characters', () => { + const yaml = ` +viewers: + - manifest_url: https://example.com/viewer-config_v2.yaml?version=1.0&format=yaml +`; + + const result = parseViewersConfig(yaml); + + expect(result.viewers[0].manifest_url).toBe( + 'https://example.com/viewer-config_v2.yaml?version=1.0&format=yaml' + ); + }); + + it('should handle empty optional strings', () => { + const yaml = ` +viewers: + - manifest_url: https://example.com/viewer.yaml + label: "" + logo: "" + instance_template_url: "" +`; + + const result = parseViewersConfig(yaml); + + expect(result.viewers[0]).toEqual({ + manifest_url: 'https://example.com/viewer.yaml', + label: '', + logo: '', + instance_template_url: '' + }); + }); + }); +}); diff --git a/frontend/src/assets/vizarr_logo.png b/frontend/src/assets/avivator.png similarity index 100% rename from frontend/src/assets/vizarr_logo.png rename to frontend/src/assets/avivator.png diff --git a/frontend/src/assets/fallback_logo.png b/frontend/src/assets/fallback_logo.png new file mode 100644 index 00000000..da5fcc42 Binary files /dev/null and b/frontend/src/assets/fallback_logo.png differ diff --git a/frontend/src/assets/ome-ngff-validator.png b/frontend/src/assets/validator.png similarity index 100% rename from frontend/src/assets/ome-ngff-validator.png rename to frontend/src/assets/validator.png diff --git a/frontend/src/assets/aics_website-3d-cell-viewer.png b/frontend/src/assets/vol-e.png similarity index 100% rename from frontend/src/assets/aics_website-3d-cell-viewer.png rename to frontend/src/assets/vol-e.png diff --git a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx index b4e34d0c..050ec566 100644 --- a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx +++ b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx @@ -1,13 +1,9 @@ import { Button, ButtonGroup, Typography } from '@material-tailwind/react'; import { Link } from 'react-router'; - -import neuroglancer_logo from '@/assets/neuroglancer.png'; -import validator_logo from '@/assets/ome-ngff-validator.png'; -import volE_logo from '@/assets/aics_website-3d-cell-viewer.png'; -import avivator_logo from '@/assets/vizarr_logo.png'; import copy_logo from '@/assets/copy-link-64.png'; import type { OpenWithToolUrls, PendingToolKey } from '@/hooks/useZarrMetadata'; import FgTooltip from '@/components/ui/widgets/FgTooltip'; +import { useViewersContext } from '@/contexts/ViewersContext'; export default function DataToolLinks({ onToolClick, @@ -20,6 +16,8 @@ export default function DataToolLinks({ readonly title: string; readonly urls: OpenWithToolUrls | null; }) { + const { validViewers } = useViewersContext(); + const tooltipTriggerClasses = 'rounded-sm m-0 p-0 transform active:scale-90 transition-transform duration-75'; @@ -33,106 +31,42 @@ export default function DataToolLinks({ {title} - {urls.neuroglancer !== null ? ( - - { - e.preventDefault(); - await onToolClick('neuroglancer'); - }} - rel="noopener noreferrer" - target="_blank" - to={urls.neuroglancer} - > - Neuroglancer logo - - - ) : null} + {validViewers.map(viewer => { + const url = urls[viewer.key]; - {urls.vole !== null ? ( - - { - e.preventDefault(); - await onToolClick('vole'); - }} - rel="noopener noreferrer" - target="_blank" - to={urls.vole} - > - Vol-E logo - - - ) : null} - - {urls.avivator !== null ? ( - - { - e.preventDefault(); - await onToolClick('avivator'); - }} - rel="noopener noreferrer" - target="_blank" - to={urls.avivator} - > - Avivator logo - - - ) : null} + // null means incompatible, don't show + if (url === null) { + return null; + } - {urls.validator !== null ? ( - - { - e.preventDefault(); - await onToolClick('validator'); - }} - rel="noopener noreferrer" - target="_blank" - to={urls.validator} + return ( + - OME-Zarr Validator logo - - - ) : null} + { + e.preventDefault(); + await onToolClick(viewer.key as PendingToolKey); + }} + rel="noopener noreferrer" + target="_blank" + to={url} + > + {`${viewer.label}`} + + + ); + })} + {/* Copy URL tool - always available when there's a data URL */} ('@/assets/*.png', { + eager: true +}); + +/** + * Extract filename from glob import path + * Converts '/src/assets/neuroglancer.png' to 'neuroglancer.png' + */ +function extractFileName(path: string): string { + const parts = path.split('/'); + return parts[parts.length - 1]; +} + +/** + * Get logo path for a viewer + * Logo resolution order: + * 1. If customLogoPath is provided, use that from @/assets/ + * 2. If not, try to load @/assets/{viewerName}.png + * 3. If not found, use fallback logo + * + * @param viewerName - Name of the viewer (case-insensitive) + * @param customLogoPath - Optional custom logo filename from config (e.g., "my-logo.png") + * @returns Logo path to use + */ +export function getViewerLogo( + viewerName: string, + customLogoPath?: string +): string { + const logoFileName = customLogoPath || `${viewerName.toLowerCase()}.png`; + + // Search through available logos + for (const [path, module] of Object.entries(LOGO_MODULES)) { + const fileName = extractFileName(path); + if (fileName === logoFileName) { + return module.default; + } + } + + // If logo not found, return fallback + return fallback_logo; +} diff --git a/frontend/src/config/viewers.config.yaml b/frontend/src/config/viewers.config.yaml new file mode 100644 index 00000000..008f3c6a --- /dev/null +++ b/frontend/src/config/viewers.config.yaml @@ -0,0 +1,22 @@ +# Fileglancer OME-Zarr Viewers Configuration +# +# Each viewer entry requires: +# - manifest_url: URL or absolute path to a capability manifest YAML file +# Use absolute paths (e.g. /viewers/neuroglancer.yaml) for manifests bundled +# in the public/ directory, or full URLs for externally hosted manifests. +# Optional overrides: +# - instance_template_url: Override the viewer's template_url from the manifest +# - logo: Filename of logo in frontend/src/assets/ (defaults to {normalized_name}.png) +# - label: Custom tooltip text (defaults to "View in {Name}") + +viewers: + - manifest_url: '/viewers/neuroglancer.yaml' + + - manifest_url: '/viewers/vizarr.yaml' + instance_template_url: 'https://janeliascicomp.github.io/viv/' + + - manifest_url: '/viewers/validator.yaml' + label: 'View in OME-Zarr Validator' + + - manifest_url: '/viewers/vole.yaml' + label: 'View in Vol-E' diff --git a/frontend/src/config/viewersConfig.ts b/frontend/src/config/viewersConfig.ts new file mode 100644 index 00000000..0f305a09 --- /dev/null +++ b/frontend/src/config/viewersConfig.ts @@ -0,0 +1,109 @@ +import yaml from 'js-yaml'; +import { z } from 'zod'; + +/** + * Zod schema for viewer entry from viewers.config.yaml + */ +const ViewerConfigEntrySchema = z.object( + { + manifest_url: z + .string({ + message: 'Each viewer must have a "manifest_url" field (string)' + }) + .refine(val => val.startsWith('/') || URL.canParse(val), { + message: + '"manifest_url" must be a valid URL or an absolute path starting with /' + }), + instance_template_url: z + .string({ message: '"instance_template_url" must be a string' }) + .optional(), + label: z.string({ message: '"label" must be a string' }).optional(), + logo: z.string({ message: '"logo" must be a string' }).optional() + }, + { + error: iss => { + if (iss.code === 'invalid_type' && iss.expected === 'object') { + return 'Each viewer must be an object with a "manifest_url" field'; + } + return undefined; + } + } +); + +/** + * Zod schema for viewers.config.yaml structure + */ +const ViewersConfigYamlSchema = z.object( + { + viewers: z + .array(ViewerConfigEntrySchema, { + message: + 'Configuration must have a "viewers" field containing an array of viewers' + }) + .min(1, { + message: '"viewers" must contain at least one viewer' + }) + }, + { + error: iss => { + if (iss.code === 'invalid_type') { + return { + message: 'Configuration must have a "viewers" field' + }; + } + } + } +); + +// exported for use in ViewersContext +export type ViewerConfigEntry = z.infer; + +type ViewersConfigYaml = z.infer; + +/** + * Parse and validate viewers configuration YAML + * @param yamlContent - The YAML content to parse + */ +export function parseViewersConfig(yamlContent: string): ViewersConfigYaml { + let parsed: unknown; + + try { + parsed = yaml.load(yamlContent); + } catch (error) { + throw new Error( + `Failed to parse viewers configuration YAML: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + + const result = ViewersConfigYamlSchema.safeParse(parsed); + + if (!result.success) { + const firstError = result.error.issues[0]; + + // Check if the error is nested within a specific viewer + if (firstError.path.length > 0 && firstError.path[0] === 'viewers') { + const viewerIndex = firstError.path[1]; + + if ( + typeof viewerIndex === 'number' && + parsed && + typeof parsed === 'object' + ) { + const configData = parsed as { viewers?: unknown[] }; + const viewer = configData.viewers?.[viewerIndex]; + + if (viewer && typeof viewer === 'object' && 'manifest_url' in viewer) { + const manifestUrl = (viewer as { manifest_url: unknown }) + .manifest_url; + if (typeof manifestUrl === 'string') { + throw new Error(`Viewer "${manifestUrl}": ${firstError.message}`); + } + } + } + } + + throw new Error(firstError.message); + } + + return result.data; +} diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx new file mode 100644 index 00000000..2bbd6118 --- /dev/null +++ b/frontend/src/contexts/ViewersContext.tsx @@ -0,0 +1,225 @@ +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + type ReactNode +} from 'react'; +import { + loadManifestsFromUrls, + isCompatible, + type ViewerManifest, + type OmeZarrMetadata +} from '@bioimagetools/capability-manifest'; +import { default as log } from '@/logger'; +import { + parseViewersConfig, + type ViewerConfigEntry +} from '@/config/viewersConfig'; +import { getViewerLogo } from '@/config/viewerLogos'; + +/** + * Validated viewer with all necessary information + */ +export interface ValidViewer { + /** Internal key for this viewer (normalized name) */ + key: string; + /** Display name */ + displayName: string; + /** URL template (may contain {dataLink} placeholder) */ + urlTemplate: string; + /** Logo path */ + logoPath: string; + /** Tooltip/alt text label */ + label: string; + /** Associated capability manifest (required) */ + manifest: ViewerManifest; +} + +interface ViewersContextType { + validViewers: ValidViewer[]; + isInitialized: boolean; + error: string | null; + getCompatibleViewers: (metadata: OmeZarrMetadata) => ValidViewer[]; +} + +const ViewersContext = createContext(undefined); + +/** + * Load viewers configuration from build-time config file + */ +async function loadViewersConfig(): Promise { + let configYaml: string; + + try { + const module = await import('@/config/viewers.config.yaml?raw'); + configYaml = module.default; + log.info( + 'Using custom viewers configuration from src/config/viewers.config.yaml' + ); + } catch (error) { + log.info( + 'No custom viewers.config.yaml found, using default configuration' + ); + return [ + { + manifest_url: + 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/neuroglancer.yaml' + } + ]; + } + + try { + const config = parseViewersConfig(configYaml); + return config.viewers; + } catch (error) { + log.error('Error parsing viewers configuration:', error); + throw error; + } +} + +/** + * Normalize viewer name to a valid key + */ +function normalizeViewerName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +export function ViewersProvider({ + children +}: { + readonly children: ReactNode; +}) { + const [validViewers, setValidViewers] = useState([]); + const [isInitialized, setIsInitialized] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + async function initialize() { + try { + log.info('Initializing viewers configuration...'); + + // Load viewer config entries + const configEntries = await loadViewersConfig(); + log.info(`Loaded configuration for ${configEntries.length} viewers`); + + // Extract manifest URLs + const manifestUrls = configEntries.map(entry => entry.manifest_url); + + // Load capability manifests + let manifestsMap: Map; + try { + manifestsMap = await loadManifestsFromUrls(manifestUrls); + log.info(`Loaded ${manifestsMap.size} viewer capability manifests`); + } catch (manifestError) { + log.error('Failed to load capability manifests:', manifestError); + throw new Error( + `Failed to load viewer manifests: ${manifestError instanceof Error ? manifestError.message : 'Unknown error'}` + ); + } + + const validated: ValidViewer[] = []; + + // Map through viewer config entries to validate + for (const entry of configEntries) { + const manifest = manifestsMap.get(entry.manifest_url); + + if (!manifest) { + log.warn( + `Viewer manifest from "${entry.manifest_url}" failed to load, skipping` + ); + continue; + } + + // Determine URL template + const urlTemplate = + entry.instance_template_url ?? manifest.viewer.template_url; + + if (!urlTemplate) { + log.warn( + `Viewer "${manifest.viewer.name}" has no template_url in manifest and no instance_template_url override, skipping` + ); + continue; + } + + // Replace {DATA_URL} with {dataLink} for consistency with existing code + const normalizedUrlTemplate = urlTemplate.replace( + /{DATA_URL}/g, + '{dataLink}' + ); + + // Create valid viewer entry + const key = normalizeViewerName(manifest.viewer.name); + const displayName = manifest.viewer.name; + const label = entry.label || `View in ${displayName}`; + const logoPath = getViewerLogo(manifest.viewer.name, entry.logo); + + validated.push({ + key, + displayName, + urlTemplate: normalizedUrlTemplate, + logoPath, + label, + manifest + }); + + log.info(`Viewer "${manifest.viewer.name}" registered successfully`); + } + + if (validated.length === 0) { + throw new Error( + 'No valid viewers configured. Check viewers.config.yaml or console for errors.' + ); + } + + setValidViewers(validated); + setIsInitialized(true); + log.info( + `Viewers initialization complete: ${validated.length} viewers available` + ); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'Unknown error'; + log.error('Failed to initialize viewers:', errorMessage); + log.error( + 'Application will continue with no viewers available. Check viewers.config.yaml for errors.' + ); + setError(errorMessage); + setValidViewers([]); // Ensure empty viewer list on error + setIsInitialized(true); // Still mark as initialized to prevent hanging + } + } + + initialize(); + }, []); + + const getCompatibleViewers = useCallback( + (metadata: OmeZarrMetadata): ValidViewer[] => { + if (!isInitialized || !metadata) { + return []; + } + + return validViewers.filter(viewer => + isCompatible(viewer.manifest, metadata) + ); + }, + [validViewers, isInitialized] + ); + + return ( + + {children} + + ); +} + +export function useViewersContext() { + const context = useContext(ViewersContext); + if (!context) { + throw new Error('useViewersContext must be used within ViewersProvider'); + } + return context; +} diff --git a/frontend/src/hooks/useN5Metadata.ts b/frontend/src/hooks/useN5Metadata.ts index b56bf955..aa665368 100644 --- a/frontend/src/hooks/useN5Metadata.ts +++ b/frontend/src/hooks/useN5Metadata.ts @@ -73,10 +73,7 @@ export default function useN5Metadata() { const toolUrls: N5OpenWithToolUrls = { copy: url || '', - neuroglancer: '', - validator: null, - vole: null, - avivator: null + neuroglancer: '' }; if (url) { diff --git a/frontend/src/hooks/useZarrMetadata.ts b/frontend/src/hooks/useZarrMetadata.ts index c0f59065..283e4074 100644 --- a/frontend/src/hooks/useZarrMetadata.ts +++ b/frontend/src/hooks/useZarrMetadata.ts @@ -4,6 +4,7 @@ import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; import { useProxiedPathContext } from '@/contexts/ProxiedPathContext'; import { useExternalBucketContext } from '@/contexts/ExternalBucketContext'; +import { useViewersContext } from '@/contexts/ViewersContext'; import { useZarrMetadataQuery, useOmeZarrThumbnailQuery @@ -31,6 +32,11 @@ export default function useZarrMetadata() { disableHeuristicalLayerTypeDetection, useLegacyMultichannelApproach } = usePreferencesContext(); + const { + validViewers, + isInitialized: viewersInitialized, + getCompatibleViewers + } = useViewersContext(); // Fetch Zarr metadata const zarrMetadataQuery = useZarrMetadataQuery({ @@ -88,105 +94,138 @@ export default function useZarrMetadata() { }, [thumbnailSrc, disableHeuristicalLayerTypeDetection]); const openWithToolUrls = useMemo(() => { - if (!metadata) { + if (!metadata || !viewersInitialized) { return null; } - const validatorBaseUrl = 'https://ome.github.io/ome-ngff-validator/'; - const neuroglancerBaseUrl = 'https://neuroglancer-demo.appspot.com/#!'; - const voleBaseUrl = 'https://volumeviewer.allencell.org/viewer'; - const avivatorBaseUrl = 'https://janeliascicomp.github.io/viv/'; const url = externalDataUrlQuery.data || currentDirProxiedPathQuery.data?.url; + const openWithToolUrls = { copy: url || '' } as OpenWithToolUrls; - // Determine which tools should be available based on metadata type + // Get compatible viewers for this dataset + let compatibleViewers = validViewers; + + // If we have multiscales metadata (OME-Zarr), use capability checking to filter if (metadata?.multiscale) { - // OME-Zarr - all urls for v2; no avivator for v3 - if (url) { - if (effectiveZarrVersion === 2) { - openWithToolUrls.avivator = buildUrl(avivatorBaseUrl, null, { - image_url: url - }); - } else { - openWithToolUrls.avivator = null; + // Convert our metadata to OmeZarrMetadata format for capability checking + const omeZarrMetadata = { + version: effectiveZarrVersion === 3 ? '0.5' : '0.4', + axes: metadata.multiscale?.axes, + multiscales: metadata.multiscale ? [metadata.multiscale] : undefined, + omero: metadata.omero, + labels: metadata.labels + } as any; // Type assertion needed due to internal type differences + + compatibleViewers = getCompatibleViewers(omeZarrMetadata); + + // Create a Set for lookup of compatible viewer keys + // Needed to mark incompatible but valid (as defined by the viewer config) viewers as null in openWithToolUrls + const compatibleKeys = new Set(compatibleViewers.map(v => v.key)); + + for (const viewer of validViewers) { + if (!compatibleKeys.has(viewer.key)) { + openWithToolUrls[viewer.key] = null; + } + } + + // For compatible viewers, generate URLs + for (const viewer of compatibleViewers) { + if (!url) { + // Compatible but no data URL yet - show as available (empty string) + openWithToolUrls[viewer.key] = ''; + continue; } - // Populate with actual URLs when proxied path is available - openWithToolUrls.validator = buildUrl(validatorBaseUrl, null, { - source: url - }); - openWithToolUrls.vole = buildUrl(voleBaseUrl, null, { - url - }); - if (disableNeuroglancerStateGeneration) { - openWithToolUrls.neuroglancer = - neuroglancerBaseUrl + - generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); - } else if (layerType) { - try { - openWithToolUrls.neuroglancer = + + // Generate the viewer URL + let viewerUrl = viewer.urlTemplate; + + // Special handling for Neuroglancer to maintain existing state generation logic + if (viewer.key === 'neuroglancer') { + // Extract base URL from template (everything before #!) + const neuroglancerBaseUrl = viewer.urlTemplate.split('#!')[0] + '#!'; + if (disableNeuroglancerStateGeneration) { + viewerUrl = neuroglancerBaseUrl + - generateNeuroglancerStateForOmeZarr( - url, - effectiveZarrVersion, - layerType, - metadata.multiscale, - metadata.arr, - metadata.labels, - metadata.omero, - useLegacyMultichannelApproach + generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); + } else if (layerType) { + try { + viewerUrl = + neuroglancerBaseUrl + + generateNeuroglancerStateForOmeZarr( + url, + effectiveZarrVersion, + layerType, + metadata.multiscale, + metadata.arr, + metadata.labels, + metadata.omero, + useLegacyMultichannelApproach + ); + } catch (error) { + log.error( + 'Error generating Neuroglancer state for OME-Zarr:', + error ); - } catch (error) { - log.error( - 'Error generating Neuroglancer state for OME-Zarr:', - error + viewerUrl = + neuroglancerBaseUrl + + generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); + } + } + } else { + // For other viewers, replace {dataLink} placeholder if present + if (viewerUrl.includes('{dataLink}')) { + viewerUrl = viewerUrl.replace( + /{dataLink}/g, + encodeURIComponent(url) ); - openWithToolUrls.neuroglancer = - neuroglancerBaseUrl + - generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); + } else { + // If no placeholder, use buildUrl with 'url' query param + viewerUrl = buildUrl(viewerUrl, null, { url }); } } - } else { - // No proxied URL - show all tools as available but empty - openWithToolUrls.validator = ''; - openWithToolUrls.vole = ''; - // if this is a zarr version 2, then set the url to blank which will show - // the icon before a data link has been generated. Setting it to null for - // all other versions, eg zarr v3 means the icon will not be present before - // a data link is generated. - openWithToolUrls.avivator = effectiveZarrVersion === 2 ? '' : null; - openWithToolUrls.neuroglancer = ''; + + openWithToolUrls[viewer.key] = viewerUrl; } } else { // Non-OME Zarr - only Neuroglancer available - if (url) { - openWithToolUrls.validator = null; - openWithToolUrls.vole = null; - openWithToolUrls.avivator = null; - if (disableNeuroglancerStateGeneration) { - openWithToolUrls.neuroglancer = - neuroglancerBaseUrl + - generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); - } else if (layerType) { - openWithToolUrls.neuroglancer = - neuroglancerBaseUrl + - generateNeuroglancerStateForZarrArray( - url, - effectiveZarrVersion, - layerType - ); + // Mark all non-Neuroglancer viewers as incompatible + for (const viewer of validViewers) { + if (viewer.key !== 'neuroglancer') { + openWithToolUrls[viewer.key] = null; + } else { + // Neuroglancer + if (url) { + // Extract base URL from template (everything before #!) + const neuroglancerBaseUrl = + viewer.urlTemplate.split('#!')[0] + '#!'; + if (disableNeuroglancerStateGeneration) { + openWithToolUrls.neuroglancer = + neuroglancerBaseUrl + + generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); + } else if (layerType) { + openWithToolUrls.neuroglancer = + neuroglancerBaseUrl + + generateNeuroglancerStateForZarrArray( + url, + effectiveZarrVersion, + layerType + ); + } else { + // layerType not yet determined - use fallback + openWithToolUrls.neuroglancer = + neuroglancerBaseUrl + + generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); + } + } else { + // No proxied URL - show Neuroglancer as available but empty + openWithToolUrls.neuroglancer = ''; + } } - } else { - // No proxied URL - only show Neuroglancer as available but empty - openWithToolUrls.validator = null; - openWithToolUrls.vole = null; - openWithToolUrls.avivator = null; - openWithToolUrls.neuroglancer = ''; } } - return openWithToolUrls; }, [ metadata, @@ -195,7 +234,10 @@ export default function useZarrMetadata() { disableNeuroglancerStateGeneration, useLegacyMultichannelApproach, layerType, - effectiveZarrVersion + effectiveZarrVersion, + validViewers, + viewersInitialized, + getCompatibleViewers ]); return { diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 88d0b33c..09306c41 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -18,6 +18,7 @@ import { ExternalBucketProvider } from '@/contexts/ExternalBucketContext'; import { ProfileContextProvider } from '@/contexts/ProfileContext'; import { NotificationProvider } from '@/contexts/NotificationsContext'; import { ServerHealthProvider } from '@/contexts/ServerHealthContext'; +import { ViewersProvider } from '@/contexts/ViewersContext'; import FileglancerNavbar from '@/components/ui/Navbar/Navbar'; import Notifications from '@/components/ui/Notifications/Notifications'; import ErrorFallback from '@/components/ErrorFallback'; @@ -64,25 +65,27 @@ export const MainLayout = () => { return ( - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + ); }; diff --git a/frontend/src/queries/n5Queries.ts b/frontend/src/queries/n5Queries.ts index 3f15ba6a..699e9ab4 100644 --- a/frontend/src/queries/n5Queries.ts +++ b/frontend/src/queries/n5Queries.ts @@ -49,9 +49,6 @@ export type N5Metadata = { export type N5OpenWithToolUrls = { copy: string; neuroglancer: string; - validator: null; - vole: null; - avivator: null; }; type N5MetadataQueryParams = { diff --git a/frontend/src/queries/zarrQueries.ts b/frontend/src/queries/zarrQueries.ts index 67f71de2..5efef7b9 100644 --- a/frontend/src/queries/zarrQueries.ts +++ b/frontend/src/queries/zarrQueries.ts @@ -12,11 +12,11 @@ import { FileOrFolder } from '@/shared.types'; export type OpenWithToolUrls = { copy: string; - validator: string | null; - neuroglancer: string; - vole: string | null; - avivator: string | null; -}; +} & Record; + +// The 'copy' key is always present, all other keys are viewer-specific +// null means the viewer is incompatible with this dataset +// empty string means the viewer is compatible but no data URL is available yet export type ZarrMetadata = Metadata | null; diff --git a/frontend/ui-tests/tests/data-link-operations.spec.ts b/frontend/ui-tests/tests/data-link-operations.spec.ts index 6b428d3c..e4b33ea8 100644 --- a/frontend/ui-tests/tests/data-link-operations.spec.ts +++ b/frontend/ui-tests/tests/data-link-operations.spec.ts @@ -40,9 +40,7 @@ test.describe('Data Link Operations', () => { // Wait for zarr metadata to load await expect(page.getByText('zarr.json')).toBeVisible({ timeout: 10000 }); - await expect( - page.getByRole('link', { name: 'Neuroglancer logo' }) - ).toBeVisible({ timeout: 10000 }); + await expect(page.getByAltText(/neuroglancer/i)).toBeVisible(); const dataLinkToggle = page.getByRole('checkbox', { name: /data link/i }); const confirmButton = page.getByRole('button', { @@ -53,9 +51,7 @@ test.describe('Data Link Operations', () => { }); await test.step('Turn on automatic data links via the data link dialog', async () => { - const neuroglancerLink = page.getByRole('link', { - name: 'Neuroglancer logo' - }); + const neuroglancerLink = page.getByAltText(/neuroglancer/i); await neuroglancerLink.click(); // Confirm the data link creation in the dialog @@ -211,9 +207,7 @@ test.describe('Data Link Operations', () => { // Navigate into the zarr directory await page.getByRole('link', { name: zarrDirName }).click(); await expect(page.getByText('zarr.json')).toBeVisible({ timeout: 10000 }); - await expect( - page.getByRole('link', { name: 'Neuroglancer logo' }) - ).toBeVisible({ timeout: 10000 }); + await expect(page.getByAltText(/neuroglancer/i)).toBeVisible(); // Click on the s0 subdirectory row to select it as the properties target const s0Row = page.getByRole('row').filter({ hasText: 's0' }); @@ -229,9 +223,7 @@ test.describe('Data Link Operations', () => { // Click the Neuroglancer viewer icon — this should create a data link // for the zarr directory (currentFileOrFolder), not for s0 (propertiesTarget) - const neuroglancerLink = page.getByRole('link', { - name: 'Neuroglancer logo' - }); + const neuroglancerLink = page.getByAltText(/neuroglancer/i); await neuroglancerLink.click(); // Confirm in dialog diff --git a/frontend/ui-tests/tests/load-zarr-files.spec.ts b/frontend/ui-tests/tests/load-zarr-files.spec.ts index deaeb46e..1ae7a036 100644 --- a/frontend/ui-tests/tests/load-zarr-files.spec.ts +++ b/frontend/ui-tests/tests/load-zarr-files.spec.ts @@ -32,10 +32,8 @@ test.describe('Zarr File Type Representation', () => { // Wait for zarr metadata to load (zarr.json file present indicates loaded) await expect(page.getByText('zarr.json')).toBeVisible({ timeout: 10000 }); - await expect( - page.getByRole('link', { name: 'Neuroglancer logo' }) - ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('link', { name: 'Vol-E logo' })).toHaveCount(0); + await expect(page.getByAltText(/neuroglancer/i)).toBeVisible(); + await expect(page.getByAltText(/vol-e/i)).toHaveCount(0); }); test('Zarr V3 OME-Zarr should show all viewers except avivator', async ({ @@ -51,16 +49,10 @@ test.describe('Zarr File Type Representation', () => { // Wait for zarr metadata to load await expect(page.getByText('zarr.json')).toBeVisible({ timeout: 10000 }); - await expect( - page.getByRole('link', { name: 'Neuroglancer logo' }) - ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('link', { name: 'Vol-E logo' })).toBeVisible(); - await expect( - page.getByRole('link', { name: 'OME-Zarr Validator logo' }) - ).toBeVisible(); - await expect(page.getByRole('link', { name: 'Avivator logo' })).toHaveCount( - 0 - ); + await expect(page.getByAltText(/neuroglancer/i)).toBeVisible(); + await expect(page.getByAltText(/vol-e/i)).toBeVisible(); + await expect(page.getByAltText(/validator/i)).toBeVisible(); + await expect(page.getByAltText(/avivator/i)).toHaveCount(0); }); test('Zarr V2 Array should show only neuroglancer', async ({ @@ -76,10 +68,8 @@ test.describe('Zarr File Type Representation', () => { // Wait for zarr metadata to load await expect(page.getByText('.zarray')).toBeVisible({ timeout: 10000 }); - await expect( - page.getByRole('link', { name: 'Neuroglancer logo' }) - ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('link', { name: 'Vol-E logo' })).toHaveCount(0); + await expect(page.getByAltText(/neuroglancer/i)).toBeVisible(); + await expect(page.getByAltText(/vol-e/i)).toHaveCount(0); }); test('Zarr V2 OME-Zarr should display all viewers including avivator', async ({ @@ -95,16 +85,10 @@ test.describe('Zarr File Type Representation', () => { // Wait for zarr metadata to load await expect(page.getByText('.zattrs')).toBeVisible({ timeout: 10000 }); - await expect( - page.getByRole('link', { name: 'Neuroglancer logo' }) - ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('link', { name: 'Vol-E logo' })).toBeVisible(); - await expect( - page.getByRole('link', { name: 'OME-Zarr Validator logo' }) - ).toBeVisible(); - await expect( - page.getByRole('link', { name: 'Avivator logo' }) - ).toBeVisible(); + await expect(page.getByAltText(/neuroglancer/i)).toBeVisible(); + await expect(page.getByAltText(/vol-e/i)).toBeVisible(); + await expect(page.getByAltText(/validator/i)).toBeVisible(); + await expect(page.getByAltText(/avivator/i)).toBeVisible(); }); test('Refresh button should update zarr metadata when .zattrs is modified', async ({