diff --git a/src/govoplan_tasks/backend/manifest.py b/src/govoplan_tasks/backend/manifest.py index 52c1f56..e4e2929 100644 --- a/src/govoplan_tasks/backend/manifest.py +++ b/src/govoplan_tasks/backend/manifest.py @@ -128,8 +128,10 @@ DOCUMENTATION = ( summary="Keep assigned work available in the Work area and the optional right-side Quick Access rail.", body=( "Tasks contributes its authorized workspace to the Work product area. When Quick Access is enabled, " - "the same provider-owned open-work summary can appear beside the current page. Views may hide or reorder " - "the contribution, but neither presentation grants task access or changes completion state." + "a bounded seven-item authorized inbox and detail can appear beside the current page. Explicit Tasks can be " + "started or completed there; work from another provider exposes only that provider's launch path. Every load " + "and command is rechecked by Tasks, and completion returns a typed work-item reference to the host. Views may " + "hide or reorder the contribution, but neither presentation grants task access or copies completion state." ), layer="configured", documentation_types=("user", "admin"), @@ -141,8 +143,10 @@ DOCUMENTATION = ( "summary": "Zugewiesene Arbeit im Produktbereich Arbeit und optional in der rechten Schnellzugriffsleiste verwenden.", "body": ( "Tasks ordnet den berechtigten Arbeitsbereich dem Produktbereich Arbeit zu. Ist der Schnellzugriff aktiviert, " - "kann dieselbe vom Modul verantwortete Zusammenfassung offener Arbeit neben der aktuellen Seite erscheinen. " - "Ansichten dürfen den Beitrag ausblenden oder ordnen, erteilen aber keine Aufgabenberechtigung." + "kann ein begrenzter, berechtigungsgeprüfter Arbeitsvorrat mit sieben Einträgen und Details neben der " + "aktuellen Seite erscheinen. Explizite Tasks lassen sich dort beginnen oder abschließen; fremde Quellen " + "behalten ihre eigenen Befehle und Startpfade. Jeder Aufruf wird erneut durch Tasks geprüft. Ansichten " + "dürfen den Beitrag ausblenden oder ordnen, erteilen aber keine Aufgabenberechtigung." ), } }, @@ -326,6 +330,8 @@ manifest = ModuleManifest( required_any=(READ_SCOPE,), order=10, modes=("browse", "resume"), + returned_reference_kinds=("tasks.work-item",), + help_context_id="tasks.quick_access.work", ), ), ), diff --git a/tests/test_webui_contract.py b/tests/test_webui_contract.py new file mode 100644 index 0000000..fb3815f --- /dev/null +++ b/tests/test_webui_contract.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from pathlib import Path +import unittest + +from govoplan_tasks.backend.manifest import get_manifest + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] + + +class TasksQuickAccessContractTests(unittest.TestCase): + def test_manifest_declares_typed_work_result_and_help(self) -> None: + tool = get_manifest().frontend.quick_access_tools[0] + + self.assertEqual("tasks.work", tool.id) + self.assertEqual(("tasks.work-item",), tool.returned_reference_kinds) + self.assertEqual("tasks.quick_access.work", tool.help_context_id) + self.assertEqual("/tasks", tool.full_page_path) + + def test_renderer_is_bounded_and_keeps_commands_source_owned(self) -> None: + source = ( + REPOSITORY_ROOT + / "webui" + / "src" + / "features" + / "tasks" + / "TasksQuickAccess.tsx" + ).read_text() + + self.assertIn("limit: 7", source) + self.assertIn('selected.provider_id !== "tasks.explicit"', source) + self.assertIn("transitionTask(settings, selected, action)", source) + self.assertIn("quickAccessLaunchState(launchContext)", source) + self.assertIn('kind: "work-item"', source) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/src/features/tasks/TasksQuickAccess.tsx b/webui/src/features/tasks/TasksQuickAccess.tsx new file mode 100644 index 0000000..de565b3 --- /dev/null +++ b/webui/src/features/tasks/TasksQuickAccess.tsx @@ -0,0 +1,220 @@ +import { Check, CirclePlay, ExternalLink, ListChecks } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Link } from "react-router"; +import { + Button, + DismissibleAlert, + LoadingFrame, + SelectionList, + SelectionListItem, + SelectionListItemContent, + StatusBadge, + hasScope, + quickAccessLaunchState, + useDashboardWidgetData, + type QuickAccessToolRenderContext +} from "@govoplan/core-webui"; +import { + listWork, + transitionTask, + type WorkItem, + type WorkStatus +} from "../../api/tasks"; + +const ACTIVE_STATUSES: WorkStatus[] = [ + "open", + "in_progress", + "deferred", + "blocked" +]; + +type Props = Pick< + QuickAccessToolRenderContext, + "settings" | "auth" | "launchContext" | "complete" +>; + +/** + * A bounded projection of the unified inbox. Every load and command goes back + * through Tasks, so optional providers retain ownership of visibility and + * completion semantics. + */ +export default function TasksQuickAccess({ + settings, + auth, + launchContext, + complete +}: Props) { + const [refreshKey, setRefreshKey] = useState(0); + const [selectedKey, setSelectedKey] = useState(""); + const [commandError, setCommandError] = useState(""); + const [busy, setBusy] = useState(false); + const load = useCallback( + () => listWork(settings, { statuses: ACTIVE_STATUSES, limit: 7 }), + [settings] + ); + const { data, loading, error } = useDashboardWidgetData(load, refreshKey); + const items = data?.items ?? []; + const selected = useMemo( + () => items.find((item) => workKey(item) === selectedKey) ?? items[0] ?? null, + [items, selectedKey] + ); + const canWrite = hasScope(auth, "tasks:item:write"); + + useEffect(() => { + if (!selectedKey && items[0]) setSelectedKey(workKey(items[0])); + if (selectedKey && !items.some((item) => workKey(item) === selectedKey)) { + setSelectedKey(items[0] ? workKey(items[0]) : ""); + } + }, [items, selectedKey]); + + async function runCommand(action: "start" | "complete") { + if (!selected || selected.provider_id !== "tasks.explicit" || !canWrite) return; + setBusy(true); + setCommandError(""); + try { + const updated = await transitionTask(settings, selected, action); + if (action === "complete") { + complete(workResult(updated, "completed")); + return; + } + setRefreshKey((value) => value + 1); + } catch (reason) { + setCommandError(errorMessage(reason)); + } finally { + setBusy(false); + } + } + + function selectForHost(item: WorkItem) { + complete(workResult(item, "selected")); + } + + const actionPath = selected ? safeActionUrl(selected.action_url) : null; + + return ( + + {error ? {error} : null} + {commandError ? {commandError} : null} + {data?.diagnostics.map((diagnostic) => ( + + {diagnostic.message} + + ))} + + {items.length ? ( + + {items.map((item) => ( + setSelectedKey(workKey(item))} + > + + ))} + + ) : !loading && !error ? ( +

i18n:govoplan-tasks.empty

+ ) : null} + + {selected ? ( +
+
+ {selected.title} + {moduleLabel(selected.owner_module)} · {dueLabel(selected.due_at)} +
+ {selected.summary ?

{selected.summary}

: null} + {selected.required_action ? ( +

i18n:govoplan-tasks.required_action: {selected.required_action}

+ ) : null} +
+ {selected.provider_id === "tasks.explicit" && canWrite && ["open", "deferred"].includes(selected.status) ? ( + + ) : null} + {selected.provider_id === "tasks.explicit" && canWrite ? ( + + ) : null} + {actionPath ? ( + selectForHost(selected)} + > +
+
+ ) : null} + + {data && data.total > items.length ? ( +

+ {items.length} / {data.total} · i18n:govoplan-tasks.open_work_inbox +

+ ) : null} +
+ ); +} + +function workResult(item: WorkItem, action: "selected" | "completed") { + return { + contractVersion: "1" as const, + outcome: "completed" as const, + action, + reference: { + ownerModule: "tasks", + kind: "work-item", + objectId: `${item.provider_id}:${item.id}`, + tenantId: item.tenant_id, + label: item.title, + version: item.revision, + path: safeActionUrl(item.action_url) || "/tasks" + } + }; +} + +function workKey(item: WorkItem): string { + return `${item.provider_id}:${item.id}`; +} + +function safeActionUrl(value?: string | null): string | null { + if (!value || !value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return null; + return value; +} + +function statusLabel(value: string): string { + return `i18n:govoplan-tasks.status.${value}`; +} + +function moduleLabel(value: string): string { + return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +function dueLabel(value?: string | null): string { + if (!value) return "i18n:govoplan-tasks.no_due_date"; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(parsed); +} + +function errorMessage(reason: unknown): string { + return reason instanceof Error ? reason.message : "i18n:govoplan-tasks.request_failed"; +} diff --git a/webui/src/module.ts b/webui/src/module.ts index 128b779..9255646 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -5,6 +5,7 @@ import type { QuickAccessToolsUiCapability } from "@govoplan/core-webui"; import TasksSummaryWidget from "./features/tasks/TasksSummaryWidget"; +import TasksQuickAccess from "./features/tasks/TasksQuickAccess"; import { generatedTranslations } from "./i18n/generatedTranslations"; import "./styles/tasks.css"; @@ -35,10 +36,7 @@ const quickAccessTools: QuickAccessToolsUiCapability = { tools: [ { id: "tasks.work", - render: ({ settings }) => createElement(TasksSummaryWidget, { - settings, - refreshKey: 0 - }) + render: (context) => createElement(TasksQuickAccess, context) } ] }; diff --git a/webui/src/styles/tasks.css b/webui/src/styles/tasks.css index bf64122..b73abb2 100644 --- a/webui/src/styles/tasks.css +++ b/webui/src/styles/tasks.css @@ -205,6 +205,26 @@ margin-top: 12px; } +.tasks-quick-detail { + display: grid; + gap: 10px; + margin-top: 12px; + border-top: var(--border-line); + padding-top: 12px; +} + +.tasks-quick-detail-heading { + display: grid; + gap: 3px; +} + +.tasks-quick-detail-heading span, +.tasks-quick-detail > p { + margin: 0; + color: var(--muted); + font-size: 12px; +} + @media (max-width: 760px) { .tasks-topbar { align-items: flex-start;