From 3fbbb84267cf7d3aeb7e3b6f243560bb53ae37dc Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 6 Aug 2026 19:02:54 +0200 Subject: [PATCH] Initialize configurable Quick Access module --- .gitea/ISSUE_TEMPLATE/bug_report.md | 35 ++ .gitea/ISSUE_TEMPLATE/config.yaml | 1 + .gitea/ISSUE_TEMPLATE/docs_workflow.md | 27 ++ .gitea/ISSUE_TEMPLATE/feature_request.md | 32 ++ .gitea/ISSUE_TEMPLATE/task.md | 28 ++ .gitea/ISSUE_TEMPLATE/tech_debt.md | 25 ++ .gitea/PULL_REQUEST_TEMPLATE.md | 15 + .gitea/workflows/module-package-release.yml | 270 +++++++++++++ .gitignore | 13 + README.md | 14 +- docs/QUICK_ACCESS.md | 41 ++ pyproject.toml | 24 ++ src/govoplan_quick_access/__init__.py | 3 + src/govoplan_quick_access/backend/__init__.py | 1 + .../backend/db/__init__.py | 3 + .../backend/db/models.py | 35 ++ src/govoplan_quick_access/backend/manifest.py | 317 +++++++++++++++ .../backend/migrations/__init__.py | 1 + .../9a4e6c2d8f10_v0118_quick_access.py | 49 +++ .../backend/migrations/versions/__init__.py | 1 + src/govoplan_quick_access/backend/router.py | 313 ++++++++++++++ src/govoplan_quick_access/backend/schemas.py | 89 ++++ src/govoplan_quick_access/backend/service.py | 381 ++++++++++++++++++ src/govoplan_quick_access/py.typed | 1 + tests/test_migrations.py | 65 +++ tests/test_quick_access.py | 142 +++++++ webui/package.json | 28 ++ webui/src/api/quickAccess.ts | 116 ++++++ webui/src/components/QuickAccessRail.tsx | 204 ++++++++++ .../settings/QuickAccessSettingsPanel.tsx | 370 +++++++++++++++++ webui/src/i18n/generatedTranslations.ts | 78 ++++ webui/src/index.ts | 2 + webui/src/module.ts | 98 +++++ webui/src/styles/quick-access.css | 265 ++++++++++++ 34 files changed, 3086 insertions(+), 1 deletion(-) create mode 100644 .gitea/ISSUE_TEMPLATE/bug_report.md create mode 100644 .gitea/ISSUE_TEMPLATE/config.yaml create mode 100644 .gitea/ISSUE_TEMPLATE/docs_workflow.md create mode 100644 .gitea/ISSUE_TEMPLATE/feature_request.md create mode 100644 .gitea/ISSUE_TEMPLATE/task.md create mode 100644 .gitea/ISSUE_TEMPLATE/tech_debt.md create mode 100644 .gitea/PULL_REQUEST_TEMPLATE.md create mode 100644 .gitea/workflows/module-package-release.yml create mode 100644 .gitignore create mode 100644 docs/QUICK_ACCESS.md create mode 100644 pyproject.toml create mode 100644 src/govoplan_quick_access/__init__.py create mode 100644 src/govoplan_quick_access/backend/__init__.py create mode 100644 src/govoplan_quick_access/backend/db/__init__.py create mode 100644 src/govoplan_quick_access/backend/db/models.py create mode 100644 src/govoplan_quick_access/backend/manifest.py create mode 100644 src/govoplan_quick_access/backend/migrations/__init__.py create mode 100644 src/govoplan_quick_access/backend/migrations/versions/9a4e6c2d8f10_v0118_quick_access.py create mode 100644 src/govoplan_quick_access/backend/migrations/versions/__init__.py create mode 100644 src/govoplan_quick_access/backend/router.py create mode 100644 src/govoplan_quick_access/backend/schemas.py create mode 100644 src/govoplan_quick_access/backend/service.py create mode 100644 src/govoplan_quick_access/py.typed create mode 100644 tests/test_migrations.py create mode 100644 tests/test_quick_access.py create mode 100644 webui/package.json create mode 100644 webui/src/api/quickAccess.ts create mode 100644 webui/src/components/QuickAccessRail.tsx create mode 100644 webui/src/features/settings/QuickAccessSettingsPanel.tsx create mode 100644 webui/src/i18n/generatedTranslations.ts create mode 100644 webui/src/index.ts create mode 100644 webui/src/module.ts create mode 100644 webui/src/styles/quick-access.css diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..c99befa --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: "Bug" +about: "Report a reproducible defect, regression, or incorrect behavior" +title: "[Bug] " +labels: + - type/bug + - status/triage + - module/quick-access +--- + +## Scope + +- Repository: +- Area/module: +- Affected version or commit: + +## Behavior + +Expected: + +Actual: + +## Reproduction + +1. +2. +3. + +## Evidence + +Logs, screenshots, traces, or failing test output: + +## Verification Target + +Command or workflow that should pass when fixed: diff --git a/.gitea/ISSUE_TEMPLATE/config.yaml b/.gitea/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.gitea/ISSUE_TEMPLATE/docs_workflow.md b/.gitea/ISSUE_TEMPLATE/docs_workflow.md new file mode 100644 index 0000000..9157fe0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/docs_workflow.md @@ -0,0 +1,27 @@ +--- +name: "Docs / workflow" +about: "Request documentation, process, or developer workflow changes" +title: "[Docs] " +labels: + - type/docs + - status/triage + - module/quick-access + - area/docs +--- + +## Scope + +- Repository: +- Document or workflow: + +## Current State + +What is missing, unclear, duplicated, or stale? + +## Desired State + +What should the docs or workflow make clear? + +## Verification Target + +How should this be checked? diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..0b3dabc --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,32 @@ +--- +name: "Feature" +about: "Propose new user-visible behavior or platform capability" +title: "[Feature] " +labels: + - type/feature + - status/triage + - module/quick-access +--- + +## Problem + +What user, operator, or developer problem should this solve? + +## Proposed Capability + +What should exist when this is done? + +## Ownership + +- Owning repository: +- Related module repositories: +- Extension point or integration boundary: + +## Acceptance Criteria + +- [ ] +- [ ] + +## Verification Target + +Command, scenario, or UI flow that should prove completion: diff --git a/.gitea/ISSUE_TEMPLATE/task.md b/.gitea/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..db85f15 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/task.md @@ -0,0 +1,28 @@ +--- +name: "Task" +about: "Track implementation, maintenance, or migration work" +title: "[Task] " +labels: + - type/task + - status/triage + - module/quick-access +--- + +## Objective + +What needs to be completed? + +## Scope + +- Owning repository: +- In-scope: +- Out-of-scope: + +## Checklist + +- [ ] +- [ ] + +## Verification Target + +Command or manual check: diff --git a/.gitea/ISSUE_TEMPLATE/tech_debt.md b/.gitea/ISSUE_TEMPLATE/tech_debt.md new file mode 100644 index 0000000..0fe3dc1 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/tech_debt.md @@ -0,0 +1,25 @@ +--- +name: "Tech debt" +about: "Track cleanup, refactoring, risk reduction, or deferred engineering work" +title: "[Debt] " +labels: + - type/debt + - status/triage + - module/quick-access +--- + +## Current Cost + +What does this make harder, riskier, slower, or more fragile? + +## Desired Shape + +What should the code, tests, or architecture look like afterwards? + +## Constraints + +What behavior, compatibility, or module boundary must be preserved? + +## Verification Target + +Focused checks that should pass: diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1984736 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Issue + +Closes # + +## Summary + +- + +## Verification + +- + +## Notes + +Follow-up issues: diff --git a/.gitea/workflows/module-package-release.yml b/.gitea/workflows/module-package-release.yml new file mode 100644 index 0000000..ef7ae89 --- /dev/null +++ b/.gitea/workflows/module-package-release.yml @@ -0,0 +1,270 @@ +name: Module Package Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + release_tag: + description: Existing protected version tag to publish + required: true + type: string + +jobs: + publish-packages: + runs-on: ubuntu-latest + env: + GITEA_REPOSITORY: ${{ gitea.repository }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + fetch-depth: 0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.12" + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: "22" + - name: Select and validate protected release tag + shell: bash + env: + REQUESTED_TAG: ${{ inputs.release_tag }} + TRIGGER_TAG: ${{ gitea.ref_name }} + run: | + set -euo pipefail + tag="${REQUESTED_TAG:-$TRIGGER_TAG}" + case "$tag" in + v[0-9]*.[0-9]*.[0-9]*) ;; + *) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;; + esac + git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main + tag_commit="$(git rev-list -n 1 "$tag")" + git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || { + echo "Release tag is not contained in main" >&2 + exit 1 + } + git checkout --detach "$tag" + printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV" + printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV" + - name: Validate package versions + run: | + python - <<'PY' + import json + from pathlib import Path + import os + import re + import tomllib + + tag = os.environ["RELEASE_TAG"] + expected = tag.removeprefix("v") + project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"] + if project.get("version") != expected: + raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}") + if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None: + raise SystemExit("Python distribution name must use the govoplan-* namespace") + webui = Path("webui/package.json") + if webui.is_file(): + package = json.loads(webui.read_text(encoding="utf-8")) + if package.get("version") != expected: + raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}") + if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None: + raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace") + release = Path("webui/package.release.json") + if release.is_file(): + release_package = json.loads(release.read_text(encoding="utf-8")) + if ( + release_package.get("name") != package.get("name") + or release_package.get("version") != expected + ): + raise SystemExit("WebUI release package identity does not match package.json and the release tag") + PY + - name: Build immutable package artifacts + shell: bash + run: | + set -euo pipefail + python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0 + rm -rf dist .package-webui + python -m build --wheel --outdir dist + python -m twine check dist/*.whl + if [[ -f webui/package.json ]]; then + mkdir .package-webui + cp -a webui/. .package-webui/ + rm -rf .package-webui/node_modules .package-webui/dist + if [[ -f .package-webui/package.release.json ]]; then + cp .package-webui/package.release.json .package-webui/package.json + fi + node <<'NODE' + const fs = require("node:fs"); + const path = ".package-webui/package.json"; + const packageJson = JSON.parse(fs.readFileSync(path, "utf8")); + const groups = ["dependencies", "optionalDependencies", "peerDependencies"]; + for (const group of groups) { + for (const [name, specifier] of Object.entries(packageJson[group] || {})) { + if (!name.startsWith("@govoplan/")) continue; + if (typeof specifier !== "string") { + throw new Error(`${group}.${name} must use a string version`); + } + const packageSlug = name.slice("@govoplan/".length); + if (!packageSlug.endsWith("-webui")) { + throw new Error(`${group}.${name} is outside the WebUI package namespace`); + } + const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`; + const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const gitTag = specifier.match( + new RegExp( + `^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`, + ), + ); + if (gitTag) { + packageJson[group][name] = gitTag[1]; + continue; + } + if (specifier.startsWith("file:") || specifier.startsWith("git+")) { + throw new Error( + `${group}.${name} must resolve to an exact registry version for publication`, + ); + } + } + } + delete packageJson.private; + fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`); + NODE + npm pkg delete private --prefix .package-webui + (cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist) + fi + python - <<'PY' + import hashlib + import json + from pathlib import Path + import os + import subprocess + + artifacts = [] + for path in sorted(Path("dist").iterdir()): + if path.suffix not in {".whl", ".tgz"}: + continue + digest = hashlib.sha256(path.read_bytes()).hexdigest() + artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size}) + payload = { + "schema_version": "1", + "repository": os.environ["GITEA_REPOSITORY"], + "tag": os.environ["RELEASE_TAG"], + "commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), + "artifacts": artifacts, + } + Path("dist/package-artifacts.json").write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + PY + - name: Retain package hash evidence + uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 + with: + name: module-packages-${{ gitea.ref_name }} + path: dist/package-artifacts.json + - name: Check immutable registry state + shell: bash + env: + PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }} + run: | + set -euo pipefail + test -n "$PACKAGE_TOKEN" + python - <<'PY' + import hashlib + import json + import os + from pathlib import Path + import tomllib + from urllib.error import HTTPError + from urllib.parse import quote + from urllib.request import Request, urlopen + + api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN" + token = os.environ["PACKAGE_TOKEN"] + + def should_publish(kind, name, version, path): + package_url = "/".join( + (api_root, kind, quote(name, safe=""), quote(version, safe=""), "files") + ) + request = Request( + package_url, + headers={"Accept": "application/json", "Authorization": f"token {token}"}, + ) + try: + with urlopen(request, timeout=30) as response: + files = json.load(response) + except HTTPError as exc: + if exc.code == 404: + print(f"{kind} package {name}=={version} is not published yet") + return True + raise + if not isinstance(files, list) or len(files) != 1: + raise SystemExit( + f"immutable {kind} package {name}=={version} has an unexpected file set" + ) + expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest() + if files[0].get("sha256") != expected_sha256: + raise SystemExit( + f"immutable {kind} package {name}=={version} already exists with a different SHA-256" + ) + print(f"verified existing {kind} package {name}=={version} ({expected_sha256})") + return False + + project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"] + wheels = tuple(Path("dist").glob("*.whl")) + if len(wheels) != 1: + raise SystemExit("release build must contain exactly one wheel") + publish_pypi = should_publish( + "pypi", str(project["name"]), str(project["version"]), wheels[0] + ) + + tarballs = tuple(Path("dist").glob("*.tgz")) + if len(tarballs) > 1: + raise SystemExit("release build must contain at most one npm package") + publish_npm = False + if tarballs: + webui = json.loads( + Path(".package-webui/package.json").read_text(encoding="utf-8") + ) + publish_npm = should_publish( + "npm", str(webui["name"]), str(webui["version"]), tarballs[0] + ) + + with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file: + env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n") + env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n") + PY + - name: Publish wheel and WebUI package + shell: bash + env: + PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }} + PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }} + run: | + set -euo pipefail + test -n "$PACKAGE_USERNAME" + test -n "$PACKAGE_TOKEN" + if [[ "$PUBLISH_PYPI" == 1 ]]; then + TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \ + python -m twine upload --non-interactive \ + --repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \ + dist/*.whl + else + echo "Exact wheel is already present; skipping immutable retry." + fi + shopt -s nullglob + webui_packages=(dist/*.tgz) + if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then + npmrc="$(mktemp)" + trap 'rm -f "$npmrc"' EXIT + chmod 600 "$npmrc" + printf '%s\n' \ + '@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \ + "//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \ + > "$npmrc" + NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \ + --ignore-scripts --access public \ + --registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/ + elif (( ${#webui_packages[@]} )); then + echo "Exact WebUI package is already present; skipping immutable retry." + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c1d6a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +.venv/ +dist/ +build/ +node_modules/ +webui/dist/ +webui/.vite/ +webui/test-results/ +webui/playwright-report/ diff --git a/README.md b/README.md index dc6c54c..3cc1f6c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,15 @@ # govoplan-quick-access -Configurable task-local Quick Access rail for GovOPlaN \ No newline at end of file + +**Repository type:** module (presentation). + + +GovOPlaN Quick Access provides the configurable right-side rail and overlay for +compact tools contributed by other modules. It owns presentation preferences, +not Mail, Postbox, Calendar, Files, Tasks, or any other domain state. + +The initial categories are **Work**, **Calendar**, **Messages**, and **Files**. +Mail, Postbox, and future chat providers are composed inside the one Messages +overlay while retaining their independent delivery and authority semantics. + +See [Quick Access architecture](docs/QUICK_ACCESS.md). diff --git a/docs/QUICK_ACCESS.md b/docs/QUICK_ACCESS.md new file mode 100644 index 0000000..511e16c --- /dev/null +++ b/docs/QUICK_ACCESS.md @@ -0,0 +1,41 @@ +# Quick Access + +Quick Access is an optional presentation module. Feature modules register +compact tool metadata through the Core manifest contract and contribute their +renderer through `quickAccess.tools`. Quick Access owns the rail, overlay, +effective preference calculation, and settings UI. It never copies or owns the +underlying business objects. + +## Resolution + +The live catalogue is derived from installed module manifests. Personal +catalogues contain only currently authorized tools; system and tenant +administrators can inspect the complete registered catalogue and inherited +constraints. Effective visibility is resolved in this order: + +1. module installation and tenant entitlement; +2. system availability, blocking, forcing, and ordering; +3. tenant availability, blocking, forcing, and ordering; +4. the user's enabled state and ordering; +5. the active View or Workflow projection; +6. the current principal's permissions. + +An upper scope may block or force a category or tool. Lower scopes cannot +override that decision, but may configure any still-available item. Stale +preferences are retained and diagnosed so uninstalling and reinstalling a +contributing module does not silently discard a user's arrangement. + +## Categories + +The initial stable categories are Work, Calendar, Messages, and Files. Messages +may contain Mail, Postbox, and future chat contributions in one overlay. A +category is a presentation grouping only; channel-specific authority, +retention, acknowledgement, encryption, and delivery state remain with each +owner module. + +## Safety + +Quick Access is not an authorization boundary. Every contribution keeps its +own permission requirements and View surface. Full-page routes remain the +canonical fallback. Disabling this module removes the rail without making any +domain state unavailable through its owning module. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7c76361 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-quick-access" +version = "0.1.18" +description = "Governed configurable Quick Access rail for GovOPlaN." +readme = "README.md" +requires-python = ">=3.12" +authors = [{ name = "GovOPlaN" }] +dependencies = [ + "govoplan-core>=0.1.18", + "govoplan-access>=0.1.18", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_quick_access = ["py.typed"] + +[project.entry-points."govoplan.modules"] +quick_access = "govoplan_quick_access.backend.manifest:get_manifest" diff --git a/src/govoplan_quick_access/__init__.py b/src/govoplan_quick_access/__init__.py new file mode 100644 index 0000000..2e6be55 --- /dev/null +++ b/src/govoplan_quick_access/__init__.py @@ -0,0 +1,3 @@ +"""GovOPlaN Quick Access module.""" + +__version__ = "0.1.18" diff --git a/src/govoplan_quick_access/backend/__init__.py b/src/govoplan_quick_access/backend/__init__.py new file mode 100644 index 0000000..6aeb428 --- /dev/null +++ b/src/govoplan_quick_access/backend/__init__.py @@ -0,0 +1 @@ +"""Quick Access backend.""" diff --git a/src/govoplan_quick_access/backend/db/__init__.py b/src/govoplan_quick_access/backend/db/__init__.py new file mode 100644 index 0000000..dbf762a --- /dev/null +++ b/src/govoplan_quick_access/backend/db/__init__.py @@ -0,0 +1,3 @@ +from govoplan_quick_access.backend.db.models import QuickAccessProfile + +__all__ = ["QuickAccessProfile"] diff --git a/src/govoplan_quick_access/backend/db/models.py b/src/govoplan_quick_access/backend/db/models.py new file mode 100644 index 0000000..cd5815f --- /dev/null +++ b/src/govoplan_quick_access/backend/db/models.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import uuid +from typing import Any + +from sqlalchemy import Index, Integer, JSON, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from govoplan_core.db.base import Base, TimestampMixin + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +class QuickAccessProfile(Base, TimestampMixin): + __tablename__ = "quick_access_profiles" + __table_args__ = ( + UniqueConstraint("scope_key", name="uq_quick_access_profile_scope"), + Index("ix_quick_access_profiles_scope", "scope_type", "tenant_id", "scope_id"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + scope_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + scope_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + scope_key: Mapped[str] = mapped_column(String(340), nullable=False, index=True) + category_preferences: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + tool_preferences: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + created_by: Mapped[str | None] = mapped_column(String(255), nullable=True) + updated_by: Mapped[str | None] = mapped_column(String(255), nullable=True) + + +__all__ = ["QuickAccessProfile", "new_uuid"] diff --git a/src/govoplan_quick_access/backend/manifest.py b/src/govoplan_quick_access/backend/manifest.py new file mode 100644 index 0000000..48bc06c --- /dev/null +++ b/src/govoplan_quick_access/backend/manifest.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +from pathlib import Path + +from govoplan_core.core.access import ( + CAPABILITY_AUTH_PERMISSION_EVALUATOR, + CAPABILITY_AUTH_PRINCIPAL_RESOLVER, +) +from govoplan_core.core.module_guards import ( + drop_table_retirement_provider, + persistent_table_uninstall_guard, +) +from govoplan_core.core.modules import ( + DocumentationLink, + DocumentationTopic, + FrontendModule, + MigrationSpec, + ModuleContext, + ModuleInterfaceProvider, + ModuleManifest, + PermissionDefinition, + RoleTemplate, +) +from govoplan_core.core.provider_governance import declared_module_architecture +from govoplan_core.core.views import ViewSurface +from govoplan_core.db.base import Base +from govoplan_quick_access.backend.db import models as quick_access_models + + +MODULE_ID = "quick_access" +MODULE_NAME = "Quick Access" +MODULE_VERSION = "0.1.18" + +READ_SCOPE = "quick_access:profile:read" +WRITE_SCOPE = "quick_access:profile:write" +TENANT_ADMIN_SCOPE = "quick_access:profile:admin" +SYSTEM_ADMIN_SCOPE = "quick_access:system:admin" + + +def _permission( + scope: str, + label: str, + description: str, + *, + level: str = "tenant", +) -> PermissionDefinition: + module_id, resource, action = scope.split(":", 2) + return PermissionDefinition( + scope=scope, + label=label, + description=description, + category="Quick Access", + level=level, + module_id=module_id, + resource=resource, + action=action, + ) + + +PERMISSIONS = ( + _permission( + READ_SCOPE, + "Use Quick Access", + "Read the effective Quick Access catalogue and preferences.", + ), + _permission( + WRITE_SCOPE, + "Configure personal Quick Access", + "Enable, disable, and order available Quick Access categories and tools.", + ), + _permission( + TENANT_ADMIN_SCOPE, + "Manage tenant Quick Access", + "Set tenant availability, forced items, and default ordering.", + ), + _permission( + SYSTEM_ADMIN_SCOPE, + "Manage system Quick Access", + "Set system-wide availability, forced items, and default ordering.", + level="system", + ), +) + +ROLE_TEMPLATES = ( + RoleTemplate( + slug="quick_access_user", + name="Quick Access user", + description="Use and arrange the Quick Access rail.", + permissions=(READ_SCOPE, WRITE_SCOPE), + default_authenticated=True, + ), + RoleTemplate( + slug="quick_access_manager", + name="Quick Access manager", + description="Manage tenant Quick Access policy and defaults.", + permissions=(READ_SCOPE, WRITE_SCOPE, TENANT_ADMIN_SCOPE), + ), + RoleTemplate( + slug="quick_access_system_manager", + name="Quick Access system manager", + description="Manage system-wide Quick Access policy and defaults.", + permissions=(SYSTEM_ADMIN_SCOPE,), + level="system", + ), +) + + +def _router(context: ModuleContext): + from govoplan_quick_access.backend.router import create_router + + return create_router(context.registry) + + +DOCUMENTATION = ( + DocumentationTopic( + id="quick-access.user", + title="Quick Access rail", + summary="Keep selected work, calendar, message, and file tools available beside the current page.", + body=( + "Open a category on the right rail to use compact tools without leaving the current task. " + "Messages combines enabled Mail, Postbox, and future chat contributions in one overlay. " + "Personal settings can reorder or hide items that remain available under system, tenant, " + "permission, and View policy. Every item retains a link to its complete owning page." + ), + layer="configured", + documentation_types=("user",), + audience=("user",), + links=( + DocumentationLink( + label="Quick Access architecture", + href="govoplan-quick-access/docs/QUICK_ACCESS.md", + kind="repository", + ), + ), + translations={ + "de": { + "title": "Schnellzugriffsleiste", + "summary": "Ausgewaehlte Werkzeuge fuer Arbeit, Kalender, Nachrichten und Dateien neben der aktuellen Seite verwenden.", + "body": ( + "Eine Kategorie in der rechten Leiste oeffnet kompakte Werkzeuge, ohne die aktuelle Aufgabe zu verlassen. " + "Nachrichten fuehrt Beitraege aus Mail, Postfach und kuenftigen Chat-Modulen in einer Einblendung zusammen. " + "Persoenliche Einstellungen koennen alle durch System, Mandant, Berechtigungen und Ansicht zugelassenen Eintraege ordnen oder ausblenden." + ), + } + }, + metadata={ + "help_contexts": [ + "quick_access.rail", + "quick_access.drawer", + "quick_access.settings.personal", + ] + }, + ), + DocumentationTopic( + id="quick-access.admin", + title="Quick Access policy", + summary="Govern which registered compact tools lower scopes may use and how they are ordered by default.", + body=( + "The catalogue follows installed module registrations. System settings constrain tenants; " + "tenant settings constrain users. An item may remain available, be blocked, or be forced. " + "Views and permissions form additional ceilings and Quick Access never grants access to domain data." + ), + layer="configured", + documentation_types=("admin",), + audience=("system_admin", "tenant_admin", "module_admin"), + translations={ + "de": { + "title": "Richtlinien fuer den Schnellzugriff", + "summary": "Verfuegbarkeit und Standardreihenfolge registrierter kompakter Werkzeuge steuern.", + "body": ( + "Der Katalog folgt den Registrierungen installierter Module. Systemeinstellungen begrenzen Mandanten, " + "Mandanteneinstellungen begrenzen Benutzer. Ein Eintrag kann verfuegbar, gesperrt oder erzwungen sein. " + "Ansichten und Berechtigungen bilden weitere Grenzen; Schnellzugriff erteilt selbst keinen Datenzugriff." + ), + } + }, + metadata={ + "help_contexts": [ + "quick_access.admin.system", + "quick_access.admin.tenant", + "quick_access.field.availability", + "quick_access.field.order", + ] + }, + ), +) + + +manifest = ModuleManifest( + id=MODULE_ID, + name=MODULE_NAME, + version=MODULE_VERSION, + dependencies=("access",), + optional_dependencies=("views", "policy"), + required_capabilities=( + CAPABILITY_AUTH_PRINCIPAL_RESOLVER, + CAPABILITY_AUTH_PERMISSION_EVALUATOR, + ), + provides_interfaces=( + ModuleInterfaceProvider(name="quick_access.runtime", version="1.0.0"), + ModuleInterfaceProvider(name="quick_access.preferences", version="1.0.0"), + ), + permissions=PERMISSIONS, + role_templates=ROLE_TEMPLATES, + route_factory=_router, + frontend=FrontendModule( + module_id=MODULE_ID, + package_name="@govoplan/quick-access-webui", + view_surfaces=( + ViewSurface( + id="quick_access.rail", + module_id=MODULE_ID, + kind="quick_access", + label="Quick Access rail", + description="Optional right-side rail and overlay host.", + order=5, + required=True, + ), + ViewSurface( + id="quick_access.drawer", + module_id=MODULE_ID, + kind="quick_access", + label="Quick Access drawer", + parent_id="quick_access.rail", + order=10, + required=True, + ), + ViewSurface( + id="quick_access.settings.personal", + module_id=MODULE_ID, + kind="section", + label="Personal Quick Access settings", + order=20, + ), + ViewSurface( + id="quick_access.admin.tenant", + module_id=MODULE_ID, + kind="section", + label="Tenant Quick Access policy", + order=30, + ), + ViewSurface( + id="quick_access.admin.system", + module_id=MODULE_ID, + kind="section", + label="System Quick Access policy", + order=40, + ), + ), + ), + migration_spec=MigrationSpec( + module_id=MODULE_ID, + metadata=Base.metadata, + script_location=str(Path(__file__).with_name("migrations") / "versions"), + retirement_supported=True, + retirement_provider=drop_table_retirement_provider( + quick_access_models.QuickAccessProfile, + label="Quick Access preferences", + ), + retirement_notes=( + "Destructive retirement removes presentation preferences only; " + "contributing module data and full-page routes remain unchanged." + ), + ), + uninstall_guard_providers=( + persistent_table_uninstall_guard( + quick_access_models.QuickAccessProfile, + label="Quick Access preferences", + ), + ), + documentation=DOCUMENTATION, + architecture=declared_module_architecture( + layer="runtime_meta", + kind="presentation", + maturity="vertical_slice", + documentation_ref="docs/QUICK_ACCESS.md", + test_ref="tests/test_quick_access.py", + known_limits=( + "The first slice provides four stable categories; administrators cannot yet define additional category identities.", + ), + supported_authority_modes=("native_authoritative",), + owned_concepts=( + "Quick Access profile", + "Quick Access rail", + "Quick Access category ordering", + ), + non_owned_concepts=( + "task", + "calendar event", + "mail message", + "postbox message", + "file", + "authorization decision", + ), + reference_packages=("product.task-focused-workspace",), + migration_docs=("docs/QUICK_ACCESS.md",), + recovery_docs=("docs/QUICK_ACCESS.md",), + security_docs=("docs/QUICK_ACCESS.md",), + operations_docs=("docs/QUICK_ACCESS.md",), + ), +) + + +def get_manifest() -> ModuleManifest: + return manifest + + +__all__ = [ + "MODULE_ID", + "MODULE_VERSION", + "READ_SCOPE", + "SYSTEM_ADMIN_SCOPE", + "TENANT_ADMIN_SCOPE", + "WRITE_SCOPE", + "get_manifest", + "manifest", +] diff --git a/src/govoplan_quick_access/backend/migrations/__init__.py b/src/govoplan_quick_access/backend/migrations/__init__.py new file mode 100644 index 0000000..b78db92 --- /dev/null +++ b/src/govoplan_quick_access/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""Quick Access migrations.""" diff --git a/src/govoplan_quick_access/backend/migrations/versions/9a4e6c2d8f10_v0118_quick_access.py b/src/govoplan_quick_access/backend/migrations/versions/9a4e6c2d8f10_v0118_quick_access.py new file mode 100644 index 0000000..b3b53ee --- /dev/null +++ b/src/govoplan_quick_access/backend/migrations/versions/9a4e6c2d8f10_v0118_quick_access.py @@ -0,0 +1,49 @@ +"""v0.1.18 Quick Access profiles. + +Revision ID: 9a4e6c2d8f10 +Revises: None +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "9a4e6c2d8f10" +down_revision = None +branch_labels = None +depends_on = "4f2a9c8e7b6d" + + +def upgrade() -> None: + op.create_table( + "quick_access_profiles", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("scope_type", sa.String(length=20), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=True), + sa.Column("scope_id", sa.String(length=255), nullable=True), + sa.Column("scope_key", sa.String(length=340), nullable=False), + sa.Column("category_preferences", sa.JSON(), nullable=False), + sa.Column("tool_preferences", sa.JSON(), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("updated_by", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("scope_key", name="uq_quick_access_profile_scope"), + ) + op.create_index("ix_quick_access_profiles_scope_type", "quick_access_profiles", ["scope_type"]) + op.create_index("ix_quick_access_profiles_tenant_id", "quick_access_profiles", ["tenant_id"]) + op.create_index("ix_quick_access_profiles_scope_id", "quick_access_profiles", ["scope_id"]) + op.create_index("ix_quick_access_profiles_scope_key", "quick_access_profiles", ["scope_key"]) + op.create_index( + "ix_quick_access_profiles_scope", + "quick_access_profiles", + ["scope_type", "tenant_id", "scope_id"], + ) + + +def downgrade() -> None: + op.drop_table("quick_access_profiles") diff --git a/src/govoplan_quick_access/backend/migrations/versions/__init__.py b/src/govoplan_quick_access/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..ca0550f --- /dev/null +++ b/src/govoplan_quick_access/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Quick Access migration revisions.""" diff --git a/src/govoplan_quick_access/backend/router.py b/src/govoplan_quick_access/backend/router.py new file mode 100644 index 0000000..e9e17c1 --- /dev/null +++ b/src/govoplan_quick_access/backend/router.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Header, HTTPException, Response, status +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope +from govoplan_core.core.concurrency import ( + ConcurrencyError, + MissingPreconditionError, + RevisionConflictError, +) +from govoplan_core.core.module_entitlements import tenant_module_entitlement_state +from govoplan_core.core.registry import PlatformRegistry +from govoplan_core.db.session import get_session +from govoplan_core.tenancy.scope import Tenant +from govoplan_quick_access.backend.manifest import ( + READ_SCOPE, + SYSTEM_ADMIN_SCOPE, + TENANT_ADMIN_SCOPE, + WRITE_SCOPE, +) +from govoplan_quick_access.backend.schemas import ( + CatalogueResponse, + EffectiveQuickAccessResponse, + ProfileResponse, + ProfileUpdateRequest, +) +from govoplan_quick_access.backend.service import ( + build_catalogue, + get_profile, + profile_response, + resolve_effective, + update_profile, +) + + +def create_router(registry: PlatformRegistry) -> APIRouter: + router = APIRouter(prefix="/quick-access", tags=["quick-access"]) + + @router.get("/catalogue", response_model=CatalogueResponse) + def api_catalogue( + include_all: bool = False, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> CatalogueResponse: + _require(principal, READ_SCOPE) + return _catalogue_for_principal( + session, registry, principal, include_all=include_all + ) + + @router.get("/effective", response_model=EffectiveQuickAccessResponse) + def api_effective( + include_all: bool = False, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> EffectiveQuickAccessResponse: + _require(principal, READ_SCOPE) + catalogue = _catalogue_for_principal( + session, registry, principal, include_all=include_all + ) + return resolve_effective( + session, + registry=registry, + tenant_id=principal.tenant_id, + account_id=principal.account_id, + catalogue=catalogue, + ) + + @router.get("/profiles/me", response_model=ProfileResponse) + def api_my_profile( + response: Response, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> ProfileResponse: + _require(principal, READ_SCOPE) + return _read_profile( + response, + session, + registry, + scope_type="user", + tenant_id=principal.tenant_id, + scope_id=principal.account_id, + principal=principal, + ) + + @router.put("/profiles/me", response_model=ProfileResponse) + def api_update_my_profile( + payload: ProfileUpdateRequest, + response: Response, + if_match: str | None = Header(default=None, alias="If-Match"), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> ProfileResponse: + _require(principal, WRITE_SCOPE) + return _write_profile( + payload, + response, + session, + registry, + principal, + if_match=if_match, + scope_type="user", + tenant_id=principal.tenant_id, + scope_id=principal.account_id, + ) + + @router.get("/profiles/tenant", response_model=ProfileResponse) + def api_tenant_profile( + response: Response, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> ProfileResponse: + _require(principal, TENANT_ADMIN_SCOPE) + return _read_profile( + response, + session, + registry, + scope_type="tenant", + tenant_id=principal.tenant_id, + scope_id=principal.tenant_id, + principal=principal, + ) + + @router.put("/profiles/tenant", response_model=ProfileResponse) + def api_update_tenant_profile( + payload: ProfileUpdateRequest, + response: Response, + if_match: str | None = Header(default=None, alias="If-Match"), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> ProfileResponse: + _require(principal, TENANT_ADMIN_SCOPE) + return _write_profile( + payload, + response, + session, + registry, + principal, + if_match=if_match, + scope_type="tenant", + tenant_id=principal.tenant_id, + scope_id=principal.tenant_id, + ) + + @router.get("/profiles/system", response_model=ProfileResponse) + def api_system_profile( + response: Response, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> ProfileResponse: + _require(principal, SYSTEM_ADMIN_SCOPE) + return _read_profile( + response, + session, + registry, + scope_type="system", + tenant_id=None, + scope_id=None, + principal=principal, + ) + + @router.put("/profiles/system", response_model=ProfileResponse) + def api_update_system_profile( + payload: ProfileUpdateRequest, + response: Response, + if_match: str | None = Header(default=None, alias="If-Match"), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> ProfileResponse: + _require(principal, SYSTEM_ADMIN_SCOPE) + return _write_profile( + payload, + response, + session, + registry, + principal, + if_match=if_match, + scope_type="system", + tenant_id=None, + scope_id=None, + ) + + return router + + +def _read_profile( + response: Response, + session: Session, + registry: PlatformRegistry, + *, + scope_type: str, + tenant_id: str | None, + scope_id: str | None, + principal: ApiPrincipal, +) -> ProfileResponse: + catalogue = _catalogue_for_principal( + session, + registry, + principal, + include_all=scope_type != "user", + ) + result = profile_response( + get_profile( + session, + scope_type=scope_type, + tenant_id=tenant_id, + scope_id=scope_id, + ), + scope_type=scope_type, + tenant_id=tenant_id, + scope_id=scope_id, + catalogue=catalogue, + ) + response.headers["ETag"] = result.etag + return result + + +def _write_profile( + payload: ProfileUpdateRequest, + response: Response, + session: Session, + registry: PlatformRegistry, + principal: ApiPrincipal, + *, + if_match: str | None, + scope_type: str, + tenant_id: str | None, + scope_id: str | None, +) -> ProfileResponse: + try: + row = update_profile( + session, + scope_type=scope_type, + tenant_id=tenant_id, + scope_id=scope_id, + actor_id=principal.account_id, + payload=payload, + if_match=if_match, + ) + session.commit() + except MissingPreconditionError as exc: + session.rollback() + raise HTTPException(status_code=428, detail=exc.as_dict()) from exc + except RevisionConflictError as exc: + session.rollback() + raise HTTPException(status_code=409, detail=exc.as_dict()) from exc + except ConcurrencyError as exc: + session.rollback() + raise HTTPException(status_code=412, detail=str(exc)) from exc + except ValueError as exc: + session.rollback() + raise HTTPException(status_code=400, detail=str(exc)) from exc + result = profile_response( + row, + scope_type=scope_type, + tenant_id=tenant_id, + scope_id=scope_id, + catalogue=_catalogue_for_principal( + session, + registry, + principal, + include_all=scope_type != "user", + ), + ) + response.headers["ETag"] = result.etag + return result + + +def _require(principal: ApiPrincipal, scope: str) -> None: + if not has_scope(principal, scope): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing scope: {scope}", + ) + + +def _catalogue_for_principal( + session: Session, + registry: PlatformRegistry, + principal: ApiPrincipal, + *, + include_all: bool, +) -> CatalogueResponse: + system_admin = has_scope(principal, SYSTEM_ADMIN_SCOPE) + tenant_admin = has_scope(principal, TENANT_ADMIN_SCOPE) + if include_all and not (system_admin or tenant_admin): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Complete Quick Access catalogue requires administration permission.", + ) + if include_all and system_admin: + return build_catalogue(registry) + + tenant = session.get(Tenant, principal.tenant_id) + if tenant is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="The active tenant is unavailable.", + ) + manifests = {manifest.id: manifest for manifest in registry.manifests()} + entitlement = tenant_module_entitlement_state( + tenant.settings or {}, + manifests, + runtime_active_modules=manifests, + ) + return build_catalogue( + registry, + permission_checker=None if include_all else principal.has, + allowed_module_ids=entitlement.effective_modules, + ) + + +__all__ = ["create_router"] diff --git a/src/govoplan_quick_access/backend/schemas.py b/src/govoplan_quick_access/backend/schemas.py new file mode 100644 index 0000000..ccd6e48 --- /dev/null +++ b/src/govoplan_quick_access/backend/schemas.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class PreferenceEntry(BaseModel): + model_config = ConfigDict(extra="forbid") + + enabled: bool | None = None + forced: bool = False + order: int | None = Field(default=None, ge=0, le=100_000) + + +class ProfileUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + base_revision: int = Field(ge=1) + category_preferences: dict[str, PreferenceEntry] = Field(default_factory=dict) + tool_preferences: dict[str, PreferenceEntry] = Field(default_factory=dict) + + +class ProfileResponse(BaseModel): + scope_type: Literal["system", "tenant", "user"] + tenant_id: str | None = None + scope_id: str | None = None + revision: int + etag: str + category_preferences: dict[str, PreferenceEntry] + tool_preferences: dict[str, PreferenceEntry] + stale_category_ids: list[str] = Field(default_factory=list) + stale_tool_ids: list[str] = Field(default_factory=list) + + +class CatalogueCategoryResponse(BaseModel): + id: str + label: str + description: str + icon: str + order: int + + +class CatalogueToolResponse(BaseModel): + id: str + module_id: str + category_id: str + label: str + description: str | None = None + icon: str + surface_id: str + full_page_path: str | None = None + required_all: list[str] + required_any: list[str] + order: int + default_enabled: bool + modes: list[str] + + +class CatalogueResponse(BaseModel): + categories: list[CatalogueCategoryResponse] + tools: list[CatalogueToolResponse] + + +class EffectiveToolResponse(CatalogueToolResponse): + enabled: bool + forced: bool + locked_by: str | None = None + + +class EffectiveCategoryResponse(CatalogueCategoryResponse): + enabled: bool + forced: bool + locked_by: str | None = None + tools: list[EffectiveToolResponse] + + +class EffectiveQuickAccessResponse(BaseModel): + categories: list[EffectiveCategoryResponse] + diagnostics: list[str] = Field(default_factory=list) + + +__all__ = [ + "CatalogueResponse", + "EffectiveQuickAccessResponse", + "PreferenceEntry", + "ProfileResponse", + "ProfileUpdateRequest", +] diff --git a/src/govoplan_quick_access/backend/service.py b/src/govoplan_quick_access/backend/service.py new file mode 100644 index 0000000..9b040df --- /dev/null +++ b/src/govoplan_quick_access/backend/service.py @@ -0,0 +1,381 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Iterable, Mapping + +from sqlalchemy.orm import Session + +from govoplan_core.core.concurrency import ( + RevisionConflictError, + assert_revision_precondition, + strong_resource_etag, +) +from govoplan_core.core.registry import PlatformRegistry +from govoplan_quick_access.backend.db.models import QuickAccessProfile +from govoplan_quick_access.backend.schemas import ( + CatalogueCategoryResponse, + CatalogueResponse, + CatalogueToolResponse, + EffectiveCategoryResponse, + EffectiveQuickAccessResponse, + EffectiveToolResponse, + PreferenceEntry, + ProfileResponse, + ProfileUpdateRequest, +) + + +BASE_CATEGORIES = ( + CatalogueCategoryResponse( + id="work", + label="i18n:govoplan-quick-access.category.work", + description="i18n:govoplan-quick-access.category.work_description", + icon="list-checks", + order=10, + ), + CatalogueCategoryResponse( + id="calendar", + label="i18n:govoplan-quick-access.category.calendar", + description="i18n:govoplan-quick-access.category.calendar_description", + icon="calendar", + order=20, + ), + CatalogueCategoryResponse( + id="messages", + label="i18n:govoplan-quick-access.category.messages", + description="i18n:govoplan-quick-access.category.messages_description", + icon="messages-square", + order=30, + ), + CatalogueCategoryResponse( + id="files", + label="i18n:govoplan-quick-access.category.files", + description="i18n:govoplan-quick-access.category.files_description", + icon="files", + order=40, + ), +) + + +def scope_key(scope_type: str, tenant_id: str | None, scope_id: str | None) -> str: + if scope_type == "system": + return "system:*" + if scope_type == "tenant" and tenant_id: + return f"tenant:{tenant_id}" + if scope_type == "user" and tenant_id and scope_id: + return f"user:{tenant_id}:{scope_id}" + raise ValueError("Invalid Quick Access profile scope") + + +def build_catalogue( + registry: PlatformRegistry, + *, + permission_checker: Callable[[str], bool] | None = None, + allowed_module_ids: Iterable[str] | None = None, +) -> CatalogueResponse: + allowed_modules = ( + None if allowed_module_ids is None else frozenset(allowed_module_ids) + ) + tools: list[CatalogueToolResponse] = [] + for manifest in registry.manifests(): + if allowed_modules is not None and manifest.id not in allowed_modules: + continue + frontend = manifest.frontend + if frontend is None: + continue + for tool in frontend.quick_access_tools: + if permission_checker is not None: + if tool.required_all and not all(permission_checker(scope) for scope in tool.required_all): + continue + if tool.required_any and not any(permission_checker(scope) for scope in tool.required_any): + continue + tools.append( + CatalogueToolResponse( + id=tool.id, + module_id=tool.module_id, + category_id=tool.category_id, + label=tool.label, + description=tool.description, + icon=tool.icon, + surface_id=tool.surface_id, + full_page_path=tool.full_page_path, + required_all=list(tool.required_all), + required_any=list(tool.required_any), + order=tool.order, + default_enabled=tool.default_enabled, + modes=list(tool.modes), + ) + ) + tools.sort(key=lambda item: (item.category_id, item.order, item.id)) + return CatalogueResponse(categories=list(BASE_CATEGORIES), tools=tools) + + +def get_profile( + session: Session, + *, + scope_type: str, + tenant_id: str | None, + scope_id: str | None, +) -> QuickAccessProfile | None: + key = scope_key(scope_type, tenant_id, scope_id) + return session.query(QuickAccessProfile).filter(QuickAccessProfile.scope_key == key).one_or_none() + + +def profile_response( + row: QuickAccessProfile | None, + *, + scope_type: str, + tenant_id: str | None, + scope_id: str | None, + catalogue: CatalogueResponse, +) -> ProfileResponse: + revision = row.revision if row is not None else 1 + resource_id = scope_key(scope_type, tenant_id, scope_id) + category_preferences = _preference_map(row.category_preferences if row else {}) + tool_preferences = _preference_map(row.tool_preferences if row else {}) + category_ids = {item.id for item in catalogue.categories} + tool_ids = {item.id for item in catalogue.tools} + return ProfileResponse( + scope_type=scope_type, + tenant_id=tenant_id, + scope_id=scope_id, + revision=revision, + etag=strong_resource_etag("quick_access_profile", resource_id, revision), + category_preferences=category_preferences, + tool_preferences=tool_preferences, + stale_category_ids=sorted(set(category_preferences) - category_ids), + stale_tool_ids=sorted(set(tool_preferences) - tool_ids), + ) + + +def update_profile( + session: Session, + *, + scope_type: str, + tenant_id: str | None, + scope_id: str | None, + actor_id: str, + payload: ProfileUpdateRequest, + if_match: str | None, +) -> QuickAccessProfile: + key = scope_key(scope_type, tenant_id, scope_id) + assert_revision_precondition( + if_match, + resource_type="quick_access_profile", + resource_id=key, + submitted_base_revision=payload.base_revision, + ) + row = get_profile( + session, + scope_type=scope_type, + tenant_id=tenant_id, + scope_id=scope_id, + ) + current_revision = row.revision if row is not None else 1 + if current_revision != payload.base_revision: + raise RevisionConflictError( + resource_type="quick_access_profile", + resource_id=key, + current_revision=current_revision, + submitted_base_revision=payload.base_revision, + refresh_path=_profile_path(scope_type), + current_etag=strong_resource_etag( + "quick_access_profile", key, current_revision + ), + ) + if scope_type == "user" and any( + entry.forced for entry in ( + *payload.category_preferences.values(), + *payload.tool_preferences.values(), + ) + ): + raise ValueError("Personal Quick Access preferences cannot force items") + category_preferences = _serialized_preferences(payload.category_preferences) + tool_preferences = _serialized_preferences(payload.tool_preferences) + if row is None: + row = QuickAccessProfile( + scope_type=scope_type, + tenant_id=tenant_id, + scope_id=scope_id, + scope_key=key, + revision=2, + created_by=actor_id, + updated_by=actor_id, + category_preferences=category_preferences, + tool_preferences=tool_preferences, + ) + session.add(row) + else: + row.revision += 1 + row.updated_by = actor_id + row.category_preferences = category_preferences + row.tool_preferences = tool_preferences + session.flush() + return row + + +def resolve_effective( + session: Session, + *, + registry: PlatformRegistry, + tenant_id: str, + account_id: str, + permission_checker: Callable[[str], bool] | None = None, + allowed_module_ids: Iterable[str] | None = None, + catalogue: CatalogueResponse | None = None, +) -> EffectiveQuickAccessResponse: + if catalogue is None: + catalogue = build_catalogue( + registry, + permission_checker=permission_checker, + allowed_module_ids=allowed_module_ids, + ) + profiles = ( + ("system", get_profile(session, scope_type="system", tenant_id=None, scope_id=None)), + ("tenant", get_profile(session, scope_type="tenant", tenant_id=tenant_id, scope_id=tenant_id)), + ("user", get_profile(session, scope_type="user", tenant_id=tenant_id, scope_id=account_id)), + ) + diagnostics: list[str] = [] + category_states: dict[str, _EffectiveState] = {} + for category in catalogue.categories: + state = _EffectiveState(enabled=True, order=category.order) + for source, profile in profiles: + entry = _profile_entry(profile, "category_preferences", category.id, diagnostics) + state.apply(entry, source=source) + category_states[category.id] = state + + tools_by_category: dict[str, list[EffectiveToolResponse]] = { + category.id: [] for category in catalogue.categories + } + for tool in catalogue.tools: + state = _EffectiveState(enabled=tool.default_enabled, order=tool.order) + for source, profile in profiles: + entry = _profile_entry(profile, "tool_preferences", tool.id, diagnostics) + state.apply(entry, source=source) + category_state = category_states.get(tool.category_id) + enabled = state.enabled and bool(category_state and category_state.enabled) + tool_payload = tool.model_dump() + tool_payload["order"] = state.order + tools_by_category.setdefault(tool.category_id, []).append( + EffectiveToolResponse( + **tool_payload, + enabled=enabled, + forced=state.forced, + locked_by=state.locked_by, + ) + ) + + categories: list[EffectiveCategoryResponse] = [] + for category in catalogue.categories: + state = category_states[category.id] + tools = sorted( + tools_by_category.get(category.id, []), + key=lambda item: (item.order, item.id), + ) + enabled = state.enabled and any(tool.enabled for tool in tools) + category_payload = category.model_dump() + category_payload["order"] = state.order + categories.append( + EffectiveCategoryResponse( + **category_payload, + enabled=enabled, + forced=state.forced, + locked_by=state.locked_by, + tools=tools, + ) + ) + categories.sort(key=lambda item: (item.order, item.id)) + return EffectiveQuickAccessResponse( + categories=categories, + diagnostics=list(dict.fromkeys(diagnostics)), + ) + + +@dataclass(slots=True) +class _EffectiveState: + enabled: bool + order: int + forced: bool = False + locked_by: str | None = None + + def apply(self, entry: PreferenceEntry | None, *, source: str) -> None: + if entry is None: + return + if entry.order is not None: + self.order = entry.order + if self.locked_by is not None: + return + if entry.enabled is not None: + self.enabled = entry.enabled + if source != "user" and entry.enabled is False: + self.forced = False + self.locked_by = source + elif source != "user" and entry.forced: + self.enabled = True + self.forced = True + self.locked_by = source + + +def _profile_entry( + profile: QuickAccessProfile | None, + field: str, + item_id: str, + diagnostics: list[str], +) -> PreferenceEntry | None: + if profile is None: + return None + raw_map = getattr(profile, field, {}) + if not isinstance(raw_map, Mapping): + diagnostics.append(f"Ignored malformed {field} in {profile.scope_key}.") + return None + raw = raw_map.get(item_id) + if raw is None: + return None + try: + return PreferenceEntry.model_validate(raw) + except Exception: + diagnostics.append(f"Ignored malformed preference for {item_id} in {profile.scope_key}.") + return None + + +def _preference_map(raw: object) -> dict[str, PreferenceEntry]: + if not isinstance(raw, Mapping): + return {} + result: dict[str, PreferenceEntry] = {} + for item_id, value in raw.items(): + try: + result[str(item_id)] = PreferenceEntry.model_validate(value) + except Exception: + continue + return result + + +def _serialized_preferences( + preferences: Mapping[str, PreferenceEntry], +) -> dict[str, dict[str, object]]: + if len(preferences) > 500: + raise ValueError("Quick Access profiles may contain at most 500 preferences") + return { + str(item_id): entry.model_dump(mode="json", exclude_none=True) + for item_id, entry in preferences.items() + if str(item_id).strip() + } + + +def _profile_path(scope_type: str) -> str: + return { + "system": "/api/v1/quick-access/profiles/system", + "tenant": "/api/v1/quick-access/profiles/tenant", + "user": "/api/v1/quick-access/profiles/me", + }[scope_type] + + +__all__ = [ + "BASE_CATEGORIES", + "build_catalogue", + "get_profile", + "profile_response", + "resolve_effective", + "scope_key", + "update_profile", +] diff --git a/src/govoplan_quick_access/py.typed b/src/govoplan_quick_access/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_quick_access/py.typed @@ -0,0 +1 @@ + diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..b1e1bdf --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from alembic.runtime.migration import MigrationContext +from sqlalchemy import create_engine, inspect + +from govoplan_core.db.migrations import migrate_database +from govoplan_quick_access.backend.manifest import get_manifest + + +class QuickAccessMigrationTests(unittest.TestCase): + def test_migration_creates_profile_table_and_head(self) -> None: + with tempfile.TemporaryDirectory( + prefix="govoplan-quick-access-migration-" + ) as directory: + url = f"sqlite:///{Path(directory) / 'quick-access.db'}" + migrate_database( + database_url=url, + enabled_modules=("quick_access",), + manifest_factories=(get_manifest,), + ) + engine = create_engine(url) + try: + with engine.connect() as connection: + self.assertIn( + "9a4e6c2d8f10", + set(MigrationContext.configure(connection).get_current_heads()), + ) + self.assertIn( + "quick_access_profiles", + inspect(connection).get_table_names(), + ) + self.assertEqual( + { + "category_preferences", + "revision", + "scope_id", + "scope_key", + "scope_type", + "tenant_id", + "tool_preferences", + }, + { + column["name"] + for column in inspect(connection).get_columns( + "quick_access_profiles" + ) + if column["name"] not in { + "created_at", + "created_by", + "id", + "updated_at", + "updated_by", + } + }, + ) + finally: + engine.dispose() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_quick_access.py b/tests/test_quick_access.py new file mode 100644 index 0000000..f717d26 --- /dev/null +++ b/tests/test_quick_access.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_core.core.modules import ( + FrontendModule, + ModuleManifest, + QuickAccessTool, +) +from govoplan_core.core.registry import PlatformRegistry +from govoplan_quick_access.backend.db.models import QuickAccessProfile +from govoplan_quick_access.backend.service import build_catalogue, resolve_effective + + +def registry_with_tools() -> PlatformRegistry: + registry = PlatformRegistry() + registry.register( + ModuleManifest( + id="example", + name="Example", + version="test", + frontend=FrontendModule( + module_id="example", + quick_access_tools=( + QuickAccessTool( + id="example.work", + module_id="example", + category_id="work", + label="Example work", + surface_id="example.module", + icon="list-checks", + required_any=("example:item:read",), + ), + ), + ), + ) + ) + return registry + + +class QuickAccessTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:") + QuickAccessProfile.__table__.create(self.engine) + + def tearDown(self) -> None: + self.engine.dispose() + + def test_catalogue_is_derived_from_manifest_tools(self) -> None: + catalogue = build_catalogue(registry_with_tools()) + + self.assertEqual(["work", "calendar", "messages", "files"], [item.id for item in catalogue.categories]) + self.assertEqual("example.work", catalogue.tools[0].id) + + def test_personal_catalogue_excludes_tools_without_permission(self) -> None: + catalogue = build_catalogue( + registry_with_tools(), permission_checker=lambda _scope: False + ) + + self.assertEqual([], catalogue.tools) + + def test_catalogue_excludes_modules_outside_the_tenant_graph(self) -> None: + catalogue = build_catalogue( + registry_with_tools(), + allowed_module_ids=(), + ) + + self.assertEqual([], catalogue.tools) + + def test_upper_scope_block_cannot_be_overridden_by_user(self) -> None: + with Session(self.engine) as session: + session.add_all( + ( + QuickAccessProfile( + scope_type="system", + tenant_id=None, + scope_id=None, + scope_key="system:*", + category_preferences={}, + tool_preferences={"example.work": {"enabled": False}}, + revision=2, + ), + QuickAccessProfile( + scope_type="user", + tenant_id="tenant-1", + scope_id="account-1", + scope_key="user:tenant-1:account-1", + category_preferences={}, + tool_preferences={"example.work": {"enabled": True}}, + revision=2, + ), + ) + ) + session.commit() + + effective = resolve_effective( + session, + registry=registry_with_tools(), + tenant_id="tenant-1", + account_id="account-1", + permission_checker=lambda _scope: True, + ) + + work = next(item for item in effective.categories if item.id == "work") + self.assertFalse(work.enabled) + self.assertFalse(work.tools[0].enabled) + self.assertEqual("system", work.tools[0].locked_by) + + def test_forced_tenant_category_remains_visible(self) -> None: + with Session(self.engine) as session: + session.add( + QuickAccessProfile( + scope_type="tenant", + tenant_id="tenant-1", + scope_id="tenant-1", + scope_key="tenant:tenant-1", + category_preferences={"work": {"enabled": True, "forced": True}}, + tool_preferences={}, + revision=2, + ) + ) + session.commit() + + effective = resolve_effective( + session, + registry=registry_with_tools(), + tenant_id="tenant-1", + account_id="account-1", + permission_checker=lambda _scope: True, + ) + + work = next(item for item in effective.categories if item.id == "work") + self.assertTrue(work.enabled) + self.assertTrue(work.forced) + self.assertEqual("tenant", work.locked_by) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..30bf153 --- /dev/null +++ b/webui/package.json @@ -0,0 +1,28 @@ +{ + "name": "@govoplan/quick-access-webui", + "version": "0.1.18", + "private": true, + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./styles/quick-access.css": "./src/styles/quick-access.css" + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.18", + "lucide-react": "^1.23.0", + "react": ">=19.2.7 <20", + "react-dom": ">=19.2.7 <20", + "react-router": ">=8.3.0 <9" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/webui/src/api/quickAccess.ts b/webui/src/api/quickAccess.ts new file mode 100644 index 0000000..17dd718 --- /dev/null +++ b/webui/src/api/quickAccess.ts @@ -0,0 +1,116 @@ +import { apiFetch, type ApiSettings } from "@govoplan/core-webui"; + +export type QuickAccessPreference = { + enabled?: boolean | null; + forced?: boolean; + order?: number | null; +}; + +export type QuickAccessProfile = { + scope_type: "system" | "tenant" | "user"; + tenant_id?: string | null; + scope_id?: string | null; + revision: number; + etag: string; + category_preferences: Record; + tool_preferences: Record; + stale_category_ids: string[]; + stale_tool_ids: string[]; +}; + +export type QuickAccessCategory = { + id: string; + label: string; + description: string; + icon: string; + order: number; +}; + +export type QuickAccessTool = { + id: string; + module_id: string; + category_id: string; + label: string; + description?: string | null; + icon: string; + surface_id: string; + full_page_path?: string | null; + required_all: string[]; + required_any: string[]; + order: number; + default_enabled: boolean; + modes: string[]; +}; + +export type QuickAccessCatalogue = { + categories: QuickAccessCategory[]; + tools: QuickAccessTool[]; +}; + +export type EffectiveQuickAccessTool = QuickAccessTool & { + enabled: boolean; + forced: boolean; + locked_by?: string | null; +}; + +export type EffectiveQuickAccessCategory = QuickAccessCategory & { + enabled: boolean; + forced: boolean; + locked_by?: string | null; + tools: EffectiveQuickAccessTool[]; +}; + +export type EffectiveQuickAccess = { + categories: EffectiveQuickAccessCategory[]; + diagnostics: string[]; +}; + +const profileEndpoints = { + system: "/api/v1/quick-access/profiles/system", + tenant: "/api/v1/quick-access/profiles/tenant", + me: "/api/v1/quick-access/profiles/me" +} as const; + +export function loadQuickAccessCatalogue( + settings: ApiSettings, + includeAll = false +): Promise { + const query = includeAll ? "?include_all=true" : ""; + return apiFetch(settings, `/api/v1/quick-access/catalogue${query}`); +} + +export function loadEffectiveQuickAccess( + settings: ApiSettings, + includeAll = false +): Promise { + const query = includeAll ? "?include_all=true" : ""; + return apiFetch(settings, `/api/v1/quick-access/effective${query}`); +} + +export function loadQuickAccessProfile( + settings: ApiSettings, + scope: "system" | "tenant" | "me" +): Promise { + return apiFetch(settings, profileEndpoints[scope]); +} + +export function saveQuickAccessProfile( + settings: ApiSettings, + scope: "system" | "tenant" | "me", + profile: QuickAccessProfile, + categoryPreferences: Record, + toolPreferences: Record +): Promise { + return apiFetch(settings, profileEndpoints[scope], { + method: "PUT", + headers: { + "Content-Type": "application/json", + "If-Match": profile.etag + }, + body: JSON.stringify({ + base_revision: profile.revision, + category_preferences: categoryPreferences, + tool_preferences: toolPreferences + }) + }); +} diff --git a/webui/src/components/QuickAccessRail.tsx b/webui/src/components/QuickAccessRail.tsx new file mode 100644 index 0000000..af915fe --- /dev/null +++ b/webui/src/components/QuickAccessRail.tsx @@ -0,0 +1,204 @@ +import { + CalendarDays, + Files, + ListChecks, + MessagesSquare, + Settings2, + X, + type LucideIcon +} from "lucide-react"; +import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; +import { Link, useLocation } from "react-router"; +import { + DismissibleAlert, + IconButton, + LoadingFrame, + usePlatformLanguage, + usePlatformUiCapabilities, + type QuickAccessRailProps, + type QuickAccessToolsUiCapability +} from "@govoplan/core-webui"; +import { + loadEffectiveQuickAccess, + type EffectiveQuickAccess, + type EffectiveQuickAccessCategory +} from "../api/quickAccess"; + + +const iconByCategory: Record = { + work: ListChecks, + calendar: CalendarDays, + messages: MessagesSquare, + files: Files +}; + +export default function QuickAccessRail({ settings, auth, tools }: QuickAccessRailProps) { + const [effective, setEffective] = useState(null); + const [activeCategoryId, setActiveCategoryId] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const drawerRef = useRef(null); + const triggerRef = useRef(null); + const location = useLocation(); + const contributions = usePlatformUiCapabilities("quickAccess.tools"); + const { translateText } = usePlatformLanguage(); + const renderers = useMemo( + () => new Map(contributions.flatMap((entry) => entry.tools).map((tool) => [tool.id, tool])), + [contributions] + ); + const availableToolIds = useMemo(() => new Set(tools.map((tool) => tool.id)), [tools]); + const categories = useMemo( + () => (effective?.categories ?? []).map((category) => ({ + ...category, + tools: category.tools.filter((tool) => tool.enabled && availableToolIds.has(tool.id)) + })).filter((category) => category.enabled && category.tools.length > 0), + [availableToolIds, effective] + ); + const activeCategory = categories.find((category) => category.id === activeCategoryId) ?? null; + + useEffect(() => { + let active = true; + async function load() { + setLoading(true); + try { + const result = await loadEffectiveQuickAccess(settings); + if (!active) return; + setEffective(result); + setError(""); + } catch (reason) { + if (active) setError(reason instanceof Error ? reason.message : "Quick Access could not be loaded."); + } finally { + if (active) setLoading(false); + } + } + void load(); + const handleChanged = () => void load(); + window.addEventListener("govoplan:quick-access-changed", handleChanged); + return () => { + active = false; + window.removeEventListener("govoplan:quick-access-changed", handleChanged); + }; + }, [auth.user.account_id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); + + useEffect(() => { + if (activeCategoryId && !categories.some((category) => category.id === activeCategoryId)) { + setActiveCategoryId(null); + } + }, [activeCategoryId, categories]); + + useEffect(() => { + if (!activeCategoryId) return; + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") closeDrawer(); + } + window.addEventListener("keydown", onKeyDown); + window.requestAnimationFrame(() => { + drawerRef.current + ?.querySelector("button, a[href], input, select, textarea") + ?.focus(); + }); + return () => window.removeEventListener("keydown", onKeyDown); + }, [activeCategoryId]); + + useEffect(() => { + setActiveCategoryId(null); + }, [location.pathname, location.search]); + + if (!loading && categories.length === 0 && !error) return null; + + function closeDrawer(restoreFocus = true) { + setActiveCategoryId(null); + if (restoreFocus) { + window.requestAnimationFrame(() => triggerRef.current?.focus()); + } + } + + function toggleCategory( + category: EffectiveQuickAccessCategory, + event: MouseEvent + ) { + if (activeCategoryId === category.id) { + closeDrawer(); + return; + } + triggerRef.current = event.currentTarget; + setActiveCategoryId(category.id); + } + + return ( + <> + + + {activeCategory ? ( + + ) : null} + + ); +} diff --git a/webui/src/features/settings/QuickAccessSettingsPanel.tsx b/webui/src/features/settings/QuickAccessSettingsPanel.tsx new file mode 100644 index 0000000..4d61682 --- /dev/null +++ b/webui/src/features/settings/QuickAccessSettingsPanel.tsx @@ -0,0 +1,370 @@ +import { ArrowDown, ArrowUp, RotateCcw, Save } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { + Button, + DismissibleAlert, + IconButton, + i18nMessage, + LoadingFrame, + SegmentedControl, + ToggleSwitch, + usePlatformLanguage, + useUnsavedDraftGuard, + type ApiSettings, + type AuthInfo +} from "@govoplan/core-webui"; +import { + loadEffectiveQuickAccess, + loadQuickAccessCatalogue, + loadQuickAccessProfile, + saveQuickAccessProfile, + type EffectiveQuickAccess, + type QuickAccessCatalogue, + type QuickAccessPreference, + type QuickAccessProfile +} from "../../api/quickAccess"; + + +type ProfileScope = "system" | "tenant" | "me"; +type Draft = { + categories: Record; + tools: Record; +}; +type AvailabilityMode = "inherit" | "available" | "blocked" | "forced"; + +const EMPTY_DRAFT: Draft = { categories: {}, tools: {} }; + +export default function QuickAccessSettingsPanel({ + settings, + auth, + scope, + canWrite +}: { + settings: ApiSettings; + auth: AuthInfo; + scope: ProfileScope; + canWrite: boolean; +}) { + const [catalogue, setCatalogue] = useState(null); + const [effective, setEffective] = useState(null); + const [profile, setProfile] = useState(null); + const [draft, setDraft] = useState(EMPTY_DRAFT); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(""); + const [messageTone, setMessageTone] = useState<"success" | "warning">("success"); + const { translateText } = usePlatformLanguage(); + const isPersonal = scope === "me"; + const dirty = useMemo( + () => profile !== null && JSON.stringify(draft) !== JSON.stringify({ + categories: profile.category_preferences, + tools: profile.tool_preferences + }), + [draft, profile] + ); + + useEffect(() => { + void load(); + }, [auth.user.account_id, scope, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); + + useUnsavedDraftGuard({ + dirty, + onSave: save, + onDiscard: reset + }); + + async function load() { + setLoading(true); + setMessage(""); + const includeAll = scope !== "me"; + try { + const [nextCatalogue, nextProfile, nextEffective] = await Promise.all([ + loadQuickAccessCatalogue(settings, includeAll), + loadQuickAccessProfile(settings, scope), + loadEffectiveQuickAccess(settings, includeAll) + ]); + setCatalogue(nextCatalogue); + setProfile(nextProfile); + setEffective(nextEffective); + setDraft({ + categories: nextProfile.category_preferences, + tools: nextProfile.tool_preferences + }); + } catch (reason) { + setMessageTone("warning"); + setMessage(reason instanceof Error ? reason.message : "i18n:govoplan-quick-access.load_failed"); + } finally { + setLoading(false); + } + } + + async function save(): Promise { + if (!profile || !canWrite) return false; + setSaving(true); + setMessage(""); + try { + const next = await saveQuickAccessProfile( + settings, + scope, + profile, + draft.categories, + draft.tools + ); + setProfile(next); + setDraft({ categories: next.category_preferences, tools: next.tool_preferences }); + setMessageTone("success"); + setMessage("i18n:govoplan-quick-access.saved"); + window.dispatchEvent(new CustomEvent("govoplan:quick-access-changed")); + return true; + } catch (reason) { + setMessageTone("warning"); + setMessage(reason instanceof Error ? reason.message : "i18n:govoplan-quick-access.save_failed"); + return false; + } finally { + setSaving(false); + } + } + + function reset() { + if (!profile) return; + setDraft({ + categories: profile.category_preferences, + tools: profile.tool_preferences + }); + } + + function setPreference( + group: "categories" | "tools", + id: string, + preference: QuickAccessPreference | null + ) { + setDraft((current) => { + const nextGroup = { ...current[group] }; + if (preference === null) delete nextGroup[id]; + else nextGroup[id] = preference; + return { ...current, [group]: nextGroup }; + }); + } + + function move( + group: "categories" | "tools", + ids: string[], + id: string, + direction: -1 | 1 + ) { + const currentIndex = ids.indexOf(id); + const targetIndex = currentIndex + direction; + if (currentIndex < 0 || targetIndex < 0 || targetIndex >= ids.length) return; + const reordered = [...ids]; + [reordered[currentIndex], reordered[targetIndex]] = [reordered[targetIndex], reordered[currentIndex]]; + setDraft((current) => ({ + ...current, + [group]: Object.fromEntries(reordered.map((itemId, index) => [ + itemId, + { ...(current[group][itemId] ?? {}), order: (index + 1) * 10 } + ])) + })); + } + + const categories = useMemo( + () => [...(catalogue?.categories ?? [])].sort((left, right) => + (draft.categories[left.id]?.order ?? left.order) - (draft.categories[right.id]?.order ?? right.order) + ), + [catalogue, draft.categories] + ); + const tools = useMemo( + () => [...(catalogue?.tools ?? [])].sort((left, right) => { + if (left.category_id !== right.category_id) return left.category_id.localeCompare(right.category_id); + return (draft.tools[left.id]?.order ?? left.order) - (draft.tools[right.id]?.order ?? right.order); + }), + [catalogue, draft.tools] + ); + const lockedCategories = new Map( + (effective?.categories ?? []).filter((item) => item.locked_by).map((item) => [item.id, item.locked_by]) + ); + const lockedTools = new Map( + (effective?.categories ?? []).flatMap((category) => category.tools).filter((item) => item.locked_by).map((item) => [item.id, item.locked_by]) + ); + const categoryIds = categories.map((item) => item.id); + + return ( +
+
+
+

