diff --git a/pydatalab/docs/config.md b/pydatalab/docs/config.md index 964c54b03..b0b838438 100644 --- a/pydatalab/docs/config.md +++ b/pydatalab/docs/config.md @@ -131,12 +131,64 @@ Currently, there are two mechanisms for accessing remote files: ## Customisation and branding -Deployments can customise the look and feel of their *datalab* instance by providing files under a `public/custom/` directory in the web app. +Deployments can customise the main navigation through the server configuration and customise the look and feel of their *datalab* instance by providing files under a `public/custom/` directory in the web app. This directory can be a symlink to a folder in your deployment repository, or mounted as a volume in Docker. It should be made available at `webapp/public/custom/` relative to the *datalab* source tree. The following customisations are supported: +### Main navigation + +The [`NAVIGATION`][pydatalab.config.ServerConfig.NAVIGATION] setting controls which links appear in the main navigation, their order, labels, optional icons, and the landing view used for `/`. +Each entry has a required `view` ID and optional `label`, `icon`, and `default` values. +The server configuration is the source of this default and is returned to the web app by the `/info` endpoint. +For example, the following configuration renames Inventory to Starting Materials, moves About to the end, and omits Collections: + +```json +{ + "NAVIGATION": [ + {"view": "samples"}, + { + "view": "starting-materials", + "label": "Starting Materials", + "icon": "vials", + "default": true + }, + {"view": "equipment"}, + {"view": "item-graph", "label": "Graph View", "icon": "project-diagram"}, + {"view": "about"} + ] +} +``` + +The same value can be supplied as JSON in the server's `.env` file or process environment: + +```dotenv +PYDATALAB_NAVIGATION='[{"view":"samples"},{"view":"starting-materials","label":"Starting Materials","default":true},{"view":"about"}]' +``` + +The built-in views are: + +| View ID | Default label | Route | +|---|---|---| +| `about` | About | `/about` | +| `samples` | Samples | `/samples` | +| `collections` | Collections | `/collections` | +| `starting-materials` | Inventory | `/starting-materials` | +| `equipment` | Equipment | `/equipment` | +| `item-graph` | Graph View | `/item-graph` | + +Entries are shown in the configured order, and omitted entries are only hidden from the navigation: their routes remain available unless the corresponding feature is disabled separately. +Set `default` to `true` on the entry that should open when a user visits `/`; only one entry can be marked as the default. +If no entry is explicitly marked as the default, the first entry is used. +The built-in navigation marks Samples as the default to preserve the existing landing page. +The navigation must contain at least one entry, and duplicate view IDs, multiple defaults, and malformed entries are rejected during server configuration. +View IDs unknown to the installed web app are ignored by it. + +The `icon` value must name a Font Awesome solid icon already registered by the web app, such as `vials` or `project-diagram`. +Unknown icons are omitted without hiding the link. +The default configuration preserves the current six links and the Graph View icon; within a custom configuration, an omitted `icon` means that entry has no icon. + ### CSS overrides (`public/custom/override.css`) A CSS file that is loaded globally before the app styles, allowing you to override any default styling. @@ -255,6 +307,12 @@ public/custom/ show_root_heading: true show_source: false +::: pydatalab.config.NavigationEntry + options: + heading_level: 2 + show_root_heading: true + show_source: false + ::: pydatalab.config.SMTPSettings options: heading_level: 2 diff --git a/pydatalab/src/pydatalab/config.py b/pydatalab/src/pydatalab/config.py index 3f9f9d431..8804819be 100644 --- a/pydatalab/src/pydatalab/config.py +++ b/pydatalab/src/pydatalab/config.py @@ -20,7 +20,13 @@ from pydatalab.models import Person from pydatalab.models.utils import RandomAlphabeticalRefcodeFactory, RefCodeFactory -__all__ = ("CONFIG", "ServerConfig", "DeploymentMetadata", "RemoteFilesystem") +__all__ = ( + "CONFIG", + "ServerConfig", + "DeploymentMetadata", + "NavigationEntry", + "RemoteFilesystem", +) config_logger = logging.getLogger("pydatalab.config") @@ -128,6 +134,49 @@ class SMTPSettings(BaseModel): ) +class NavigationEntry(BaseModel): + """A configured entry in the web application's main navigation.""" + + view: str = Field(description="Stable identifier of a view known to the web application.") + label: str | None = Field( + None, + description="Optional navigation label. The view's default label is used when omitted.", + ) + icon: str | None = Field( + None, + description="Optional name of a Font Awesome icon registered by the web application.", + ) + default: bool = Field( + False, + description="Whether this entry is the landing view for the web application.", + ) + + model_config = ConfigDict(extra="forbid") + + @field_validator("view", "label", "icon") + @classmethod + def strip_non_empty_strings(cls, value: str | None) -> str | None: + if value is None: + return None + value = value.strip() + if not value: + raise ValueError("navigation values must not be blank") + return value + + +def default_navigation() -> list[NavigationEntry]: + """Return the navigation used when a deployment provides no customisation.""" + + return [ + NavigationEntry(view="about"), + NavigationEntry(view="samples", default=True), + NavigationEntry(view="collections"), + NavigationEntry(view="starting-materials"), + NavigationEntry(view="equipment"), + NavigationEntry(view="item-graph", icon="project-diagram"), + ] + + class ServerConfig(BaseSettings): """A model that provides settings for deploying the API.""" @@ -279,6 +328,11 @@ class ServerConfig(BaseSettings): description="A list of dotted import paths ('package.module:ClassName') to custom `Item` subclasses to register as additional item types served through the generic item endpoints, e.g. ['mypackage.models:MySample']. Each model must declare its own unique `type` literal.", ) + NAVIGATION: list[NavigationEntry] = Field( + default_factory=default_navigation, + description="Ordered views to display in the web application's main navigation.", + ) + PREDEFINED_LOCATIONS: set[str] = Field( default_factory=set, description="A list of additional lab locations to populate in the /locations endpoint that will be suggested globally for autocompletion. Use '>' to indicate location hierarchy", @@ -400,6 +454,24 @@ def check_predefined_locations(cls, v): construct_location_hierarchy(v) return v + @field_validator("NAVIGATION") + @classmethod + def validate_navigation(cls, value: list[NavigationEntry]) -> list[NavigationEntry]: + if not value: + raise ValueError("NAVIGATION must contain at least one entry") + + views = [entry.view for entry in value] + if len(views) != len(set(views)): + raise ValueError("NAVIGATION must not contain duplicate view IDs") + + default_indices = [index for index, entry in enumerate(value) if entry.default] + if len(default_indices) > 1: + raise ValueError("NAVIGATION must not contain multiple default views") + if not default_indices: + value[0] = value[0].model_copy(update={"default": True}) + + return value + @field_validator("LOG_FILE", mode="before") @classmethod def make_missing_log_directory(cls, v): diff --git a/pydatalab/src/pydatalab/routes/v0_1/info.py b/pydatalab/src/pydatalab/routes/v0_1/info.py index d16e61a2d..6c5113841 100644 --- a/pydatalab/src/pydatalab/routes/v0_1/info.py +++ b/pydatalab/src/pydatalab/routes/v0_1/info.py @@ -17,7 +17,7 @@ from pydatalab import __version__ from pydatalab.apps import BLOCK_TYPES -from pydatalab.config import CONFIG +from pydatalab.config import CONFIG, NavigationEntry from pydatalab.deployment_stats import ( STATS_REFRESH_INTERVAL, build_stats_summary, @@ -117,6 +117,7 @@ class Info(Attributes, Meta): identifier_prefix: str features: FeatureFlags | None = None max_upload_bytes: int + navigation: list[NavigationEntry] @field_validator("maintainer", mode="before") @classmethod @@ -139,6 +140,7 @@ def _get_deployment_metadata_once() -> dict: "identifier_prefix": identifier_prefix, "max_upload_bytes": CONFIG.MAX_CONTENT_LENGTH, "features": FEATURE_FLAGS, + "navigation": CONFIG.NAVIGATION, } ) return metadata diff --git a/pydatalab/tests/server/test_info_and_health.py b/pydatalab/tests/server/test_info_and_health.py index 614740aab..323bc9681 100644 --- a/pydatalab/tests/server/test_info_and_health.py +++ b/pydatalab/tests/server/test_info_and_health.py @@ -99,3 +99,54 @@ def test_info_endpoint_includes_max_upload_bytes(client, app): assert isinstance(attributes["max_upload_bytes"], int) assert attributes["max_upload_bytes"] > 0 assert attributes["max_upload_bytes"] == 10 * 1000 * 1000 + + +def test_info_endpoint_includes_navigation(client): + response = client.get("/info") + + assert response.status_code == 200 + assert response.json["data"]["attributes"]["navigation"] == [ + {"view": "about", "label": None, "icon": None, "default": False}, + {"view": "samples", "label": None, "icon": None, "default": True}, + {"view": "collections", "label": None, "icon": None, "default": False}, + {"view": "starting-materials", "label": None, "icon": None, "default": False}, + {"view": "equipment", "label": None, "icon": None, "default": False}, + { + "view": "item-graph", + "label": None, + "icon": "project-diagram", + "default": False, + }, + ] + + +def test_info_endpoint_includes_custom_navigation(client, monkeypatch): + from pydatalab.config import CONFIG, ServerConfig + from pydatalab.routes.v0_1.info import _get_deployment_metadata_once + + custom_config = ServerConfig( + TESTING=True, + _env_file=None, + NAVIGATION=[ + {"view": "equipment"}, + {"view": "starting-materials", "label": "Materials", "default": True}, + ], + ) + monkeypatch.setattr(CONFIG, "NAVIGATION", custom_config.NAVIGATION) + _get_deployment_metadata_once.cache_clear() + + try: + response = client.get("/info") + finally: + _get_deployment_metadata_once.cache_clear() + + assert response.status_code == 200 + assert response.json["data"]["attributes"]["navigation"] == [ + {"view": "equipment", "label": None, "icon": None, "default": False}, + { + "view": "starting-materials", + "label": "Materials", + "icon": None, + "default": True, + }, + ] diff --git a/pydatalab/tests/test_config.py b/pydatalab/tests/test_config.py index 0637c5794..a2cab9bdc 100644 --- a/pydatalab/tests/test_config.py +++ b/pydatalab/tests/test_config.py @@ -1,6 +1,7 @@ from pathlib import Path import pytest +from pydantic import ValidationError def test_default_settings(): @@ -28,6 +29,140 @@ def test_update_settings(): assert Path(config.FILE_DIRECTORY).name == "files" +def test_default_navigation(monkeypatch): + from pydatalab.config import ServerConfig + + monkeypatch.delenv("PYDATALAB_NAVIGATION", raising=False) + config = ServerConfig(TESTING=True, _env_file=None) + + assert [entry.model_dump(exclude_none=True) for entry in config.NAVIGATION] == [ + {"view": "about", "default": False}, + {"view": "samples", "default": True}, + {"view": "collections", "default": False}, + {"view": "starting-materials", "default": False}, + {"view": "equipment", "default": False}, + {"view": "item-graph", "icon": "project-diagram", "default": False}, + ] + + +def test_custom_navigation(): + from pydatalab.config import ServerConfig + + config = ServerConfig( + TESTING=True, + NAVIGATION=[ + { + "view": "starting-materials", + "label": "Starting Materials", + "icon": "vials", + "default": True, + }, + {"view": "my-custom-view"}, + ], + ) + + assert [entry.model_dump(exclude_none=True) for entry in config.NAVIGATION] == [ + { + "view": "starting-materials", + "label": "Starting Materials", + "icon": "vials", + "default": True, + }, + {"view": "my-custom-view", "default": False}, + ] + + +def test_navigation_uses_first_entry_as_default(): + from pydatalab.config import ServerConfig + + config = ServerConfig( + TESTING=True, + NAVIGATION=[{"view": "equipment"}, {"view": "about"}], + ) + + assert [entry.default for entry in config.NAVIGATION] == [True, False] + + +def test_navigation_from_environment(monkeypatch): + from pydatalab.config import ServerConfig + + monkeypatch.setenv( + "PYDATALAB_NAVIGATION", + '[{"view":"samples","label":"Experiments"},{"view":"about","default":true}]', + ) + + config = ServerConfig(TESTING=True, _env_file=None) + + assert [entry.model_dump(exclude_none=True) for entry in config.NAVIGATION] == [ + {"view": "samples", "label": "Experiments", "default": False}, + {"view": "about", "default": True}, + ] + + +def test_navigation_from_dotenv(tmp_path, monkeypatch): + from pydatalab.config import ServerConfig + + monkeypatch.delenv("PYDATALAB_NAVIGATION", raising=False) + env_file = tmp_path / ".env" + env_file.write_text( + "PYDATALAB_NAVIGATION='[" + '{"view":"starting-materials","label":"Starting Materials"},' + '{"view":"about"}' + "]'\n", + encoding="utf-8", + ) + + config = ServerConfig(TESTING=True, _env_file=env_file) + + assert [entry.model_dump(exclude_none=True) for entry in config.NAVIGATION] == [ + {"view": "starting-materials", "label": "Starting Materials", "default": True}, + {"view": "about", "default": False}, + ] + + +def test_navigation_rejects_duplicate_views(): + from pydatalab.config import ServerConfig + + with pytest.raises(ValidationError, match="duplicate view IDs"): + ServerConfig(TESTING=True, NAVIGATION=[{"view": "samples"}, {"view": "samples"}]) + + +def test_navigation_rejects_empty_list(): + from pydatalab.config import ServerConfig + + with pytest.raises(ValidationError, match="at least one entry"): + ServerConfig(TESTING=True, NAVIGATION=[]) + + +def test_navigation_rejects_multiple_default_views(): + from pydatalab.config import ServerConfig + + with pytest.raises(ValidationError, match="multiple default views"): + ServerConfig( + TESTING=True, + NAVIGATION=[ + {"view": "samples", "default": True}, + {"view": "about", "default": True}, + ], + ) + + +@pytest.mark.parametrize( + "entry", + [ + {"view": ""}, + {"view": "samples", "label": " "}, + {"view": "samples", "icon": ""}, + {"view": "samples", "unknown": "value"}, + ], +) +def test_navigation_rejects_malformed_entries(entry): + from pydatalab.config import ServerConfig + + with pytest.raises(ValidationError): + ServerConfig(TESTING=True, NAVIGATION=[entry]) + + def test_config_override(): from pydatalab.main import create_app diff --git a/webapp/cypress/component/NavbarTest.cy.jsx b/webapp/cypress/component/NavbarTest.cy.jsx index 2900fc2dc..0b32fc679 100644 --- a/webapp/cypress/component/NavbarTest.cy.jsx +++ b/webapp/cypress/component/NavbarTest.cy.jsx @@ -2,6 +2,11 @@ import Navbar from "@/components/Navbar.vue"; import { createRouter, createWebHistory } from "vue-router"; import { createStore } from "vuex"; import LoginDetails from "@/components/LoginDetails.vue"; +import { NAVIGATION_VIEWS, resolveDefaultRouteName } from "@/navigation.js"; +import { library } from "@fortawesome/fontawesome-svg-core"; +import { faProjectDiagram, faVials } from "@fortawesome/free-solid-svg-icons"; + +library.add(faProjectDiagram, faVials); describe("Navbar", () => { let store; @@ -11,6 +16,16 @@ describe("Navbar", () => { store = createStore({ state: { currentUserDisplayName: null, + serverInfo: { + navigation: [ + { view: "about" }, + { view: "samples", default: true }, + { view: "collections" }, + { view: "starting-materials" }, + { view: "equipment" }, + { view: "item-graph", icon: "project-diagram" }, + ], + }, }, }); @@ -18,15 +33,19 @@ describe("Navbar", () => { history: createWebHistory(), routes: [ { path: "/about", name: "About" }, - { path: "/samples", name: "Samples" }, - { path: "/collections", name: "Collections" }, - { path: "/starting-materials", name: "Inventory" }, - { path: "/equipment", name: "Equipment" }, - { path: "/item-graph", name: "GraphView" }, + { path: "/samples", name: "samples" }, + { path: "/collections", name: "collections" }, + { path: "/starting-materials", name: "starting-materials" }, + { path: "/equipment", name: "equipment" }, + { path: "/item-graph", name: "item-graph" }, ], }); }); + afterEach(() => { + delete NAVIGATION_VIEWS.collections.isAvailable; + }); + it("renders logo image when logo_url is provided", () => { cy.mount(Navbar, { global: { @@ -109,6 +128,165 @@ describe("Navbar", () => { }); }); + it("renders configured links in order with custom labels and icons", () => { + store.state.serverInfo.navigation = [ + { view: "equipment" }, + { view: "starting-materials", label: "Starting Materials", icon: "vials" }, + { view: "about", label: "About this deployment" }, + ]; + + cy.mount(Navbar, { + global: { + plugins: [store, router], + }, + }); + + cy.get("#nav a").then(($links) => { + expect([...$links].map((link) => link.textContent.trim())).to.deep.equal([ + "Equipment", + "Starting Materials", + "About this deployment", + ]); + }); + cy.get('[data-testid="navbar-link-starting-materials"]') + .should("have.attr", "href", "/starting-materials") + .find('font-awesome-icon[icon="vials"]') + .should("exist"); + cy.get("#nav .nav-separator").should("have.length", 2); + }); + + it("supports an explicitly empty navigation", () => { + store.state.serverInfo.navigation = []; + + cy.mount(Navbar, { + global: { + plugins: [store, router], + }, + }); + + cy.get("#nav a").should("not.exist"); + cy.get("#nav .nav-separator").should("not.exist"); + }); + + it("uses the legacy navigation when loaded server metadata has no navigation", () => { + store.state.serverInfo = {}; + + cy.mount(Navbar, { + global: { + plugins: [store, router], + }, + }); + + cy.get("#nav a").should("have.length", 6); + cy.get('[data-testid="navbar-link-item-graph"] font-awesome-icon').should("exist"); + }); + + it("resolves an explicitly configured default route", () => { + const serverInfo = { + navigation: [{ view: "samples" }, { view: "starting-materials", default: true }], + }; + + expect(resolveDefaultRouteName(serverInfo)).to.equal("starting-materials"); + }); + + it("uses the first resolved route when no default is marked", () => { + const serverInfo = { + navigation: [{ view: "equipment" }, { view: "about" }], + }; + + expect(resolveDefaultRouteName(serverInfo)).to.equal("equipment"); + }); + + it("uses Samples as the legacy and final defensive default", () => { + expect(resolveDefaultRouteName({})).to.equal("samples"); + expect( + resolveDefaultRouteName({ navigation: [{ view: "missing-view", default: true }] }), + ).to.equal("samples"); + }); + + it("falls back when the configured default is unknown or unavailable", () => { + expect( + resolveDefaultRouteName({ + navigation: [{ view: "missing-view", default: true }, { view: "equipment" }], + }), + ).to.equal("equipment"); + + NAVIGATION_VIEWS.collections.isAvailable = (serverInfo) => serverInfo.features.test_enabled; + expect( + resolveDefaultRouteName({ + features: { test_enabled: false }, + navigation: [{ view: "collections", default: true }, { view: "about" }], + }), + ).to.equal("About"); + }); + + it("does not render links before server metadata is loaded", () => { + store.state.serverInfo = null; + + cy.mount(Navbar, { + global: { + plugins: [store, router], + stubs: { + LoginDetails: true, + }, + }, + }); + + cy.get("#nav a").should("not.exist"); + cy.get("#nav .nav-separator").should("not.exist"); + }); + + it("skips unknown views and icons without affecting valid links", () => { + store.state.serverInfo.navigation = [ + { view: "missing-view" }, + { view: "constructor" }, + { view: "__proto__" }, + { view: "samples", icon: "missing-icon" }, + { view: "about" }, + ]; + + cy.mount(Navbar, { + global: { + plugins: [store, router], + }, + }); + + cy.get("#nav a").should("have.length", 2); + cy.get('[data-testid="navbar-link-samples"] font-awesome-icon').should("not.exist"); + cy.get("#nav .nav-separator").should("have.length", 1); + }); + + it("hides a configured view when its catalog availability predicate returns false", () => { + NAVIGATION_VIEWS.collections.isAvailable = (serverInfo) => serverInfo.features.test_enabled; + store.state.serverInfo = { + features: { test_enabled: false }, + navigation: [{ view: "samples" }, { view: "collections" }, { view: "equipment" }], + }; + + cy.mount(Navbar, { + global: { + plugins: [store, router], + }, + }); + + cy.get('[data-testid="navbar-link-collections"]').should("not.exist"); + cy.get("#nav a").should("have.length", 2); + cy.get("#nav .nav-separator").should("have.length", 1); + }); + + it("only hides omitted links and leaves their routes registered", () => { + store.state.serverInfo.navigation = [{ view: "samples" }]; + + cy.mount(Navbar, { + global: { + plugins: [store, router], + }, + }); + + cy.get('[data-testid="navbar-link-collections"]').should("not.exist"); + expect(router.hasRoute("collections")).to.equal(true); + }); + it("navigates to the correct route on link click", () => { cy.mount(Navbar, { global: { diff --git a/webapp/cypress/component/RouterTest.cy.js b/webapp/cypress/component/RouterTest.cy.js new file mode 100644 index 000000000..7797a1727 --- /dev/null +++ b/webapp/cypress/component/RouterTest.cy.js @@ -0,0 +1,44 @@ +import router from "@/router/index.js"; +import store from "@/store/index.js"; +import { invalidateCurrentUserCache } from "@/server_fetch_utils.js"; + +describe("Router", () => { + beforeEach(() => { + store.state.serverInfo = null; + invalidateCurrentUserCache(); + }); + + it("uses one explicit home route without changing direct routes", () => { + expect(router.resolve("/").name).to.equal("home"); + expect(router.resolve("/samples").name).to.equal("samples"); + expect(router.resolve("/equipment").name).to.equal("equipment"); + expect(router.resolve("/admin").name).to.equal("admin"); + }); + + it("redirects the root path to the configured default route", () => { + cy.intercept("GET", "**/get-current-user/", { + immutable_id: "test-user", + display_name: "Test User", + role: "user", + account_status: "active", + }); + cy.intercept("GET", "**/info", { + data: { + attributes: { + navigation: [ + { view: "samples", default: false }, + { view: "equipment", default: true }, + ], + }, + }, + }); + + cy.then(async () => { + await router.push("/"); + await router.isReady(); + + expect(router.currentRoute.value.name).to.equal("equipment"); + expect(router.currentRoute.value.path).to.equal("/equipment"); + }); + }); +}); diff --git a/webapp/src/components/Navbar.vue b/webapp/src/components/Navbar.vue index d98fb1fe1..758415892 100644 --- a/webapp/src/components/Navbar.vue +++ b/webapp/src/components/Navbar.vue @@ -18,14 +18,13 @@