feat(webui): complete files quick access

This commit is contained in:
2026-08-19 19:48:56 +02:00
parent ad55d47645
commit e1361427b8
9 changed files with 228 additions and 17 deletions
+10 -9
View File
@@ -686,10 +686,11 @@ manifest = ModuleManifest(
summary="Use managed files in the Records and documents area and keep a compact file surface beside current work.", summary="Use managed files in the Records and documents area and keep a compact file surface beside current work.",
body=( body=(
"Files contributes its authorized workspace to Records and documents. When Quick Access is enabled, the owner-rendered " "Files contributes its authorized workspace to Records and documents. When Quick Access is enabled, the owner-rendered "
"recent-file selector appears in Files on the right rail and can return one exact file-version reference to the current task. " "selector shows at most seven recently changed files and can return one exact file-version reference to the current task. "
"Opening the selector causes Files to re-run its own authorization; completion and cancellation are explicit, and the full Files " "Accounts with upload permission can launch the full Files upload dialog into a freshly reauthorized managed space. Opening "
"workspace remains available as a deep-link fallback. View and rail settings may recommend or focus this tool, but do not bypass " "either path causes Files to re-run its own provider, space, folder, object, and scope checks; completion and cancellation are "
"file ownership, shares, connector policy, integrity gates, or purpose-aware access." "explicit, and the full Files workspace remains available as a deep-link fallback. View and rail settings may recommend or "
"focus this tool, but do not bypass file ownership, shares, connector policy, integrity gates, or purpose-aware access."
), ),
layer="configured", layer="configured",
documentation_types=("user", "admin"), documentation_types=("user", "admin"),
@@ -715,11 +716,11 @@ manifest = ModuleManifest(
"summary": "Verwaltete Dateien im Produktbereich Akten und Dokumente und optional neben der aktuellen Arbeit verwenden.", "summary": "Verwaltete Dateien im Produktbereich Akten und Dokumente und optional neben der aktuellen Arbeit verwenden.",
"body": ( "body": (
"Files ordnet seinen berechtigten Arbeitsbereich Akten und Dokumente zu. Ist der Schnellzugriff aktiviert, erscheint " "Files ordnet seinen berechtigten Arbeitsbereich Akten und Dokumente zu. Ist der Schnellzugriff aktiviert, erscheint "
"die vom Modul gerenderte Auswahl zuletzt berechtigter Dateien rechts neben der aktuellen Seite und kann einen exakten " "die vom Modul gerenderte Auswahl von höchstens sieben zuletzt geänderten berechtigten Dateien rechts neben der aktuellen "
"Dateiversionsverweis an die aktuelle Aufgabe zurückgeben. Files prüft die Berechtigung beim Öffnen erneut; Abschluss und " "Seite und kann einen exakten Dateiversionsverweis zurückgeben. Mit Upload-Berechtigung lässt sich der vollständige "
"Abbruch sind ausdrücklich, und der vollständige Files-Arbeitsbereich bleibt als Ausweichziel erreichbar. Ansichts- und " "Upload-Dialog in einem erneut berechtigungsgeprüften verwalteten Bereich öffnen. Files prüft Anbieter, Bereich, Ordner, "
"Leistenkonfiguration dürfen das Werkzeug empfehlen oder fokussieren, umgehen aber weder Eigentum, Freigaben, Connector-" "Objekt und Berechtigung bei jedem Pfad erneut. Abschluss und Abbruch sind ausdrücklich; Eigentum, Freigaben, Connector-"
"Richtlinien, Integritätsprüfungen noch zweckgebundenen Zugriff." "Richtlinien, Integritätsprüfungen und zweckgebundener Zugriff bleiben maßgeblich."
), ),
} }
}, },
+28 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, require_scope from govoplan_core.auth import ApiPrincipal, require_scope
@@ -14,6 +14,7 @@ from govoplan_files.backend.storage.files import (
count_assets_for_user, count_assets_for_user,
list_assets_for_user, list_assets_for_user,
list_assets_for_user_window, list_assets_for_user_window,
list_recent_assets_for_user,
) )
@@ -79,6 +80,7 @@ def list_files(
path_prefix: str | None = None, path_prefix: str | None = None,
campaign_usage: Literal["linked", "unlinked"] | None = None, campaign_usage: Literal["linked", "unlinked"] | None = None,
audit_relevant: bool | None = None, audit_relevant: bool | None = None,
sort: Literal["path", "recent"] = "path",
page_size: int | None = Query(default=None, ge=1, le=1000), page_size: int | None = Query(default=None, ge=1, le=1000),
cursor: str | None = None, cursor: str | None = None,
session: Session = Depends(get_session), session: Session = Depends(get_session),
@@ -99,6 +101,31 @@ def list_files(
audit_relevant=audit_relevant, audit_relevant=audit_relevant,
is_admin=_is_admin(principal), is_admin=_is_admin(principal),
) )
if sort == "recent":
if cursor:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Recent file projections do not accept a path-order cursor.",
)
recent_limit = page_size or 25
assets = list_recent_assets_for_user(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
limit=recent_limit,
owner_type=owner_type,
owner_id=owner_id,
campaign_id=campaign_id,
path_prefix=path_prefix,
campaign_usage=campaign_usage,
audit_relevant=audit_relevant,
is_admin=_is_admin(principal),
)
return FileListResponse(
files=_asset_list_response(session, assets, include_shares=True),
total=total,
watermark=watermark,
)
effective_page_size = _cursor_page_size(FILES_LIST_CURSOR_SCOPE, cursor, page_size) effective_page_size = _cursor_page_size(FILES_LIST_CURSOR_SCOPE, cursor, page_size)
if effective_page_size is not None: if effective_page_size is not None:
fingerprint = _files_list_fingerprint( fingerprint = _files_list_fingerprint(
@@ -529,6 +529,41 @@ def list_assets_for_user(
return query.order_by(FileAsset.display_path.asc(), FileAsset.updated_at.desc(), FileAsset.id.asc()).all() return query.order_by(FileAsset.display_path.asc(), FileAsset.updated_at.desc(), FileAsset.id.asc()).all()
def list_recent_assets_for_user(
session: Session,
*,
tenant_id: str,
user_id: str,
limit: int,
owner_type: str | None = None,
owner_id: str | None = None,
campaign_id: str | None = None,
path_prefix: str | None = None,
campaign_usage: str | None = None,
audit_relevant: bool | None = None,
is_admin: bool = False,
) -> list[FileAsset]:
"""Return a bounded recent projection through the normal access query."""
query = _asset_visibility_query_for_user(
session,
tenant_id=tenant_id,
user_id=user_id,
owner_type=owner_type,
owner_id=owner_id,
campaign_id=campaign_id,
path_prefix=path_prefix,
campaign_usage=campaign_usage,
audit_relevant=audit_relevant,
is_admin=is_admin,
)
return (
query.order_by(FileAsset.updated_at.desc(), FileAsset.id.asc())
.limit(max(1, limit))
.all()
)
def list_assets_for_user_window( def list_assets_for_user_window(
session: Session, session: Session,
*, *,
+54 -1
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import unittest import unittest
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from sqlalchemy import create_engine, text from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@@ -12,7 +13,11 @@ from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_files.backend.capabilities import FilesAccessService, virtual_folder_resource_id from govoplan_files.backend.capabilities import FilesAccessService, virtual_folder_resource_id
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
from govoplan_files.backend.storage.files import count_assets_for_user, list_assets_for_user from govoplan_files.backend.storage.files import (
count_assets_for_user,
list_assets_for_user,
list_recent_assets_for_user,
)
TENANT_ID = "tenant-1" TENANT_ID = "tenant-1"
@@ -22,6 +27,54 @@ GROUP_ID = "group-1"
class FilesAccessProviderTests(unittest.TestCase): class FilesAccessProviderTests(unittest.TestCase):
def test_recent_projection_is_bounded_ordered_and_access_filtered(self) -> None:
session = _session()
self.addCleanup(_close_session, session)
_seed_access_subjects(session)
now = datetime.now(timezone.utc)
session.add_all(
[
FileAsset(
id="owned-older",
tenant_id=TENANT_ID,
owner_type="user",
owner_user_id=USER_ID,
display_path="older.pdf",
filename="older.pdf",
updated_at=now - timedelta(hours=2),
),
FileAsset(
id="owned-newer",
tenant_id=TENANT_ID,
owner_type="user",
owner_user_id=USER_ID,
display_path="newer.pdf",
filename="newer.pdf",
updated_at=now - timedelta(hours=1),
),
FileAsset(
id="other-newest",
tenant_id=TENANT_ID,
owner_type="user",
owner_user_id=OTHER_USER_ID,
display_path="private.pdf",
filename="private.pdf",
updated_at=now,
),
]
)
session.commit()
with patch("govoplan_files.backend.storage.files.user_group_ids", return_value=[]):
recent = list_recent_assets_for_user(
session,
tenant_id=TENANT_ID,
user_id=USER_ID,
limit=1,
)
self.assertEqual(["owned-newer"], [asset.id for asset in recent])
def test_file_access_provider_explains_owner_share_admin_and_missing_resources(self) -> None: def test_file_access_provider_explains_owner_share_admin_and_missing_resources(self) -> None:
session = _session() session = _session()
self.addCleanup(_close_session, session) self.addCleanup(_close_session, session)
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
from pathlib import Path
import unittest
from govoplan_files.backend.manifest import manifest
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
class FilesQuickAccessContractTests(unittest.TestCase):
def test_manifest_declares_exact_version_result_and_fallback(self) -> None:
tool = manifest.frontend.quick_access_tools[0]
self.assertEqual("files.recent", tool.id)
self.assertEqual(("files.file-version",), tool.returned_reference_kinds)
self.assertEqual("files.quick_access.files", tool.help_context_id)
self.assertEqual("/files", tool.full_page_path)
def test_renderer_and_page_keep_recent_selection_and_upload_bounded(self) -> None:
renderer = (
REPOSITORY_ROOT / "webui/src/features/files/FileQuickAccess.tsx"
).read_text(encoding="utf-8")
page = (
REPOSITORY_ROOT / "webui/src/features/files/FilesPage.tsx"
).read_text(encoding="utf-8")
self.assertIn('sort: "recent", page_size: 7', renderer)
self.assertIn('kind: "file-version"', renderer)
self.assertIn("quickAccessLaunchState(launchContext)", renderer)
self.assertIn('to="/files?quickAction=upload"', renderer)
self.assertIn('parameters.get("quickAction") !== "upload"', page)
self.assertIn('openDialog("upload"', page)
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -127,6 +127,7 @@ class FilesRouterContractTests(unittest.TestCase):
self.assertIn("campaign_usage", parameters) self.assertIn("campaign_usage", parameters)
self.assertIn("audit_relevant", parameters) self.assertIn("audit_relevant", parameters)
self.assertIn("sort", parameters)
self.assertIn("total", route.response_model.model_fields) self.assertIn("total", route.response_model.model_fields)
+1 -1
View File
@@ -470,7 +470,7 @@ payload: {owner_type: "user" | "group";owner_id: string;path: string;recursive?:
return apiFetch<FolderDeleteResponse>(settings, "/api/v1/files/folders/delete", { method: "POST", body: JSON.stringify({ recursive: true, ...payload }) }); return apiFetch<FolderDeleteResponse>(settings, "/api/v1/files/folders/delete", { method: "POST", body: JSON.stringify({ recursive: true, ...payload }) });
} }
export function listFiles(settings: ApiSettings, params: {owner_type?: string;owner_id?: string;campaign_id?: string;path_prefix?: string;campaign_usage?: FileCampaignUsageFilter;audit_relevant?: boolean;page_size?: number;cursor?: string | null;} = {}): Promise<FileListResponse> { export function listFiles(settings: ApiSettings, params: {owner_type?: string;owner_id?: string;campaign_id?: string;path_prefix?: string;campaign_usage?: FileCampaignUsageFilter;audit_relevant?: boolean;sort?: "path" | "recent";page_size?: number;cursor?: string | null;} = {}): Promise<FileListResponse> {
const search = new URLSearchParams(); const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) { for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== "") search.set(key, String(value)); if (value !== undefined && value !== null && value !== "") search.set(key, String(value));
+21 -5
View File
@@ -1,5 +1,6 @@
import { FileText, X } from "lucide-react"; import { FileText, UploadCloud, X } from "lucide-react";
import { useCallback } from "react"; import { useCallback } from "react";
import { Link } from "react-router";
import { import {
Button, Button,
DismissibleAlert, DismissibleAlert,
@@ -7,6 +8,8 @@ import {
SelectionList, SelectionList,
SelectionListItem, SelectionListItem,
SelectionListItemContent, SelectionListItemContent,
hasScope,
quickAccessLaunchState,
useDashboardWidgetData, useDashboardWidgetData,
type QuickAccessToolRenderContext type QuickAccessToolRenderContext
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
@@ -14,7 +17,7 @@ import { listFiles } from "../../api/files";
type Props = Pick< type Props = Pick<
QuickAccessToolRenderContext, QuickAccessToolRenderContext,
"settings" | "launchContext" | "complete" | "cancel" "settings" | "auth" | "launchContext" | "complete" | "cancel" | "close"
>; >;
/** /**
@@ -23,15 +26,18 @@ type Props = Pick<
*/ */
export default function FileQuickAccess({ export default function FileQuickAccess({
settings, settings,
auth,
launchContext, launchContext,
complete, complete,
cancel cancel,
close
}: Props) { }: Props) {
const load = useCallback( const load = useCallback(
async () => (await listFiles(settings, { page_size: 7 })).files, async () => (await listFiles(settings, { sort: "recent", page_size: 7 })).files,
[settings] [settings]
); );
const { data: files, loading, error } = useDashboardWidgetData(load, 0); const { data: files, loading, error } = useDashboardWidgetData(load, 0);
const canUpload = hasScope(auth, "files:upload");
return ( return (
<LoadingFrame loading={loading} label="Loading recent files"> <LoadingFrame loading={loading} label="Loading recent files">
@@ -74,7 +80,17 @@ export default function FileQuickAccess({
<p className="muted">No authorized files are available.</p> <p className="muted">No authorized files are available.</p>
) : null} ) : null}
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={() => cancel("user")}><X size={15} aria-hidden="true" /> Cancel selection</Button> {canUpload ? (
<Link
className="btn btn-secondary"
to="/files?quickAction=upload"
state={quickAccessLaunchState(launchContext)}
onClick={close}
>
<UploadCloud size={15} aria-hidden="true" /> i18n:govoplan-files.upload.8bdf057f
</Link>
) : null}
<Button onClick={() => cancel("user")}><X size={15} aria-hidden="true" /> i18n:govoplan-files.cancel.77dfd213</Button>
</div> </div>
</LoadingFrame> </LoadingFrame>
); );
+40
View File
@@ -16,6 +16,7 @@ import { FormGrid, ActionToolbar,
type ApiSettings, type ApiSettings,
type AuthInfo, i18nMessage } from type AuthInfo, i18nMessage } from
"@govoplan/core-webui"; "@govoplan/core-webui";
import { useLocation, useNavigate } from "react-router";
import { import {
bulkDeleteFiles, bulkDeleteFiles,
bulkRenameFiles, bulkRenameFiles,
@@ -114,12 +115,15 @@ const FILES_WORKFLOW_DOCUMENTATION = {
} as const; } as const;
export default function FilesPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) { export default function FilesPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
const location = useLocation();
const navigate = useNavigate();
const canDownload = hasScope(auth, "files:download"); const canDownload = hasScope(auth, "files:download");
const canUpload = hasScope(auth, "files:upload"); const canUpload = hasScope(auth, "files:upload");
const canOrganize = hasScope(auth, "files:organize"); const canOrganize = hasScope(auth, "files:organize");
const canDelete = hasScope(auth, "files:delete"); const canDelete = hasScope(auth, "files:delete");
const canShare = hasScope(auth, "files:file:share"); const canShare = hasScope(auth, "files:file:share");
const [spaces, setSpaces] = useState<FileSpace[]>(EMPTY_SPACES); const [spaces, setSpaces] = useState<FileSpace[]>(EMPTY_SPACES);
const [spacesLoaded, setSpacesLoaded] = useState(false);
const [activeSpaceId, setActiveSpaceId] = useState(""); const [activeSpaceId, setActiveSpaceId] = useState("");
const activeSpace = spaces.find((space) => space.id === activeSpaceId) ?? spaces[0] ?? null; const activeSpace = spaces.find((space) => space.id === activeSpaceId) ?? spaces[0] ?? null;
const activeSpaceIsConnector = activeSpace?.space_type === "connector"; const activeSpaceIsConnector = activeSpace?.space_type === "connector";
@@ -330,6 +334,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
}, [activeSpaceId, currentFolder, propertyFiltersActive, propertyFilterResults, searchActive, searchResults, sortColumn, sortDirection]); }, [activeSpaceId, currentFolder, propertyFiltersActive, propertyFilterResults, searchActive, searchResults, sortColumn, sortDirection]);
async function loadSpaces() { async function loadSpaces() {
setSpacesLoaded(false);
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
@@ -341,6 +346,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} finally { } finally {
setSpacesLoaded(true);
setBusy(false); setBusy(false);
} }
} }
@@ -434,6 +440,40 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
const parameters = new URLSearchParams(location.search);
if (parameters.get("quickAction") !== "upload" || !spacesLoaded) return;
const uploadSpace =
activeSpace && !isConnectorSpace(activeSpace)
? activeSpace
: spaces.find((space) => !isConnectorSpace(space)) ?? null;
if (canUpload && uploadSpace) {
setActiveSpaceId(uploadSpace.id);
openDialog("upload", { spaceId: uploadSpace.id, folderPath: "" });
} else if (!canUpload) {
setError("File upload permission is required.");
} else {
setError("No authorized managed file space is available for upload.");
}
parameters.delete("quickAction");
const search = parameters.toString();
navigate(
{ pathname: location.pathname, search: search ? `?${search}` : "" },
{ replace: true, state: location.state }
);
}, [
activeSpace,
canUpload,
location.pathname,
location.search,
location.state,
navigate,
spaces,
spacesLoaded
]);
useEffect(() => { useEffect(() => {
if (!contextMenu) return undefined; if (!contextMenu) return undefined;
const close = () => setContextMenu(null); const close = () => setContextMenu(null);