i18n:govoplan-quick-access.settings_title

+

{isPersonal ? "i18n:govoplan-quick-access.personal_help" : "i18n:govoplan-quick-access.admin_help"}

+
+
+ + +
+
+ + {message ? {message} : null} + {profile && (profile.stale_category_ids.length || profile.stale_tool_ids.length) ? ( + + i18n:govoplan-quick-access.stale_preferences_retained + + ) : null} + + +
+

i18n:govoplan-quick-access.categories

+
+ {categories.map((category, index) => ( + setPreference("categories", category.id, value)} + onMoveUp={() => move("categories", categoryIds, category.id, -1)} + onMoveDown={() => move("categories", categoryIds, category.id, 1)} + first={index === 0} + last={index === categories.length - 1} + /> + ))} +
+
+ +
+

i18n:govoplan-quick-access.registered_tools

+
+ {categories.flatMap((category) => { + const categoryTools = tools.filter((tool) => tool.category_id === category.id); + const toolIds = categoryTools.map((tool) => tool.id); + return categoryTools.map((tool, index) => ( + setPreference("tools", tool.id, value)} + onMoveUp={() => move("tools", toolIds, tool.id, -1)} + onMoveDown={() => move("tools", toolIds, tool.id, 1)} + first={index === 0} + last={index === categoryTools.length - 1} + /> + )); + })} + {!tools.length ?

i18n:govoplan-quick-access.no_registered_tools

: null} +
+
+
+
+ ); +} + + +function PreferenceRow({ + id, + label, + description, + preference, + defaultEnabled = true, + isPersonal, + lockedBy, + disabled, + onChange, + onMoveUp, + onMoveDown, + first, + last +}: { + id: string; + label: string; + description: string; + preference?: QuickAccessPreference; + defaultEnabled?: boolean; + isPersonal: boolean; + lockedBy?: string | null; + disabled: boolean; + onChange: (value: QuickAccessPreference | null) => void; + onMoveUp: () => void; + onMoveDown: () => void; + first: boolean; + last: boolean; +}) { + const mode = preferenceMode(preference); + const rowDisabled = disabled || Boolean(lockedBy); + return ( +
+
+ {label} + {description} + {lockedBy ? ( + + {i18nMessage("i18n:govoplan-quick-access.locked_by_value", { + value0: lockedBy + })} + + ) : null} +
+
+ {isPersonal ? ( + onChange({ ...preference, enabled })} + /> + ) : ( + onChange(preferenceForMode(value, preference))} + options={[ + { id: "inherit", label: "i18n:govoplan-quick-access.inherit" }, + { id: "available", label: "i18n:govoplan-quick-access.available" }, + { id: "blocked", label: "i18n:govoplan-quick-access.blocked" }, + { id: "forced", label: "i18n:govoplan-quick-access.forced" } + ]} + /> + )} +
+ } variant="ghost" disabled={disabled || first} onClick={onMoveUp} /> + } variant="ghost" disabled={disabled || last} onClick={onMoveDown} /> +
+
+
+ ); +} + + +function preferenceMode(preference?: QuickAccessPreference): AvailabilityMode { + if (!preference || preference.enabled === null || preference.enabled === undefined) return "inherit"; + if (preference.enabled === false) return "blocked"; + return preference.forced ? "forced" : "available"; +} + +function editableConstraintSource( + scope: ProfileScope, + source: string | null | undefined +): string | null { + if (!source || scope === "system") return null; + if (scope === "tenant") return source === "system" ? source : null; + return source; +} + +function preferenceForMode( + mode: AvailabilityMode, + current?: QuickAccessPreference +): QuickAccessPreference | null { + if (mode === "inherit") return current?.order === undefined ? null : { order: current.order }; + if (mode === "blocked") return { ...current, enabled: false, forced: false }; + if (mode === "forced") return { ...current, enabled: true, forced: true }; + return { ...current, enabled: true, forced: false }; +} diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts new file mode 100644 index 0000000..9963f8a --- /dev/null +++ b/webui/src/i18n/generatedTranslations.ts @@ -0,0 +1,78 @@ +import type { PlatformTranslations } from "@govoplan/core-webui"; + +export const generatedTranslations: PlatformTranslations = { + de: { + "i18n:govoplan-quick-access.quick_access": "Schnellzugriff", + "i18n:govoplan-quick-access.configure": "Schnellzugriff konfigurieren", + "i18n:govoplan-quick-access.close": "Schließen", + "i18n:govoplan-quick-access.loading": "Schnellzugriff wird geladen", + "i18n:govoplan-quick-access.open_full_page": "Vollständige Seite öffnen", + "i18n:govoplan-quick-access.compact_view_unavailable": "Die kompakte Ansicht ist nicht verfügbar. Verwenden Sie die vollständige Seite.", + "i18n:govoplan-quick-access.settings_title": "Schnellzugriff", + "i18n:govoplan-quick-access.personal_help": "Werkzeuge auswählen und ordnen, die neben der aktuellen Arbeit verfügbar bleiben.", + "i18n:govoplan-quick-access.admin_help": "Verfügbarkeit, erzwungene Einträge und Standardreihenfolge für nachgeordnete Ebenen festlegen.", + "i18n:govoplan-quick-access.categories": "Kategorien", + "i18n:govoplan-quick-access.registered_tools": "Registrierte Werkzeuge", + "i18n:govoplan-quick-access.no_registered_tools": "Derzeit registriert kein aktiviertes Modul ein Schnellzugriffswerkzeug.", + "i18n:govoplan-quick-access.inherit": "Erben", + "i18n:govoplan-quick-access.available": "Verfügbar", + "i18n:govoplan-quick-access.blocked": "Gesperrt", + "i18n:govoplan-quick-access.forced": "Erzwungen", + "i18n:govoplan-quick-access.hidden": "Ausgeblendet", + "i18n:govoplan-quick-access.visible": "Sichtbar", + "i18n:govoplan-quick-access.move_up": "Nach oben", + "i18n:govoplan-quick-access.move_down": "Nach unten", + "i18n:govoplan-quick-access.save": "Speichern", + "i18n:govoplan-quick-access.discard": "Verwerfen", + "i18n:govoplan-quick-access.saved": "Die Schnellzugriffseinstellungen wurden gespeichert.", + "i18n:govoplan-quick-access.load_failed": "Die Schnellzugriffseinstellungen konnten nicht geladen werden.", + "i18n:govoplan-quick-access.save_failed": "Die Schnellzugriffseinstellungen konnten nicht gespeichert werden.", + "i18n:govoplan-quick-access.stale_preferences_retained": "Einstellungen für derzeit nicht verfügbare Modulwerkzeuge bleiben erhalten und gelten wieder, sobald die Werkzeuge zurückkehren.", + "i18n:govoplan-quick-access.category.work": "Arbeit", + "i18n:govoplan-quick-access.category.work_description": "Aufgaben, Freigaben, Übergaben und andere zu bearbeitende Arbeit.", + "i18n:govoplan-quick-access.category.calendar": "Kalender", + "i18n:govoplan-quick-access.category.calendar_description": "Termine, Verfügbarkeit und zeitgebundene Verpflichtungen.", + "i18n:govoplan-quick-access.category.messages": "Nachrichten", + "i18n:govoplan-quick-access.category.messages_description": "Mail, Postfach und künftige Gesprächskanäle in einer Einblendung.", + "i18n:govoplan-quick-access.category.files": "Dateien", + "i18n:govoplan-quick-access.category.files_description": "Aktuelle und kontextbezogene Dateien, ohne die laufende Aufgabe zu verlassen.", + "i18n:govoplan-quick-access.locked_by_value": "Durch {value0} festgelegt" + }, + en: { + "i18n:govoplan-quick-access.quick_access": "Quick Access", + "i18n:govoplan-quick-access.configure": "Configure Quick Access", + "i18n:govoplan-quick-access.close": "Close", + "i18n:govoplan-quick-access.loading": "Loading Quick Access", + "i18n:govoplan-quick-access.open_full_page": "Open full page", + "i18n:govoplan-quick-access.compact_view_unavailable": "The compact view is unavailable. Use the full page.", + "i18n:govoplan-quick-access.settings_title": "Quick Access", + "i18n:govoplan-quick-access.personal_help": "Choose and order the tools kept beside your current work.", + "i18n:govoplan-quick-access.admin_help": "Set availability, forced items, and default ordering for lower scopes.", + "i18n:govoplan-quick-access.categories": "Categories", + "i18n:govoplan-quick-access.registered_tools": "Registered tools", + "i18n:govoplan-quick-access.no_registered_tools": "No enabled module currently registers a Quick Access tool.", + "i18n:govoplan-quick-access.inherit": "Inherit", + "i18n:govoplan-quick-access.available": "Available", + "i18n:govoplan-quick-access.blocked": "Blocked", + "i18n:govoplan-quick-access.forced": "Forced", + "i18n:govoplan-quick-access.hidden": "Hidden", + "i18n:govoplan-quick-access.visible": "Visible", + "i18n:govoplan-quick-access.move_up": "Move up", + "i18n:govoplan-quick-access.move_down": "Move down", + "i18n:govoplan-quick-access.save": "Save", + "i18n:govoplan-quick-access.discard": "Discard", + "i18n:govoplan-quick-access.saved": "Quick Access settings were saved.", + "i18n:govoplan-quick-access.load_failed": "Quick Access settings could not be loaded.", + "i18n:govoplan-quick-access.save_failed": "Quick Access settings could not be saved.", + "i18n:govoplan-quick-access.stale_preferences_retained": "Preferences for currently unavailable module tools are retained and will apply if those tools return.", + "i18n:govoplan-quick-access.category.work": "Work", + "i18n:govoplan-quick-access.category.work_description": "Tasks, approvals, handoffs, and other work requiring attention.", + "i18n:govoplan-quick-access.category.calendar": "Calendar", + "i18n:govoplan-quick-access.category.calendar_description": "Schedule, availability, and time-bound commitments.", + "i18n:govoplan-quick-access.category.messages": "Messages", + "i18n:govoplan-quick-access.category.messages_description": "Mail, Postbox, and future conversational channels in one overlay.", + "i18n:govoplan-quick-access.category.files": "Files", + "i18n:govoplan-quick-access.category.files_description": "Recent and contextual files without leaving the current task.", + "i18n:govoplan-quick-access.locked_by_value": "Set by {value0}" + } +}; diff --git a/webui/src/index.ts b/webui/src/index.ts new file mode 100644 index 0000000..1c5321a --- /dev/null +++ b/webui/src/index.ts @@ -0,0 +1,2 @@ +export { default, quickAccessModule } from "./module"; +export * from "./api/quickAccess"; diff --git a/webui/src/module.ts b/webui/src/module.ts new file mode 100644 index 0000000..6333f7a --- /dev/null +++ b/webui/src/module.ts @@ -0,0 +1,98 @@ +import { createElement, lazy } from "react"; +import type { + AdminSectionsUiCapability, + PlatformWebModule, + QuickAccessRuntimeUiCapability, + SettingsSectionsUiCapability +} from "@govoplan/core-webui"; +import QuickAccessRail from "./components/QuickAccessRail"; +import { generatedTranslations } from "./i18n/generatedTranslations"; +import "./styles/quick-access.css"; + + +const QuickAccessSettingsPanel = lazy( + () => import("./features/settings/QuickAccessSettingsPanel") +); + +const runtime: QuickAccessRuntimeUiCapability = { + Rail: QuickAccessRail +}; + +const settingsSections: SettingsSectionsUiCapability = { + sections: [ + { + id: "quick-access", + label: "i18n:govoplan-quick-access.quick_access", + group: "ui", + order: 30, + surfaceId: "quick_access.settings.personal", + allOf: ["quick_access:profile:read"], + render: ({ settings, auth }) => createElement(QuickAccessSettingsPanel, { + settings, + auth, + scope: "me", + canWrite: auth.scopes.includes("quick_access:profile:write") + }) + } + ] +}; + +const adminSections: AdminSectionsUiCapability = { + sections: [ + { + id: "system-quick-access", + moduleId: "quick_access", + kind: "settings", + label: "i18n:govoplan-quick-access.quick_access", + group: "SYSTEM", + order: 31, + surfaceId: "quick_access.admin.system", + allOf: ["quick_access:system:admin"], + render: ({ settings, auth }) => createElement(QuickAccessSettingsPanel, { + settings, + auth, + scope: "system", + canWrite: true + }) + }, + { + id: "tenant-quick-access", + moduleId: "quick_access", + kind: "settings", + label: "i18n:govoplan-quick-access.quick_access", + group: "TENANT", + order: 31, + surfaceId: "quick_access.admin.tenant", + allOf: ["quick_access:profile:admin"], + render: ({ settings, auth }) => createElement(QuickAccessSettingsPanel, { + settings, + auth, + scope: "tenant", + canWrite: true + }) + } + ] +}; + +export const quickAccessModule: PlatformWebModule = { + id: "quick_access", + label: "i18n:govoplan-quick-access.quick_access", + version: "0.1.18", + dependencies: ["access"], + optionalDependencies: ["views", "policy"], + translations: generatedTranslations, + viewSurfaces: [ + { id: "quick_access.rail", moduleId: "quick_access", kind: "quick_access", label: "i18n:govoplan-quick-access.quick_access", order: 5, required: true }, + { id: "quick_access.drawer", moduleId: "quick_access", kind: "quick_access", label: "i18n:govoplan-quick-access.quick_access", parentId: "quick_access.rail", order: 10, required: true }, + { id: "quick_access.settings.personal", moduleId: "quick_access", kind: "section", label: "i18n:govoplan-quick-access.quick_access", order: 20 }, + { id: "quick_access.admin.tenant", moduleId: "quick_access", kind: "section", label: "i18n:govoplan-quick-access.quick_access", order: 30 }, + { id: "quick_access.admin.system", moduleId: "quick_access", kind: "section", label: "i18n:govoplan-quick-access.quick_access", order: 40 } + ], + uiCapabilities: { + "quickAccess.runtime": runtime, + "settings.sections": settingsSections, + "admin.sections": adminSections + } +}; + +export default quickAccessModule; diff --git a/webui/src/styles/quick-access.css b/webui/src/styles/quick-access.css new file mode 100644 index 0000000..f7bf475 --- /dev/null +++ b/webui/src/styles/quick-access.css @@ -0,0 +1,265 @@ +.quick-access-rail, +.quick-access-drawer, +.quick-access-rail *, +.quick-access-drawer * { + box-sizing: border-box; +} + +.quick-access-rail { + position: relative; + z-index: 36; + width: 48px; + min-width: 48px; + height: 100vh; + min-height: 0; + display: flex; + flex-direction: column; + align-items: stretch; + border-left: var(--border-line); + background: var(--panel-header); +} + +.quick-access-rail-tools { + min-height: 0; + display: grid; + align-content: start; + gap: 3px; + overflow-y: auto; + padding: 6px 4px; +} + +.quick-access-rail-button { + width: 40px; + height: 40px; + display: grid; + place-items: center; + border: 0; + border-radius: 4px; + color: var(--muted); + background: transparent; + cursor: pointer; + text-decoration: none; +} + +.quick-access-rail-button:hover { + color: var(--text-strong); + background: var(--sidebar-hover-bg); +} + +.quick-access-rail-button.active { + color: var(--accent); + background: var(--accent-soft); +} + +.quick-access-settings-link { + margin: auto 4px 6px; +} + +.quick-access-rail-loading { + width: 18px; + height: 18px; + margin: 11px auto; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: quick-access-spin 0.8s linear infinite; +} + +.quick-access-drawer { + position: fixed; + z-index: 35; + top: 0; + right: 48px; + width: min(420px, calc(100vw - 104px)); + height: 100vh; + min-height: 0; + display: flex; + flex-direction: column; + border-left: var(--border-line); + box-shadow: -12px 0 30px rgb(0 0 0 / 18%); + background: var(--panel); +} + +.quick-access-drawer-header { + min-height: 64px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: var(--border-line); + padding: 10px 12px 10px 16px; + background: var(--panel-header); +} + +.quick-access-drawer-header > div:first-child, +.quick-access-tool-heading > div:first-child, +.quick-access-preference-copy { + min-width: 0; + display: grid; + gap: 3px; +} + +.quick-access-drawer-header small, +.quick-access-tool-heading small, +.quick-access-preference-copy small, +.quick-access-settings-heading p, +.quick-access-tool-unavailable, +.quick-access-empty { + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.4; +} + +.quick-access-drawer-content { + min-height: 0; + flex: 1; + overflow-y: auto; +} + +.quick-access-tool { + display: grid; + gap: 12px; + border-bottom: var(--border-line); + padding: 14px 16px 16px; +} + +.quick-access-tool-heading, +.quick-access-settings-heading, +.quick-access-preference-row, +.quick-access-preference-controls, +.quick-access-settings-actions, +.quick-access-order-buttons { + display: flex; + align-items: center; + gap: 8px; +} + +.quick-access-tool-heading, +.quick-access-settings-heading, +.quick-access-preference-row { + justify-content: space-between; +} + +.quick-access-tool-heading > a { + flex: 0 0 auto; + color: var(--accent); + font-size: 12px; +} + +.quick-access-settings { + min-width: 0; + display: grid; + gap: 16px; +} + +.quick-access-settings-heading { + align-items: flex-start; +} + +.quick-access-settings-heading h2, +.quick-access-settings-section h3 { + margin: 0; + color: var(--text-strong); +} + +.quick-access-settings-section { + display: grid; + gap: 8px; +} + +.quick-access-settings-section h3 { + font-size: 14px; +} + +.quick-access-preference-list { + border: var(--border-line); + background: var(--panel); +} + +.quick-access-preference-row { + min-height: 62px; + padding: 8px 10px 8px 12px; +} + +.quick-access-preference-row + .quick-access-preference-row { + border-top: var(--border-line); +} + +.quick-access-preference-row:hover { + background: var(--sidebar-hover-bg); +} + +.quick-access-preference-row.is-locked { + background: var(--panel-soft); +} + +.quick-access-preference-controls { + flex: 0 0 auto; +} + +.quick-access-preference-controls .segmented-control-option { + min-height: 30px; + padding: 4px 8px; + font-size: 12px; +} + +.quick-access-order-buttons { + gap: 2px; +} + +.quick-access-empty { + padding: 16px; +} + +@keyframes quick-access-spin { + to { transform: rotate(360deg); } +} + +@media (max-width: 760px) { + .quick-access-rail { + position: fixed; + z-index: 36; + right: 0; + bottom: 0; + left: 0; + width: 100%; + min-width: 0; + height: 50px; + flex-direction: row; + border-top: var(--border-line); + border-left: 0; + } + + .quick-access-rail-tools { + display: flex; + flex: 1; + overflow-x: auto; + overflow-y: hidden; + } + + .quick-access-settings-link { + margin: 4px 6px 4px auto; + } + + .quick-access-drawer { + right: 0; + bottom: 50px; + top: auto; + width: 100%; + height: min(70vh, 680px); + border-top: var(--border-line); + border-left: 0; + box-shadow: 0 -12px 30px rgb(0 0 0 / 18%); + } + + .quick-access-preference-row { + align-items: stretch; + flex-direction: column; + } + + .quick-access-preference-controls { + justify-content: space-between; + overflow-x: auto; + } +}