feat(webui): complete bounded work quick access
This commit is contained in:
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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()
|
||||
@@ -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 (
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-tasks.loading">
|
||||
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
{commandError ? <DismissibleAlert tone="danger" resetKey={commandError}>{commandError}</DismissibleAlert> : null}
|
||||
{data?.diagnostics.map((diagnostic) => (
|
||||
<DismissibleAlert
|
||||
key={`${diagnostic.provider_id}:${diagnostic.code}`}
|
||||
tone="warning"
|
||||
resetKey={`${diagnostic.provider_id}:${diagnostic.code}:${diagnostic.message}`}
|
||||
>
|
||||
{diagnostic.message}
|
||||
</DismissibleAlert>
|
||||
))}
|
||||
|
||||
{items.length ? (
|
||||
<SelectionList variant="navigation" label="i18n:govoplan-tasks.work_items">
|
||||
{items.map((item) => (
|
||||
<SelectionListItem
|
||||
key={workKey(item)}
|
||||
selected={selected ? workKey(item) === workKey(selected) : false}
|
||||
onClick={() => setSelectedKey(workKey(item))}
|
||||
>
|
||||
<SelectionListItemContent
|
||||
leading={<ListChecks size={16} aria-hidden="true" />}
|
||||
title={item.title}
|
||||
description={item.required_action || item.summary || moduleLabel(item.owner_module)}
|
||||
/>
|
||||
<StatusBadge status={item.status} label={statusLabel(item.status)} />
|
||||
</SelectionListItem>
|
||||
))}
|
||||
</SelectionList>
|
||||
) : !loading && !error ? (
|
||||
<p className="muted">i18n:govoplan-tasks.empty</p>
|
||||
) : null}
|
||||
|
||||
{selected ? (
|
||||
<section className="tasks-quick-detail" aria-label="i18n:govoplan-tasks.work_details">
|
||||
<div className="tasks-quick-detail-heading">
|
||||
<strong>{selected.title}</strong>
|
||||
<span>{moduleLabel(selected.owner_module)} · {dueLabel(selected.due_at)}</span>
|
||||
</div>
|
||||
{selected.summary ? <p>{selected.summary}</p> : null}
|
||||
{selected.required_action ? (
|
||||
<p><strong>i18n:govoplan-tasks.required_action:</strong> {selected.required_action}</p>
|
||||
) : null}
|
||||
<div className="button-row compact-actions">
|
||||
{selected.provider_id === "tasks.explicit" && canWrite && ["open", "deferred"].includes(selected.status) ? (
|
||||
<Button onClick={() => void runCommand("start")} disabled={busy}>
|
||||
<CirclePlay size={15} aria-hidden="true" /> i18n:govoplan-tasks.start
|
||||
</Button>
|
||||
) : null}
|
||||
{selected.provider_id === "tasks.explicit" && canWrite ? (
|
||||
<Button variant="primary" onClick={() => void runCommand("complete")} disabled={busy}>
|
||||
<Check size={15} aria-hidden="true" /> i18n:govoplan-tasks.complete
|
||||
</Button>
|
||||
) : null}
|
||||
{actionPath ? (
|
||||
<Link
|
||||
className="btn btn-secondary"
|
||||
to={actionPath}
|
||||
state={quickAccessLaunchState(launchContext)}
|
||||
onClick={() => selectForHost(selected)}
|
||||
>
|
||||
<ExternalLink size={15} aria-hidden="true" /> i18n:govoplan-tasks.open_work
|
||||
</Link>
|
||||
) : (
|
||||
<Button onClick={() => selectForHost(selected)}>
|
||||
i18n:govoplan-tasks.select_help
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{data && data.total > items.length ? (
|
||||
<p className="muted small-note">
|
||||
{items.length} / {data.total} · i18n:govoplan-tasks.open_work_inbox
|
||||
</p>
|
||||
) : null}
|
||||
</LoadingFrame>
|
||||
);
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
+2
-4
@@ -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)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user