Initialize configurable Quick Access module
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -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:
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
@@ -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?
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## Issue
|
||||||
|
|
||||||
|
Closes #
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Follow-up issues:
|
||||||
@@ -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
|
||||||
+13
@@ -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/
|
||||||
@@ -1,3 +1,15 @@
|
|||||||
# govoplan-quick-access
|
# govoplan-quick-access
|
||||||
|
|
||||||
Configurable task-local Quick Access rail for GovOPlaN
|
<!-- govoplan-repository-type:start -->
|
||||||
|
**Repository type:** module (presentation).
|
||||||
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
|
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).
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""GovOPlaN Quick Access module."""
|
||||||
|
|
||||||
|
__version__ = "0.1.18"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Quick Access backend."""
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from govoplan_quick_access.backend.db.models import QuickAccessProfile
|
||||||
|
|
||||||
|
__all__ = ["QuickAccessProfile"]
|
||||||
@@ -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"]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Quick Access migrations."""
|
||||||
+49
@@ -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")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Quick Access migration revisions."""
|
||||||
@@ -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"]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, QuickAccessPreference>;
|
||||||
|
tool_preferences: Record<string, QuickAccessPreference>;
|
||||||
|
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<QuickAccessCatalogue> {
|
||||||
|
const query = includeAll ? "?include_all=true" : "";
|
||||||
|
return apiFetch(settings, `/api/v1/quick-access/catalogue${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadEffectiveQuickAccess(
|
||||||
|
settings: ApiSettings,
|
||||||
|
includeAll = false
|
||||||
|
): Promise<EffectiveQuickAccess> {
|
||||||
|
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<QuickAccessProfile> {
|
||||||
|
return apiFetch(settings, profileEndpoints[scope]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveQuickAccessProfile(
|
||||||
|
settings: ApiSettings,
|
||||||
|
scope: "system" | "tenant" | "me",
|
||||||
|
profile: QuickAccessProfile,
|
||||||
|
categoryPreferences: Record<string, QuickAccessPreference>,
|
||||||
|
toolPreferences: Record<string, QuickAccessPreference>
|
||||||
|
): Promise<QuickAccessProfile> {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<string, LucideIcon> = {
|
||||||
|
work: ListChecks,
|
||||||
|
calendar: CalendarDays,
|
||||||
|
messages: MessagesSquare,
|
||||||
|
files: Files
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function QuickAccessRail({ settings, auth, tools }: QuickAccessRailProps) {
|
||||||
|
const [effective, setEffective] = useState<EffectiveQuickAccess | null>(null);
|
||||||
|
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const drawerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const location = useLocation();
|
||||||
|
const contributions = usePlatformUiCapabilities<QuickAccessToolsUiCapability>("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<HTMLElement>("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<HTMLButtonElement>
|
||||||
|
) {
|
||||||
|
if (activeCategoryId === category.id) {
|
||||||
|
closeDrawer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
triggerRef.current = event.currentTarget;
|
||||||
|
setActiveCategoryId(category.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<aside className="quick-access-rail" aria-label="i18n:govoplan-quick-access.quick_access">
|
||||||
|
<div className="quick-access-rail-tools">
|
||||||
|
{loading ? <span className="quick-access-rail-loading" aria-label="i18n:govoplan-quick-access.loading" /> : null}
|
||||||
|
{categories.map((category) => {
|
||||||
|
const Icon = iconByCategory[category.id] ?? ListChecks;
|
||||||
|
const label = translateText(category.label);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={category.id}
|
||||||
|
type="button"
|
||||||
|
className={`quick-access-rail-button${activeCategoryId === category.id ? " active" : ""}`}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
aria-expanded={activeCategoryId === category.id}
|
||||||
|
aria-controls="quick-access-drawer"
|
||||||
|
onClick={(event) => toggleCategory(category, event)}
|
||||||
|
>
|
||||||
|
<Icon size={20} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
className="quick-access-rail-button quick-access-settings-link"
|
||||||
|
to="/settings?section=quick-access"
|
||||||
|
title={translateText("i18n:govoplan-quick-access.configure")}
|
||||||
|
aria-label={translateText("i18n:govoplan-quick-access.configure")}
|
||||||
|
>
|
||||||
|
<Settings2 size={19} aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{activeCategory ? (
|
||||||
|
<div
|
||||||
|
id="quick-access-drawer"
|
||||||
|
className="quick-access-drawer"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="false"
|
||||||
|
aria-label={translateText(activeCategory.label)}
|
||||||
|
ref={drawerRef}
|
||||||
|
>
|
||||||
|
<header className="quick-access-drawer-header">
|
||||||
|
<div>
|
||||||
|
<strong>{translateText(activeCategory.label)}</strong>
|
||||||
|
<small>{translateText(activeCategory.description)}</small>
|
||||||
|
</div>
|
||||||
|
<IconButton label="i18n:govoplan-quick-access.close" icon={<X size={18} />} variant="ghost" onClick={() => closeDrawer()} />
|
||||||
|
</header>
|
||||||
|
<div className="quick-access-drawer-content">
|
||||||
|
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||||
|
<LoadingFrame loading={loading} label="i18n:govoplan-quick-access.loading">
|
||||||
|
{activeCategory.tools.map((tool) => {
|
||||||
|
const renderer = renderers.get(tool.id);
|
||||||
|
return (
|
||||||
|
<section className="quick-access-tool" key={tool.id} data-tool-id={tool.id}>
|
||||||
|
<div className="quick-access-tool-heading">
|
||||||
|
<div>
|
||||||
|
<strong>{translateText(tool.label)}</strong>
|
||||||
|
{tool.description ? <small>{translateText(tool.description)}</small> : null}
|
||||||
|
</div>
|
||||||
|
{tool.full_page_path ? <Link to={tool.full_page_path} onClick={() => closeDrawer(false)}>i18n:govoplan-quick-access.open_full_page</Link> : null}
|
||||||
|
</div>
|
||||||
|
{renderer
|
||||||
|
? renderer.render({ settings, auth, close: () => closeDrawer(), active: true })
|
||||||
|
: <p className="quick-access-tool-unavailable">i18n:govoplan-quick-access.compact_view_unavailable</p>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</LoadingFrame>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, QuickAccessPreference>;
|
||||||
|
tools: Record<string, QuickAccessPreference>;
|
||||||
|
};
|
||||||
|
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<QuickAccessCatalogue | null>(null);
|
||||||
|
const [effective, setEffective] = useState<EffectiveQuickAccess | null>(null);
|
||||||
|
const [profile, setProfile] = useState<QuickAccessProfile | null>(null);
|
||||||
|
const [draft, setDraft] = useState<Draft>(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<boolean> {
|
||||||
|
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 (
|
||||||
|
<div className="quick-access-settings">
|
||||||
|
<div className="quick-access-settings-heading">
|
||||||
|
<div>
|
||||||
|
<h2>i18n:govoplan-quick-access.settings_title</h2>
|
||||||
|
<p>{isPersonal ? "i18n:govoplan-quick-access.personal_help" : "i18n:govoplan-quick-access.admin_help"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="quick-access-settings-actions">
|
||||||
|
<Button variant="secondary" icon={<RotateCcw size={16} />} disabled={!dirty || saving} onClick={reset}>i18n:govoplan-quick-access.discard</Button>
|
||||||
|
<Button variant="primary" icon={<Save size={16} />} disabled={!dirty || saving || !canWrite} onClick={() => void save()}>i18n:govoplan-quick-access.save</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message ? <DismissibleAlert tone={messageTone} resetKey={message}>{message}</DismissibleAlert> : null}
|
||||||
|
{profile && (profile.stale_category_ids.length || profile.stale_tool_ids.length) ? (
|
||||||
|
<DismissibleAlert tone="info" resetKey={`${profile.stale_category_ids.join(",")}:${profile.stale_tool_ids.join(",")}`}>
|
||||||
|
i18n:govoplan-quick-access.stale_preferences_retained
|
||||||
|
</DismissibleAlert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<LoadingFrame loading={loading} label="i18n:govoplan-quick-access.loading">
|
||||||
|
<section className="quick-access-settings-section">
|
||||||
|
<h3>i18n:govoplan-quick-access.categories</h3>
|
||||||
|
<div className="quick-access-preference-list">
|
||||||
|
{categories.map((category, index) => (
|
||||||
|
<PreferenceRow
|
||||||
|
key={category.id}
|
||||||
|
id={category.id}
|
||||||
|
label={translateText(category.label)}
|
||||||
|
description={translateText(category.description)}
|
||||||
|
preference={draft.categories[category.id]}
|
||||||
|
isPersonal={isPersonal}
|
||||||
|
lockedBy={editableConstraintSource(scope, lockedCategories.get(category.id))}
|
||||||
|
disabled={!canWrite || saving}
|
||||||
|
onChange={(value) => 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}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="quick-access-settings-section">
|
||||||
|
<h3>i18n:govoplan-quick-access.registered_tools</h3>
|
||||||
|
<div className="quick-access-preference-list">
|
||||||
|
{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) => (
|
||||||
|
<PreferenceRow
|
||||||
|
key={tool.id}
|
||||||
|
id={tool.id}
|
||||||
|
label={translateText(tool.label)}
|
||||||
|
description={`${translateText(category.label)} · ${tool.module_id}${tool.description ? ` · ${translateText(tool.description)}` : ""}`}
|
||||||
|
preference={draft.tools[tool.id]}
|
||||||
|
defaultEnabled={tool.default_enabled}
|
||||||
|
isPersonal={isPersonal}
|
||||||
|
lockedBy={editableConstraintSource(scope, lockedTools.get(tool.id))}
|
||||||
|
disabled={!canWrite || saving}
|
||||||
|
onChange={(value) => 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 ? <p className="quick-access-empty">i18n:govoplan-quick-access.no_registered_tools</p> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</LoadingFrame>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={`quick-access-preference-row${lockedBy ? " is-locked" : ""}`} data-preference-id={id}>
|
||||||
|
<div className="quick-access-preference-copy">
|
||||||
|
<strong>{label}</strong>
|
||||||
|
<small>{description}</small>
|
||||||
|
{lockedBy ? (
|
||||||
|
<small>
|
||||||
|
{i18nMessage("i18n:govoplan-quick-access.locked_by_value", {
|
||||||
|
value0: lockedBy
|
||||||
|
})}
|
||||||
|
</small>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="quick-access-preference-controls">
|
||||||
|
{isPersonal ? (
|
||||||
|
<ToggleSwitch
|
||||||
|
label={label}
|
||||||
|
inactiveLabel="i18n:govoplan-quick-access.hidden"
|
||||||
|
activeLabel="i18n:govoplan-quick-access.visible"
|
||||||
|
checked={preference?.enabled ?? defaultEnabled}
|
||||||
|
disabled={rowDisabled}
|
||||||
|
onChange={(enabled) => onChange({ ...preference, enabled })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SegmentedControl
|
||||||
|
ariaLabel={`${label} availability`}
|
||||||
|
role="group"
|
||||||
|
value={mode}
|
||||||
|
disabled={rowDisabled}
|
||||||
|
onChange={(value) => 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" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="quick-access-order-buttons">
|
||||||
|
<IconButton label="i18n:govoplan-quick-access.move_up" icon={<ArrowUp size={16} />} variant="ghost" disabled={disabled || first} onClick={onMoveUp} />
|
||||||
|
<IconButton label="i18n:govoplan-quick-access.move_down" icon={<ArrowDown size={16} />} variant="ghost" disabled={disabled || last} onClick={onMoveDown} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default, quickAccessModule } from "./module";
|
||||||
|
export * from "./api/quickAccess";
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user