From c288f698f762ec75fdd115eae07964eb3d1e7340 Mon Sep 17 00:00:00 2001 From: Julian Haupt Date: Tue, 16 Jun 2026 19:48:24 +0200 Subject: [PATCH] tasks: add open tasks dashboard --- public/locales/de-DE/translation.json | 25 ++ public/locales/en-US/translation.json | 25 ++ src/api/resources/task.js | 154 ++++++++++-- src/api/resources/task.test.js | 76 ++++++ src/components/GoTo.jsx | 322 +++++++++++++++----------- src/css/style.css | 58 +++++ src/index.jsx | 12 +- src/pages/Dashboard.jsx | 13 ++ src/pages/Dashboard.test.jsx | 1 + src/pages/TaskDashboard.jsx | 316 +++++++++++++++++++++++++ src/pages/TaskDashboard.test.jsx | 106 +++++++++ src/pages/Tasks.jsx | 130 ++++++++--- src/state.js | 4 + src/state.test.js | 2 + 14 files changed, 1062 insertions(+), 182 deletions(-) create mode 100644 src/pages/TaskDashboard.jsx create mode 100644 src/pages/TaskDashboard.test.jsx diff --git a/public/locales/de-DE/translation.json b/public/locales/de-DE/translation.json index 5f7d6a9..d3ac808 100644 --- a/public/locales/de-DE/translation.json +++ b/public/locales/de-DE/translation.json @@ -34,6 +34,7 @@ }, "nav": { "tasks": "Aufgaben", + "task-dashboard": "Offene Aufgaben", "processes": "Prozesse", "decisions": "Entscheidungen", "deployments": "Deployments", @@ -73,6 +74,7 @@ "pages": { "dashboard": "Dashboard", "tasks": "Aufgaben", + "task-dashboard": "Dashboard für offene Aufgaben", "processes": "Prozesse", "decisions": "Entscheidungen", "deployments": "Deployments", @@ -241,8 +243,10 @@ "assignee": "assignee", "assigneeLike": "assigneeLike", "candidateGroup": "candidateGroup", + "includeAssignedTasks": "includeAssignedTasks", "candidateUser": "candidateUser", "involvedUser": "involvedUser", + "assigned": "assigned", "unassigned": "unassigned", "processDefinitionKey": "processDefinitionKey", "processDefinitionName": "processDefinitionName", @@ -266,6 +270,27 @@ "suspended": "suspended" } }, + "task_dashboard": { + "title": "Dashboard für offene Aufgaben", + "open-tasklist": "Tasklist öffnen", + "assignment-by-type": "Zuordnung nach Typ", + "assignment-by-group": "Zuordnung nach Gruppe", + "summary": { + "open": "offene Aufgaben", + "assigned": "zugewiesene Aufgaben", + "unassigned": "nicht zugewiesene Aufgaben" + }, + "groups": { + "group": "Gruppe", + "open-tasks": "Offene Aufgaben", + "empty": "Keine offenen Aufgaben werden Gruppen angeboten." + }, + "search": { + "title": "Aufgaben suchen", + "refresh": "Aktualisieren", + "empty": "Keine Aufgaben passen zu dieser Suche." + } + }, "processes": { "title": "Prozessdefinitionen", "version": "Version", diff --git a/public/locales/en-US/translation.json b/public/locales/en-US/translation.json index ac20fbe..76ac129 100644 --- a/public/locales/en-US/translation.json +++ b/public/locales/en-US/translation.json @@ -34,6 +34,7 @@ }, "nav": { "tasks": "Tasks", + "task-dashboard": "Open Tasks", "processes": "Processes", "decisions": "Decisions", "deployments": "Deployments", @@ -73,6 +74,7 @@ "pages": { "dashboard": "Dashboard", "tasks": "Tasks", + "task-dashboard": "Open Tasks Dashboard", "processes": "Processes", "decisions": "Decisions", "deployments": "Deployments", @@ -241,8 +243,10 @@ "assignee": "assignee", "assigneeLike": "assigneeLike", "candidateGroup": "candidateGroup", + "includeAssignedTasks": "includeAssignedTasks", "candidateUser": "candidateUser", "involvedUser": "involvedUser", + "assigned": "assigned", "unassigned": "unassigned", "processDefinitionKey": "processDefinitionKey", "processDefinitionName": "processDefinitionName", @@ -266,6 +270,27 @@ "suspended": "suspended" } }, + "task_dashboard": { + "title": "Open Tasks Dashboard", + "open-tasklist": "Open Tasklist", + "assignment-by-type": "Assignments by type", + "assignment-by-group": "Assignments by group", + "summary": { + "open": "open tasks", + "assigned": "assigned tasks", + "unassigned": "unassigned tasks" + }, + "groups": { + "group": "Group", + "open-tasks": "Open tasks", + "empty": "No open tasks are offered to groups." + }, + "search": { + "title": "Search Tasks", + "refresh": "Refresh", + "empty": "No tasks match this search." + } + }, "processes": { "title": "Process Definitions", "version": "Version", diff --git a/src/api/resources/task.js b/src/api/resources/task.js index ee0124d..3ff69fd 100644 --- a/src/api/resources/task.js +++ b/src/api/resources/task.js @@ -6,6 +6,7 @@ import { GET_TEXT, POST, PUT, + get_auth_header, get_credentials, } from "../helper.jsx"; import engine_rest from "../engine_rest.jsx"; @@ -121,7 +122,14 @@ const tasks_with_process_definitions = async (tasks, state) => { return tasks; }; -const get_tasks = (state, sort_key = "name", sort_order = "asc", firstResult = 0, maxResults = 3, filter = {}) => { +const get_tasks = ( + state, + sort_key = "name", + sort_order = "asc", + firstResult = 0, + maxResults = 3, + filter = {}, +) => { const prev = state.api.task.list.value; state.api.task.list.value = { status: RESPONSE_STATE.LOADING, @@ -142,32 +150,135 @@ const get_tasks = (state, sort_key = "name", sort_order = "asc", firstResult = 0 ...filter, }); - fetch( - `${_url_engine_rest(state)}/task?${params}`, - { headers }, - ) + fetch(`${_url_engine_rest(state)}/task?${params}`, { headers }) .then((response) => response.ok ? response.json() : Promise.reject(response), ) .then((tasks) => tasks_with_process_definitions(tasks, state)) - .then( - (json) => { - const existing = firstResult > 0 ? (prev?.data ?? []) : []; - const existingIds = new Set(existing.map((t) => t.id)); - const newTasks = json.filter((t) => !existingIds.has(t.id)); - state.api.task.list.value = { - status: RESPONSE_STATE.SUCCESS, - data: [...existing, ...newTasks], - hasMore: json.length === maxResults, - }; - }, - ) + .then((json) => { + const existing = firstResult > 0 ? (prev?.data ?? []) : []; + const existingIds = new Set(existing.map((t) => t.id)); + const newTasks = json.filter((t) => !existingIds.has(t.id)); + state.api.task.list.value = { + status: RESPONSE_STATE.SUCCESS, + data: [...existing, ...newTasks], + hasMore: json.length === maxResults, + }; + }) .catch( (error) => (state.api.task.list.value = { status: RESPONSE_STATE.ERROR, error }), ); }; +const fetch_task_count = async (state, filter = {}) => { + const headers = new Headers(); + headers.set("Authorization", get_auth_header(state)); + + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(filter)) { + if (value !== null && value !== undefined && value !== "") + params.set(key, String(value)); + } + + const query = params.toString(), + response = await fetch( + `${_url_engine_rest(state)}/task/count${query ? `?${query}` : ""}`, + { headers }, + ), + json = await (response.ok ? response.json() : Promise.reject(response)); + + return json.count ?? 0; +}; + +const get_task_dashboard_summary = async (state, groups = []) => { + const signal = state.api.task.dashboard.summary; + signal.value = { + status: RESPONSE_STATE.LOADING, + data: signal.peek?.()?.data, + }; + + try { + const [total, assigned, unassigned, group_counts] = await Promise.all([ + fetch_task_count(state), + fetch_task_count(state, { assigned: true }), + fetch_task_count(state, { unassigned: true }), + Promise.all( + groups.map(async (group) => ({ + id: group.id, + name: group.name ?? group.id, + count: await fetch_task_count(state, { + candidateGroup: group.id, + includeAssignedTasks: true, + }), + })), + ), + ]); + + signal.value = { + status: RESPONSE_STATE.SUCCESS, + data: { + total, + assigned, + unassigned, + groups: group_counts.filter((group) => group.count > 0), + }, + }; + } catch (error) { + signal.value = { status: RESPONSE_STATE.ERROR, error }; + } +}; + +const get_task_dashboard_results = async ( + state, + filter = {}, + sort_key = "name", + sort_order = "asc", + firstResult = 0, + maxResults = 20, +) => { + const signal = state.api.task.dashboard.results, + prev = signal.peek?.(); + signal.value = { + status: RESPONSE_STATE.LOADING, + data: prev?.data, + hasMore: prev?.hasMore, + }; + + const headers = new Headers(); + headers.set("Authorization", get_auth_header(state)); + + const params = new URLSearchParams({ + sortBy: sort_key, + sortOrder: sort_order, + firstResult, + maxResults, + }); + for (const [key, value] of Object.entries(filter)) { + if (value !== null && value !== undefined && value !== "") + params.set(key, String(value)); + } + + try { + const response = await fetch(`${_url_engine_rest(state)}/task?${params}`, { + headers, + }), + tasks = await (response.ok ? response.json() : Promise.reject(response)), + enriched = await tasks_with_process_definitions(tasks, state), + existing = firstResult > 0 ? (prev?.data ?? []) : [], + existingIds = new Set(existing.map((t) => t.id)), + fresh = enriched.filter((t) => !existingIds.has(t.id)); + + signal.value = { + status: RESPONSE_STATE.SUCCESS, + data: [...existing, ...fresh], + hasMore: enriched.length === maxResults, + }; + } catch (error) { + signal.value = { status: RESPONSE_STATE.ERROR, error }; + } +}; + const get_task_process_definitions = (state, ids) => fetch( `${_url_engine_rest(state)}/process-definition?processDefinitionIdIn=${ids}`, @@ -190,10 +301,17 @@ const create_comment = (state, task_id, message) => ); const post_task_form = (state, task_id, data) => - POST(`/task/${task_id}/submit-form`, { variables: data, withVariablesInReturn: true, }, state, state.api.task.submit_form ); + POST( + `/task/${task_id}/submit-form`, + { variables: data, withVariablesInReturn: true }, + state, + state.api.task.submit_form, + ); const task = { get_tasks, + get_task_dashboard_summary, + get_task_dashboard_results, get_task, update_task, get_task_form, diff --git a/src/api/resources/task.test.js b/src/api/resources/task.test.js index dc7fbcf..c27a2b5 100644 --- a/src/api/resources/task.test.js +++ b/src/api/resources/task.test.js @@ -234,5 +234,81 @@ describe("api/resources/task", () => { ); expect(result).toEqual([{ id: "d1" }]); }); + + it("get_task_dashboard_summary counts all, assigned, unassigned and group tasks", async () => { + fetchMock + .mockResolvedValueOnce({ ok: true, json: async () => ({ count: 9 }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ count: 4 }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ count: 5 }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ count: 3 }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ count: 0 }) }); + + await task.get_task_dashboard_summary(state, [ + { id: "sales", name: "Sales" }, + { id: "empty", name: "Empty" }, + ]); + + const urls = fetchMock.mock.calls.map(([url]) => url); + expect(urls[0]).toContain("/task/count"); + expect(urls[1]).toContain("assigned=true"); + expect(urls[2]).toContain("unassigned=true"); + expect(urls[3]).toContain("candidateGroup=sales"); + expect(urls[3]).toContain("includeAssignedTasks=true"); + expect(state.api.task.dashboard.summary.value).toEqual({ + status: RESPONSE_STATE.SUCCESS, + data: { + total: 9, + assigned: 4, + unassigned: 5, + groups: [{ id: "sales", name: "Sales", count: 3 }], + }, + }); + }); + + it("get_task_dashboard_results queries tasks into the dashboard result signal", async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => [ + { id: "t1", name: "Review", processDefinitionId: "pd1" }, + ], + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => [{ id: "pd1", name: "Invoice", version: 2 }], + }); + + await task.get_task_dashboard_results( + state, + { candidateGroup: "sales", includeAssignedTasks: true }, + "created", + "desc", + 0, + 20, + ); + + const url = fetchMock.mock.calls[0][0]; + expect(url).toContain("/task?"); + expect(url).toContain("sortBy=created"); + expect(url).toContain("sortOrder=desc"); + expect(url).toContain("candidateGroup=sales"); + expect(url).toContain("includeAssignedTasks=true"); + expect(fetchMock.mock.calls[1][0]).toContain( + "/process-definition?processDefinitionIdIn=pd1", + ); + expect(state.api.task.dashboard.results.value).toEqual({ + status: RESPONSE_STATE.SUCCESS, + data: [ + { + id: "t1", + name: "Review", + processDefinitionId: "pd1", + definitionName: "Invoice", + definitionVersion: 2, + }, + ], + hasMore: false, + }); + }); }); }); diff --git a/src/components/GoTo.jsx b/src/components/GoTo.jsx index 87dde84..9b88466 100644 --- a/src/components/GoTo.jsx +++ b/src/components/GoTo.jsx @@ -1,18 +1,18 @@ -import { useContext } from "preact/hooks" -import { signal, useSignal } from "@preact/signals" -import { useHotkeys } from "react-hotkeys-hook" -import { useLocation } from "preact-iso" -import { useTranslation } from "react-i18next" -import { AppState } from "../state.js" -import { RESPONSE_STATE } from "../api/engine_rest.jsx" -import { _url_engine_rest, get_auth_header } from "../api/helper.jsx" - -const close = () => document.getElementById("global-search").close() +import { useContext } from "preact/hooks"; +import { signal, useSignal } from "@preact/signals"; +import { useHotkeys } from "react-hotkeys-hook"; +import { useLocation } from "preact-iso"; +import { useTranslation } from "react-i18next"; +import { AppState } from "../state.js"; +import { RESPONSE_STATE } from "../api/engine_rest.jsx"; +import { _url_engine_rest, get_auth_header } from "../api/helper.jsx"; + +const close = () => document.getElementById("global-search").close(); const show = () => { - const dialog = document.getElementById("global-search") - dialog.showModal() - dialog.querySelector("input")?.focus() -} + const dialog = document.getElementById("global-search"); + dialog.showModal(); + dialog.querySelector("input")?.focus(); +}; const CATEGORIES = [ { key: "pages", labelKey: "goto.categories.pages" }, @@ -21,11 +21,12 @@ const CATEGORIES = [ { key: "decisions", labelKey: "goto.categories.decisions" }, { key: "deployments", labelKey: "goto.categories.deployments" }, { key: "lookup", labelKey: "goto.categories.lookup" }, -] +]; const PAGES = [ { nameKey: "goto.pages.dashboard", href: "/" }, { nameKey: "goto.pages.tasks", href: "/tasks" }, + { nameKey: "goto.pages.task-dashboard", href: "/tasks-dashboard" }, { nameKey: "goto.pages.processes", href: "/processes" }, { nameKey: "goto.pages.decisions", href: "/decisions" }, { nameKey: "goto.pages.deployments", href: "/deployments" }, @@ -38,125 +39,179 @@ const PAGES = [ { nameKey: "goto.pages.admin-system", href: "/admin/system" }, { nameKey: "goto.pages.account", href: "/account" }, { nameKey: "goto.pages.help", href: "/help" }, -] +]; const match = (text, query) => - text?.toLowerCase().includes(query.toLowerCase()) + text?.toLowerCase().includes(query.toLowerCase()); const collect_results = (query, state, t) => { - if (!query) return [] + if (!query) return []; - const results = [] + const results = []; // Pages - const pages = PAGES.filter((p) => match(t(p.nameKey), query)) + const pages = PAGES.filter((p) => match(t(p.nameKey), query)); if (pages.length) - results.push({ category: "pages", items: pages.map((p) => ({ label: t(p.nameKey), href: p.href })) }) + results.push({ + category: "pages", + items: pages.map((p) => ({ label: t(p.nameKey), href: p.href })), + }); // Tasks - const tasks = state.api.task.list.value + const tasks = state.api.task.list.value; if (tasks?.status === RESPONSE_STATE.SUCCESS && tasks.data) { - const matched = tasks.data.filter((t) => - match(t.name, query) || match(t.id, query) || match(t.assignee, query) || match(t.definitionName, query) - ).slice(0, 8) + const matched = tasks.data + .filter( + (t) => + match(t.name, query) || + match(t.id, query) || + match(t.assignee, query) || + match(t.definitionName, query), + ) + .slice(0, 8); if (matched.length) - results.push({ category: "tasks", items: matched.map((t) => ({ - label: t.name ?? "Unnamed", - detail: t.definitionName || t.processDefinitionId, - href: `/tasks/${t.id}`, - })) }) + results.push({ + category: "tasks", + items: matched.map((t) => ({ + label: t.name ?? "Unnamed", + detail: t.definitionName || t.processDefinitionId, + href: `/tasks/${t.id}`, + })), + }); } // Process definitions - const defs = state.api.process.definition.list.value + const defs = state.api.process.definition.list.value; if (defs?.status === RESPONSE_STATE.SUCCESS && defs.data) { - const matched = defs.data.filter((d) => - match(d.definition?.name, query) || match(d.definition?.key, query) || match(d.definition?.id, query) - ).slice(0, 8) + const matched = defs.data + .filter( + (d) => + match(d.definition?.name, query) || + match(d.definition?.key, query) || + match(d.definition?.id, query), + ) + .slice(0, 8); if (matched.length) - results.push({ category: "processes", items: matched.map((d) => ({ - label: d.definition?.name ?? d.definition?.key ?? "–", - detail: d.definition?.key, - href: `/processes/${d.definition?.id}`, - })) }) + results.push({ + category: "processes", + items: matched.map((d) => ({ + label: d.definition?.name ?? d.definition?.key ?? "–", + detail: d.definition?.key, + href: `/processes/${d.definition?.id}`, + })), + }); } // Decision definitions - const decisions = state.api.decision.definitions.value + const decisions = state.api.decision.definitions.value; if (decisions?.status === RESPONSE_STATE.SUCCESS && decisions.data) { - const matched = decisions.data.filter((d) => - match(d.name, query) || match(d.key, query) || match(d.id, query) - ).slice(0, 8) + const matched = decisions.data + .filter( + (d) => + match(d.name, query) || match(d.key, query) || match(d.id, query), + ) + .slice(0, 8); if (matched.length) - results.push({ category: "decisions", items: matched.map((d) => ({ - label: d.name ?? d.key ?? "–", - detail: d.key, - href: `/decisions/${d.id}`, - })) }) + results.push({ + category: "decisions", + items: matched.map((d) => ({ + label: d.name ?? d.key ?? "–", + detail: d.key, + href: `/decisions/${d.id}`, + })), + }); } // Deployments - const deployments = state.api.deployment.all.value + const deployments = state.api.deployment.all.value; if (deployments?.status === RESPONSE_STATE.SUCCESS && deployments.data) { - const matched = deployments.data.filter((d) => - match(d.name, query) || match(d.id, query) || match(d.source, query) - ).slice(0, 8) + const matched = deployments.data + .filter( + (d) => + match(d.name, query) || match(d.id, query) || match(d.source, query), + ) + .slice(0, 8); if (matched.length) - results.push({ category: "deployments", items: matched.map((d) => ({ - label: d.name ?? "Unnamed", - detail: d.id, - href: `/deployments/${d.id}`, - })) }) + results.push({ + category: "deployments", + items: matched.map((d) => ({ + label: d.name ?? "Unnamed", + detail: d.id, + href: `/deployments/${d.id}`, + })), + }); } - return results -} + return results; +}; -const lookup_signal = signal(null) +const lookup_signal = signal(null); const do_lookup = async (query, state) => { if (!query || query.length < 5) { - lookup_signal.value = null - return + lookup_signal.value = null; + return; } - lookup_signal.value = { status: "loading" } + lookup_signal.value = { status: "loading" }; - const headers = new Headers() - headers.set("Authorization", get_auth_header(state)) - const base = _url_engine_rest(state) + const headers = new Headers(); + headers.set("Authorization", get_auth_header(state)); + const base = _url_engine_rest(state); const lookups = [ - { typeKey: "goto.lookup-types.process-definition", url: `/process-definition/${query}`, href: (d) => `/processes/${d.id}`, label: (d) => d.name ?? d.key }, - { typeKey: "goto.lookup-types.process-instance", url: `/process-instance/${query}`, href: (d) => `/processes/${d.definitionId}`, label: (d) => d.id }, - { typeKey: "goto.lookup-types.task", url: `/task/${query}`, href: (d) => `/tasks/${d.id}`, label: (d) => d.name ?? d.id }, - { typeKey: "goto.lookup-types.deployment", url: `/deployment/${query}`, href: (d) => `/deployments/${d.id}`, label: (d) => d.name ?? d.id }, - ] + { + typeKey: "goto.lookup-types.process-definition", + url: `/process-definition/${query}`, + href: (d) => `/processes/${d.id}`, + label: (d) => d.name ?? d.key, + }, + { + typeKey: "goto.lookup-types.process-instance", + url: `/process-instance/${query}`, + href: (d) => `/processes/${d.definitionId}`, + label: (d) => d.id, + }, + { + typeKey: "goto.lookup-types.task", + url: `/task/${query}`, + href: (d) => `/tasks/${d.id}`, + label: (d) => d.name ?? d.id, + }, + { + typeKey: "goto.lookup-types.deployment", + url: `/deployment/${query}`, + href: (d) => `/deployments/${d.id}`, + label: (d) => d.name ?? d.id, + }, + ]; const results = await Promise.all( lookups.map(async (l) => { try { - const res = await fetch(`${base}${l.url}`, { headers }) - if (!res.ok) return null - const data = await res.json() - return { typeKey: l.typeKey, label: l.label(data), href: l.href(data) } + const res = await fetch(`${base}${l.url}`, { headers }); + if (!res.ok) return null; + const data = await res.json(); + return { typeKey: l.typeKey, label: l.label(data), href: l.href(data) }; } catch { - return null + return null; } - }) - ) + }), + ); - const found = results.filter(Boolean) - lookup_signal.value = found.length ? { status: "success", data: found } : { status: "empty" } -} + const found = results.filter(Boolean); + lookup_signal.value = found.length + ? { status: "success", data: found } + : { status: "empty" }; +}; -let debounce_timer = null +let debounce_timer = null; const GoTo = () => ( -) +); const SearchComponent = () => { const state = useContext(AppState), @@ -164,62 +219,61 @@ const SearchComponent = () => { [t] = useTranslation(), query = useSignal(""), results = useSignal([]), - selected = useSignal(0) + selected = useSignal(0); - useHotkeys("alt+k", () => setTimeout(show, 100)) + useHotkeys("alt+k", () => setTimeout(show, 100)); const update_search = (value) => { - query.value = value - results.value = collect_results(value, state, t) - selected.value = 0 + query.value = value; + results.value = collect_results(value, state, t); + selected.value = 0; - clearTimeout(debounce_timer) - debounce_timer = setTimeout(() => do_lookup(value, state), 300) - } + clearTimeout(debounce_timer); + debounce_timer = setTimeout(() => do_lookup(value, state), 300); + }; const flat_items = () => { - const items = [] + const items = []; for (const group of results.value) - for (const item of group.items) - items.push(item) + for (const item of group.items) items.push(item); - const lk = lookup_signal.value - if (lk?.status === "success") - for (const item of lk.data) - items.push(item) + const lk = lookup_signal.value; + if (lk?.status === "success") for (const item of lk.data) items.push(item); - return items - } + return items; + }; const navigate = (href) => { - close() - query.value = "" - results.value = [] - lookup_signal.value = null - route(href) - } + close(); + query.value = ""; + results.value = []; + lookup_signal.value = null; + route(href); + }; const on_keydown = (e) => { - const items = flat_items() + const items = flat_items(); if (e.key === "ArrowDown") { - e.preventDefault() - selected.value = Math.min(selected.value + 1, items.length - 1) + e.preventDefault(); + selected.value = Math.min(selected.value + 1, items.length - 1); } else if (e.key === "ArrowUp") { - e.preventDefault() - selected.value = Math.max(selected.value - 1, 0) + e.preventDefault(); + selected.value = Math.max(selected.value - 1, 0); } else if (e.key === "Enter" && items.length > 0) { - e.preventDefault() - navigate(items[selected.value].href) + e.preventDefault(); + navigate(items[selected.value].href); } - } + }; - let item_index = 0 + let item_index = 0; return (

{t("goto.title")}

- +
{ {!query.value &&

{t("goto.hint")}

} {results.value.map((group) => { - const cat = CATEGORIES.find((c) => c.key === group.category) + const cat = CATEGORIES.find((c) => c.key === group.category); return (

{cat ? t(cat.labelKey) : group.category}

{group.items.map((item) => { - const idx = item_index++ + const idx = item_index++; return ( { class={`goto-item ${idx === selected.value ? "goto-selected" : ""}`} role="option" aria-selected={idx === selected.value} - onClick={(e) => { e.preventDefault(); navigate(item.href) }} + onClick={(e) => { + e.preventDefault(); + navigate(item.href); + }} > {item.label} {item.detail && {item.detail}} - ) + ); })}
- ) + ); })} {query.value && lookup_signal.value?.status === "success" && (

{t("goto.id-lookup")}

{lookup_signal.value.data.map((item) => { - const idx = item_index++ + const idx = item_index++; return ( { class={`goto-item ${idx === selected.value ? "goto-selected" : ""}`} role="option" aria-selected={idx === selected.value} - onClick={(e) => { e.preventDefault(); navigate(item.href) }} + onClick={(e) => { + e.preventDefault(); + navigate(item.href); + }} > {item.label} {t(item.typeKey)} - ) + ); })}
)} - {query.value && results.value.length === 0 && lookup_signal.value?.status === "empty" && ( -

{t("goto.no-results")}

- )} + {query.value && + results.value.length === 0 && + lookup_signal.value?.status === "empty" && ( +

{t("goto.no-results")}

+ )}
- ) -} + ); +}; -export { GoTo } +export { GoTo }; diff --git a/src/css/style.css b/src/css/style.css index 59fa993..4b48166 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -931,6 +931,64 @@ .dashboard > section th:first-child { padding-left: 0; } + + .task-dashboard { + padding: var(--spacing-2); + overflow-y: auto; + } + .task-dashboard > header, + .task-dashboard > section > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-2); + } + .task-dashboard > section { + margin-top: var(--spacing-2); + padding: var(--spacing-2); + background: var(--background-2); + border-radius: var(--border-radius); + } + .task-dashboard h1, + .task-dashboard h2 { + margin: 0; + } + .task-dashboard h2 { + font-size: var(--h3-size); + } + .task-dashboard-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12em, 1fr)); + gap: var(--spacing-2); + margin-top: var(--spacing-2); + } + .task-dashboard-summary > a { + display: flex; + flex-direction: column; + gap: var(--spacing-1); + padding: var(--spacing-2); + color: var(--text); + background: var(--background-1); + border-radius: var(--border-radius); + text-decoration: none; + } + .task-dashboard-summary > a:hover { + background: var(--background-3); + } + .task-dashboard-summary strong { + font-size: 2em; + line-height: 1; + } + .task-dashboard #list-filter { + margin: var(--spacing-1) 0; + padding-inline: 0; + } + .task-dashboard table th { + background: var(--background-2); + } + .task-dashboard .num { + text-align: right; + } } @layer util { diff --git a/src/index.jsx b/src/index.jsx index 075d533..1ce2e3f 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -10,6 +10,7 @@ import { GoTo } from "./components/GoTo.jsx"; import { Home } from "./pages/Home.jsx"; import { DashboardPage } from "./pages/Dashboard.jsx"; import { TasksPage } from "./pages/Tasks.jsx"; +import { TaskDashboardPage } from "./pages/TaskDashboard.jsx"; import { ProcessesPage } from "./pages/Processes.jsx"; import { MigrationsPage } from "./pages/Migrations.jsx"; import { AdminPage } from "./pages/Admin.jsx"; @@ -71,15 +72,10 @@ const Routing = () => {
- + {/**/} - + + { if (state.api.task.list.value === null) void engine_rest.task.get_tasks(state); + if (state.api.task.dashboard.summary.value === null) + void engine_rest.task.get_task_dashboard_summary(state); if (state.api.process.definition.list.value === null) void engine_rest.process_definition.list(state); if (state.api.deployment.all.value === null) @@ -40,6 +42,17 @@ export const DashboardPage = () => { ); }} /> + ( + <> + {data?.total ?? 0} + {t("task_dashboard.summary.open")} + + )} + /> { it("fires the on-mount fetches for tasks, processes, deployments and decisions", () => { renderPage(state); expect(engine_rest.task.get_tasks).toHaveBeenCalled(); + expect(engine_rest.task.get_task_dashboard_summary).toHaveBeenCalled(); expect(engine_rest.process_definition.list).toHaveBeenCalled(); expect(engine_rest.deployment.all).toHaveBeenCalled(); expect(engine_rest.decision.get_decision_definitions).toHaveBeenCalled(); diff --git a/src/pages/TaskDashboard.jsx b/src/pages/TaskDashboard.jsx new file mode 100644 index 0000000..841fa0a --- /dev/null +++ b/src/pages/TaskDashboard.jsx @@ -0,0 +1,316 @@ +import { useContext, useEffect } from "preact/hooks"; +import { useLocation, useRoute } from "preact-iso"; +import { useTranslation } from "react-i18next"; +import engine_rest, { RequestState } from "../api/engine_rest.jsx"; +import { ListFilter } from "../components/ListFilter.jsx"; +import { formatRelativeDate } from "../helper/date_formatter.js"; +import { + parse_list_query, + with_manage, + write_list_query, +} from "../helper/list_query.js"; +import { AppState } from "../state.js"; +import { TASK_PAGE_SIZE, TASK_SORT_OPTIONS, TasksManage } from "./Tasks.jsx"; + +const find_saved = (signal, id) => { + if (!id || id === "all" || id === "my") return null; + return (signal.value?.data ?? []).find((f) => f.id === id) ?? null; +}; + +const current_user_id = (state) => + state.api.user.profile.value?.id ?? + state.auth.user.id.value ?? + state.auth.credentials.value?.username; + +const task_query_from_route = (state, query) => { + const { saved_filter_id, criteria } = parse_list_query(query), + saved = find_saved(state.api.filter.list, saved_filter_id), + preset = + saved_filter_id === "my" ? { assignee: current_user_id(state) } : {}; + + return { + ...preset, + ...(saved?.query ?? {}), + ...criteria, + }; +}; + +const load_results = (state, query, firstResult = 0) => { + const { sortBy, sortOrder } = parse_list_query(query); + void engine_rest.task.get_task_dashboard_results( + state, + task_query_from_route(state, query), + sortBy ?? "name", + sortOrder ?? "asc", + firstResult, + TASK_PAGE_SIZE, + ); +}; + +const TaskDashboardPage = () => { + const state = useContext(AppState), + { query } = useRoute(), + [t] = useTranslation(), + filter_status = state.api.filter.list.value?.status, + groups = state.api.group.list.value?.data ?? [], + group_ids = groups.map((group) => group.id).join(","); + + useEffect(() => { + if (state.api.filter.list.value === null) + void engine_rest.filter.get_filters(state); + if (state.api.group.list.value === null) void engine_rest.group.all(state); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + void engine_rest.task.get_task_dashboard_summary(state, groups); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [group_ids]); + + useEffect(() => { + load_results(state, query); + // Reload once task filters are available because a saved filter id in the + // URL has to be resolved before it can be executed as a query. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(query), filter_status]); + + if (query?.filters === "manage") { + return ( +
+ +
+ ); + } + + return ( +
+
+

{t("task_dashboard.title")}

+ + {t("task_dashboard.open-tasklist")} + +
+ +
+

{t("task_dashboard.assignment-by-type")}

+ +
+ +
+

{t("task_dashboard.assignment-by-group")}

+ +
+ +
+
+

{t("task_dashboard.search.title")}

+ +
+ +
+
+ ); +}; + +const SummaryCard = ({ href, value, label }) => ( + + {value} + {label} + +); + +const TaskTypeSummary = () => { + const state = useContext(AppState), + [t] = useTranslation(); + + return ( + { + const summary = state.api.task.dashboard.summary.value?.data; + return ( +
+ + + +
+ ); + }} + /> + ); +}; + +const GroupDistribution = () => { + const state = useContext(AppState), + [t] = useTranslation(); + + return ( + { + const groups = [ + ...(state.api.task.dashboard.summary.value?.data?.groups ?? []), + ].sort((a, b) => b.count - a.count || a.id.localeCompare(b.id)); + + if (groups.length === 0) + return

{t("task_dashboard.groups.empty")}

; + + return ( + + + + + + + + + {groups.map((group) => ( + + + + + ))} + +
{t("task_dashboard.groups.group")}{t("task_dashboard.groups.open-tasks")}
{group.name} + + {group.count} + +
+ ); + }} + /> + ); +}; + +const TaskSearch = () => { + const state = useContext(AppState), + { query } = useRoute(), + { route } = useLocation(), + [t] = useTranslation(), + parsed = parse_list_query(query), + result_signal = state.api.task.dashboard.results, + current = { + saved_filter_id: parsed.saved_filter_id, + sortBy: parsed.sortBy ?? "name", + sortOrder: parsed.sortOrder ?? "asc", + criteria: parsed.criteria, + }, + apply_patch = (patch) => { + route(write_list_query(window.location.href, patch), true); + }, + load_more = () => { + load_results(state, query, result_signal.value?.data?.length ?? 0); + }; + + return ( + <> + + filter && filter.id && Object.keys(filter.query ?? {}).length > 0 + } + current={current} + defaults={{ sortBy: "name", sortOrder: "asc" }} + include_my_filter + on_change={apply_patch} + on_manage={() => route(with_manage(), false)} + /> + + { + const tasks = result_signal.value?.data ?? []; + if (tasks.length === 0) + return

{t("task_dashboard.search.empty")}

; + + return ( + <> + + + + + + + + + + + + {tasks.map((task) => ( + + + + + + + + ))} + +
{t("tasks.task-list.table-headings.task-name")}{t("tasks.task-list.table-headings.assignee")} + {t("tasks.task-list.table-headings.process-definition")} + {t("tasks.task-list.table-headings.created-on")}{t("tasks.task-list.table-headings.due-in")}
+ + {task.name ?? t("dashboard.unnamed")} + + {task.assignee ?? "—"} + {task.processDefinitionId ? ( + + {task.definitionName || task.processDefinitionId} + + ) : ( + "—" + )} + + {task.created ? ( + + ) : ( + "—" + )} + + {task.due ? ( + + ) : ( + "—" + )} +
+ {result_signal.value?.hasMore ? ( + + ) : ( + {t("tasks.no-more-items")} + )} + + ); + }} + /> + + ); +}; + +export { TaskDashboardPage }; diff --git a/src/pages/TaskDashboard.test.jsx b/src/pages/TaskDashboard.test.jsx new file mode 100644 index 0000000..63baec0 --- /dev/null +++ b/src/pages/TaskDashboard.test.jsx @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { h } from "preact"; +import { render, cleanup } from "@testing-library/preact"; + +vi.mock("../api/engine_rest.jsx", async (importOriginal) => { + const actual = await importOriginal(); + const spyify = (o) => + Object.fromEntries( + Object.entries(o).map(([k, v]) => [ + k, + typeof v === "function" + ? vi.fn() + : v && typeof v === "object" + ? spyify(v) + : v, + ]), + ); + return { ...actual, default: spyify(actual.default) }; +}); + +vi.mock("../components/BPMNViewer.jsx", () => ({ + BPMNViewer: ({ xml }) => h("div", { "data-testid": "bpmn-viewer" }, xml), +})); +vi.mock("../components/CamundaForm.jsx", () => ({ + CamundaForm: ({ schema }) => + h("div", { "data-testid": "camunda-form" }, JSON.stringify(schema)), +})); +vi.mock("./StartProcessList.jsx", () => ({ + StartProcessList: () => + h("div", { "data-testid": "start-process-list" }, "start"), +})); + +let mockQuery = {}; +const routeFn = vi.fn(); +vi.mock("preact-iso", () => ({ + useRoute: () => ({ params: {}, query: mockQuery }), + useLocation: () => ({ route: routeFn, path: "/tasks-dashboard" }), +})); + +import { AppState } from "../state.js"; +import engine_rest from "../api/engine_rest.jsx"; +import { TaskDashboardPage } from "./TaskDashboard.jsx"; +import { create_mock_state, signal_response } from "../test/helpers.js"; + +const renderPage = (state) => + render(h(AppState.Provider, { value: state }, h(TaskDashboardPage, {}))); + +describe("TaskDashboardPage", () => { + let state; + beforeEach(() => { + state = create_mock_state(); + mockQuery = {}; + routeFn.mockClear(); + }); + afterEach(cleanup); + + it("loads task filters, groups, the summary and the first result page", () => { + renderPage(state); + expect(engine_rest.filter.get_filters).toHaveBeenCalled(); + expect(engine_rest.group.all).toHaveBeenCalled(); + expect(engine_rest.task.get_task_dashboard_summary).toHaveBeenCalled(); + expect(engine_rest.task.get_task_dashboard_results).toHaveBeenCalled(); + }); + + it("renders assignment counts and group distribution links", () => { + signal_response(state.api.task.dashboard.summary, { + total: 9, + assigned: 4, + unassigned: 5, + groups: [{ id: "sales", name: "Sales", count: 3 }], + }); + + const { getByText } = renderPage(state); + expect( + getByText("task_dashboard.summary.open").closest("a").href, + ).toContain("/tasks-dashboard"); + expect( + getByText("task_dashboard.summary.assigned").closest("a").href, + ).toContain("q.assigned=true"); + const group_link = getByText("3").closest("a"); + expect(group_link.getAttribute("href")).toContain("q.candidateGroup=sales"); + expect(group_link.getAttribute("href")).toContain( + "q.includeAssignedTasks=true", + ); + }); + + it("renders task search results with task and process links", () => { + signal_response(state.api.filter.list, []); + signal_response(state.api.task.dashboard.results, [ + { + id: "t1", + name: "Approve invoice", + assignee: "demo", + processDefinitionId: "pd1", + definitionName: "Invoice", + }, + ]); + + const { getByText } = renderPage(state); + expect(getByText("Approve invoice").getAttribute("href")).toBe( + "/tasks/t1/form", + ); + expect(getByText("Invoice").getAttribute("href")).toBe("/processes/pd1"); + expect(getByText("demo")).toBeTruthy(); + }); +}); diff --git a/src/pages/Tasks.jsx b/src/pages/Tasks.jsx index f031e63..ccbafb4 100644 --- a/src/pages/Tasks.jsx +++ b/src/pages/Tasks.jsx @@ -12,7 +12,6 @@ import { BPMNViewer } from "../components/BPMNViewer.jsx"; import { Tabs } from "../components/Tabs.jsx"; import { ListFilter } from "../components/ListFilter.jsx"; import { ManageFilters } from "../components/ManageFilters.jsx"; -import * as formatter from "../helper/date_formatter.js"; import { filter_share_link, parse_list_query, @@ -45,29 +44,107 @@ const SORT_OPTIONS = [ const FILTER_KEYS = [ { key: "assignee", nameKey: "tasks.filter_keys.assignee", type: "string" }, - { key: "assigneeLike", nameKey: "tasks.filter_keys.assigneeLike", type: "string" }, - { key: "candidateGroup", nameKey: "tasks.filter_keys.candidateGroup", type: "string" }, - { key: "candidateUser", nameKey: "tasks.filter_keys.candidateUser", type: "string" }, - { key: "involvedUser", nameKey: "tasks.filter_keys.involvedUser", type: "string" }, - { key: "unassigned", nameKey: "tasks.filter_keys.unassigned", type: "boolean" }, - { key: "processDefinitionKey", nameKey: "tasks.filter_keys.processDefinitionKey", type: "string" }, - { key: "processDefinitionName", nameKey: "tasks.filter_keys.processDefinitionName", type: "string" }, - { key: "processDefinitionNameLike", nameKey: "tasks.filter_keys.processDefinitionNameLike", type: "string" }, - { key: "processInstanceBusinessKey", nameKey: "tasks.filter_keys.processInstanceBusinessKey", type: "string" }, - { key: "processInstanceBusinessKeyLike", nameKey: "tasks.filter_keys.processInstanceBusinessKeyLike", type: "string" }, - { key: "taskDefinitionKey", nameKey: "tasks.filter_keys.taskDefinitionKey", type: "string" }, - { key: "taskDefinitionKeyLike", nameKey: "tasks.filter_keys.taskDefinitionKeyLike", type: "string" }, + { + key: "assigneeLike", + nameKey: "tasks.filter_keys.assigneeLike", + type: "string", + }, + { + key: "candidateGroup", + nameKey: "tasks.filter_keys.candidateGroup", + type: "string", + }, + { + key: "includeAssignedTasks", + nameKey: "tasks.filter_keys.includeAssignedTasks", + type: "boolean", + }, + { + key: "candidateUser", + nameKey: "tasks.filter_keys.candidateUser", + type: "string", + }, + { + key: "involvedUser", + nameKey: "tasks.filter_keys.involvedUser", + type: "string", + }, + { key: "assigned", nameKey: "tasks.filter_keys.assigned", type: "boolean" }, + { + key: "unassigned", + nameKey: "tasks.filter_keys.unassigned", + type: "boolean", + }, + { + key: "processDefinitionKey", + nameKey: "tasks.filter_keys.processDefinitionKey", + type: "string", + }, + { + key: "processDefinitionName", + nameKey: "tasks.filter_keys.processDefinitionName", + type: "string", + }, + { + key: "processDefinitionNameLike", + nameKey: "tasks.filter_keys.processDefinitionNameLike", + type: "string", + }, + { + key: "processInstanceBusinessKey", + nameKey: "tasks.filter_keys.processInstanceBusinessKey", + type: "string", + }, + { + key: "processInstanceBusinessKeyLike", + nameKey: "tasks.filter_keys.processInstanceBusinessKeyLike", + type: "string", + }, + { + key: "taskDefinitionKey", + nameKey: "tasks.filter_keys.taskDefinitionKey", + type: "string", + }, + { + key: "taskDefinitionKeyLike", + nameKey: "tasks.filter_keys.taskDefinitionKeyLike", + type: "string", + }, { key: "name", nameKey: "tasks.filter_keys.name", type: "string" }, { key: "nameLike", nameKey: "tasks.filter_keys.nameLike", type: "string" }, - { key: "description", nameKey: "tasks.filter_keys.description", type: "string" }, - { key: "descriptionLike", nameKey: "tasks.filter_keys.descriptionLike", type: "string" }, + { + key: "description", + nameKey: "tasks.filter_keys.description", + type: "string", + }, + { + key: "descriptionLike", + nameKey: "tasks.filter_keys.descriptionLike", + type: "string", + }, { key: "priority", nameKey: "tasks.filter_keys.priority", type: "number" }, { key: "dueBefore", nameKey: "tasks.filter_keys.dueBefore", type: "date" }, { key: "dueAfter", nameKey: "tasks.filter_keys.dueAfter", type: "date" }, - { key: "followUpBefore", nameKey: "tasks.filter_keys.followUpBefore", type: "date" }, - { key: "followUpAfter", nameKey: "tasks.filter_keys.followUpAfter", type: "date" }, - { key: "createdBefore", nameKey: "tasks.filter_keys.createdBefore", type: "date" }, - { key: "createdAfter", nameKey: "tasks.filter_keys.createdAfter", type: "date" }, + { + key: "followUpBefore", + nameKey: "tasks.filter_keys.followUpBefore", + type: "date", + }, + { + key: "followUpAfter", + nameKey: "tasks.filter_keys.followUpAfter", + type: "date", + }, + { + key: "createdBefore", + nameKey: "tasks.filter_keys.createdBefore", + type: "date", + }, + { + key: "createdAfter", + nameKey: "tasks.filter_keys.createdAfter", + type: "date", + }, { key: "active", nameKey: "tasks.filter_keys.active", type: "boolean" }, { key: "suspended", nameKey: "tasks.filter_keys.suspended", type: "boolean" }, ]; @@ -665,11 +742,6 @@ const GroupsList = () => { const SetGroupsButton = () => { const state = useContext(AppState), [t] = useTranslation(), - { - api: { - task: { identity_links }, - }, - } = state, close = () => document.getElementById("add_groups").close(), show = () => document.getElementById("add_groups").showModal(), group_state = useSignal(null), @@ -1205,7 +1277,7 @@ const HistoryTab = () => { { api: { history: { user_operation }, - task: { one, comment }, + task: { comment }, }, } = state; @@ -1279,4 +1351,10 @@ const task_tabs = [ }, ]; -export { TasksPage }; +export { + FILTER_KEYS as TASK_FILTER_KEYS, + SORT_OPTIONS as TASK_SORT_OPTIONS, + TASK_PAGE_SIZE, + TasksManage, + TasksPage, +}; diff --git a/src/state.js b/src/state.js index f63ad2e..509c7e2 100644 --- a/src/state.js +++ b/src/state.js @@ -128,6 +128,10 @@ const createAppState = () => { task: { list: signal(null), one: signal(null), + dashboard: { + summary: signal(null), + results: signal(null), + }, by_process_instance: signal(null), form: signal(null), rendered_form: signal(null), diff --git a/src/state.test.js b/src/state.test.js index c6b9968..a9d88e6 100644 --- a/src/state.test.js +++ b/src/state.test.js @@ -53,6 +53,8 @@ describe("state", () => { const { api } = createAppState(); expect(api.process.definition.list.value).toBeNull(); expect(api.process.instance.one.value).toBeNull(); + expect(api.task.dashboard.summary.value).toBeNull(); + expect(api.task.dashboard.results.value).toBeNull(); expect(api.task.comment.list.value).toBeNull(); expect(api.authorization.all.value).toBeNull(); expect(api.batch.list.value).toBeNull();