diff --git a/public/locales/de-DE/translation.json b/public/locales/de-DE/translation.json index 5f7d6a9..e904084 100644 --- a/public/locales/de-DE/translation.json +++ b/public/locales/de-DE/translation.json @@ -388,7 +388,21 @@ "startedAfter": "startedAfter", "finished": "finished" }, - "manage_title": "Prozessinstanz-Filter verwalten" + "manage_title": "Prozessinstanz-Filter verwalten", + "delete": { + "open": "Auswahl löschen", + "title": "Laufende Prozessinstanzen löschen", + "reason": "Löschgrund", + "skip-custom-listeners": "Benutzerdefinierte Listener überspringen", + "skip-subprocesses": "Unterprozesse überspringen", + "skip-io-mappings": "IO-Mappings überspringen", + "confirm": "Instanzen löschen", + "select-all": "Alle Instanzen auswählen", + "count": "{{count}} ausgewählt", + "batch-created": "Batch erstellt:", + "view-batch": "Batch anzeigen", + "success": "Lösch-Batch gestartet." + } }, "history-mode-na": "Live-Daten – der Verlaufsmodus gilt hier nicht.", "filter": { diff --git a/public/locales/en-US/translation.json b/public/locales/en-US/translation.json index ac20fbe..67f89d7 100644 --- a/public/locales/en-US/translation.json +++ b/public/locales/en-US/translation.json @@ -388,7 +388,21 @@ "startedAfter": "startedAfter", "finished": "finished" }, - "manage_title": "Manage process-instance filters" + "manage_title": "Manage process-instance filters", + "delete": { + "open": "Delete selected", + "title": "Delete running process instances", + "reason": "Delete reason", + "skip-custom-listeners": "Skip custom listeners", + "skip-subprocesses": "Skip subprocesses", + "skip-io-mappings": "Skip IO mappings", + "confirm": "Delete instances", + "select-all": "Select all instances", + "count": "{{count}} selected", + "batch-created": "Batch created:", + "view-batch": "View batch", + "success": "Delete batch started." + } }, "history-mode-na": "Live data — history mode does not apply here.", "filter": { diff --git a/src/api/resources/process_instance.js b/src/api/resources/process_instance.js index d1d6c02..50252ef 100644 --- a/src/api/resources/process_instance.js +++ b/src/api/resources/process_instance.js @@ -102,6 +102,19 @@ const modify_process_instance_async = ( state.api.process.instance.modification, ); +/** + * Deletes multiple running process instances asynchronously. + * Returns a Batch the user can monitor on the Batches page. + * @see https://docs.camunda.org/manual/latest/reference/rest/process-instance/post-delete/ + */ +const delete_process_instances_async = (state, body) => + POST( + "/process-instance/delete", + body, + state, + state.api.process.instance.delete_async, + ); + const process_instance = { one: get_process_instance, variables: get_process_instance_variables, @@ -113,6 +126,7 @@ const process_instance = { activity_instances: get_activity_instances, modify: modify_process_instance, modify_async: modify_process_instance_async, + delete_async: delete_process_instances_async, }; export default process_instance; diff --git a/src/api/resources/process_instance.test.js b/src/api/resources/process_instance.test.js index 90bb054..1a64bd7 100644 --- a/src/api/resources/process_instance.test.js +++ b/src/api/resources/process_instance.test.js @@ -122,4 +122,21 @@ describe("api/resources/process_instance", () => { signal: state.api.process.instance.modification, }); }); + + it("delete_async() POSTs a batch delete to /process-instance/delete", () => { + const body = { + processInstanceIds: ["inst-1", "inst-2"], + deleteReason: "obsolete", + skipCustomListeners: true, + skipSubprocesses: true, + skipIoMappings: false, + }; + process_instance.delete_async(state, body); + expect_api_call(POST, { + url: "/process-instance/delete", + body, + state, + signal: state.api.process.instance.delete_async, + }); + }); }); diff --git a/src/css/components.css b/src/css/components.css index 5bed3e4..dd64202 100644 --- a/src/css/components.css +++ b/src/css/components.css @@ -869,6 +869,58 @@ div.accordion { color: var(--text-2); } +/* process instance bulk actions */ + +.bulk-instance-delete { + margin-bottom: var(--spacing-2); + padding: var(--spacing-1); + border: var(--border); + border-radius: var(--border-radius); + background: var(--background-1); +} + +.bulk-instance-delete fieldset { + display: grid; + grid-template-columns: minmax(14rem, 1fr) repeat(3, max-content); + gap: var(--spacing-1); + align-items: end; +} + +.bulk-instance-delete legend { + grid-column: 1 / -1; +} + +.bulk-instance-delete label { + display: flex; + flex-direction: column; + gap: var(--spacing-half); +} + +.bulk-instance-delete label:has(input[type="checkbox"]) { + flex-direction: row; + align-items: center; +} + +.bulk-instance-delete input[type="checkbox"] { + width: auto; +} + +.bulk-instance-delete .button-group { + grid-column: 1 / -1; + display: flex; + gap: var(--spacing-1); +} + +.bulk-instance-delete .info-box { + margin-top: var(--spacing-1); +} + +@media (max-width: 840px) { + .bulk-instance-delete fieldset { + grid-template-columns: 1fr; + } +} + /* task detail cards */ .task-cards { diff --git a/src/pages/Processes.jsx b/src/pages/Processes.jsx index d6ac79e..b33807f 100644 --- a/src/pages/Processes.jsx +++ b/src/pages/Processes.jsx @@ -61,6 +61,19 @@ const instance_params_from_query = (state, query) => { }; }; +const process_instance_delete_body = (form, processInstanceIds) => { + const data = new FormData(form), + deleteReason = String(data.get("deleteReason") ?? "").trim(); + + return { + processInstanceIds, + ...(deleteReason ? { deleteReason } : {}), + skipCustomListeners: data.has("skipCustomListeners"), + skipSubprocesses: data.has("skipSubprocesses"), + skipIoMappings: data.has("skipIoMappings"), + }; +}; + const SORT_OPTIONS = [ { key: "name", nameKey: "processes.sort.name" }, { key: "key", nameKey: "processes.sort.key" }, @@ -686,7 +699,7 @@ const DefinitionsEmpty = () => { {t("processes.empty.how-to")} @@ -817,7 +830,10 @@ const Instances = () => { { route } = useLocation(), [t] = useTranslation(), history_mode = query.history === "true", - list = state.api.process.instance.list; + list = state.api.process.instance.list, + delete_result = state.api.process.instance.delete_async, + selected = useSignal(new Set()), + delete_open = useSignal(false); useEffect(() => { hydrate_signal( @@ -857,9 +873,17 @@ const Instances = () => { .join("&"); useEffect(() => { + selected.value = new Set(); + delete_open.value = false; + delete_result.value = null; if (!params.selection_id) fetch_page(0); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [params.definition_id, params.selection_id, history_mode, criteria_signature]); + }, [ + params.definition_id, + params.selection_id, + history_mode, + criteria_signature, + ]); const load_more = () => fetch_page(list.value?.data?.length ?? 0); @@ -879,6 +903,51 @@ const Instances = () => { return ; } + const rows = list.value?.data ?? []; + const selected_ids = [...selected.value]; + const has_selection = selected_ids.length > 0; + const all_selected = + rows.length > 0 && rows.every((row) => selected.value.has(row.id)); + const delete_loading = delete_result.value?.status === RESPONSE_STATE.LOADING; + + const toggle_instance = (id) => { + const next = new Set(selected.value); + if (next.has(id)) next.delete(id); + else next.add(id); + selected.value = next; + }; + + const toggle_all_instances = () => { + const next = new Set(selected.value); + if (all_selected) rows.forEach((row) => next.delete(row.id)); + else rows.forEach((row) => next.add(row.id)); + selected.value = next; + }; + + const open_delete = () => { + if (!has_selection) return; + delete_result.value = null; + delete_open.value = true; + }; + + const cancel_delete = () => { + delete_open.value = false; + delete_result.value = null; + }; + + const submit_delete = async (event) => { + event.preventDefault(); + if (!has_selection || delete_loading) return; + + const body = process_instance_delete_body( + event.currentTarget, + selected_ids, + ); + await engine_rest.process_instance.delete_async(state, body); + selected.value = new Set(); + fetch_page(0); + }; + return !params?.selection_id ? ( <> { on_change={apply_patch} on_manage={open_manage} /> + {!history_mode ? ( +
+
+ + {has_selection ? ( + + {t("processes.instance.delete.count", { + count: selected_ids.length, + })} + + ) : null} +
+
+ ) : null} + {delete_open.value ? ( +
+
+ {t("processes.instance.delete.title")} + + + + +
+ + +
+
+ null} + on_success={() => { + const batch = delete_result.value?.data; + return batch?.id ? ( +

+ {t("processes.instance.delete.batch-created")}{" "} + + {t("processes.instance.delete.view-batch")} + +

+ ) : ( +

{t("processes.instance.delete.success")}

+ ); + }} + /> + + ) : null}
+ {!history_mode ? ( + + ) : null} @@ -900,7 +1048,11 @@ const Instances = () => { - +
+ + {t("common.id")} {t("processes.start-time")} {t("common.state")}
@@ -917,9 +1069,17 @@ const Instances = () => { ); }; -const InstanceTableRows = () => +const InstanceTableRows = ({ selectable = false, selected, on_toggle }) => useContext(AppState).api.process.instance.list.value?.data?.map( - (instance) => , + (instance) => ( + on_toggle?.(instance.id)} + {...instance} + /> + ), ) ?? null; const InstanceDetails = () => { @@ -928,8 +1088,7 @@ const InstanceDetails = () => { params: { selection_id, definition_id, panel }, query, } = useRoute(), - history_mode = query.history === "true", - [t] = useTranslation(); + history_mode = query.history === "true"; if (selection_id) { if ( @@ -983,10 +1142,28 @@ const InstanceDetailsDescription = () => { ); }; -const ProcessInstance = ({ id, startTime, state, businessKey }) => { +const ProcessInstance = ({ + id, + startTime, + state, + businessKey, + selectable = false, + checked = false, + on_toggle, +}) => { const { params, query } = useRoute(); return ( - + + {selectable ? ( + e.stopPropagation()}> + + + ) : null} { ? !history_mode ? Object.entries( state.api.process.instance.variables.value.data, - ).map( - // eslint-disable-next-line react/jsx-key - ([name, { type, value }]) => ( - - {name} - {type} - {value} - - ), - ) + ).map(([name, { type, value }]) => ( + + {name} + {type} + {value} + + )) : state.api.process.instance.variables.value.data.map( - // eslint-disable-next-line react/jsx-key ({ name, type, value }) => ( - + {name} {type} {value} @@ -1464,13 +1637,6 @@ const JobDefinitions = () => { ); }; -const BackToListBtn = ({ url, title, className }) => ( - - - - -); - const DefinitionsManage = () => { const state = useContext(AppState), { route } = useLocation(), diff --git a/src/pages/Processes.test.jsx b/src/pages/Processes.test.jsx index 60c4b48..b815744 100644 --- a/src/pages/Processes.test.jsx +++ b/src/pages/Processes.test.jsx @@ -304,6 +304,93 @@ describe("ProcessesPage — definition tabs", () => { expect(getByText("BK-1")).toBeTruthy(); }); + it("instances tab disables bulk delete until an instance is selected", () => { + mockParams = { definition_id: "proc:1", panel: "instances" }; + signal_response(state.api.process.instance.list, [ + { + id: "abcdef1234567890", + startTime: "2024-01-01T00:00:00Z", + state: "ACTIVE", + businessKey: "BK-1", + }, + ]); + const { container, getByText } = renderPage(state); + const open_delete = getByText("processes.instance.delete.open"); + expect(open_delete.disabled).toBe(true); + fireEvent.click(container.querySelector('tbody input[type="checkbox"]')); + expect(open_delete.disabled).toBe(false); + }); + + it("instances tab sends selected instances to the async delete endpoint", async () => { + mockParams = { definition_id: "proc:1", panel: "instances" }; + signal_response(state.api.process.instance.list, [ + { + id: "abcdef1234567890", + startTime: "2024-01-01T00:00:00Z", + state: "ACTIVE", + businessKey: "BK-1", + }, + { + id: "fedcba0987654321", + startTime: "2024-01-02T00:00:00Z", + state: "ACTIVE", + businessKey: "BK-2", + }, + ]); + engine_rest.process_instance.delete_async.mockImplementationOnce( + async (next_state) => { + signal_response(next_state.api.process.instance.delete_async, { + id: "batch-1", + }); + }, + ); + + const { container, getByText } = renderPage(state); + fireEvent.click(container.querySelector('tbody input[type="checkbox"]')); + fireEvent.click(getByText("processes.instance.delete.open")); + fireEvent.input(container.querySelector('input[name="deleteReason"]'), { + target: { value: "obsolete" }, + }); + fireEvent.click( + container.querySelector('input[name="skipCustomListeners"]'), + ); + fireEvent.click(container.querySelector('input[name="skipSubprocesses"]')); + fireEvent.submit(container.querySelector(".bulk-instance-delete")); + + await Promise.resolve(); + await Promise.resolve(); + + expect(engine_rest.process_instance.delete_async).toHaveBeenCalled(); + const call = engine_rest.process_instance.delete_async.mock.lastCall; + expect(call[0]).toBe(state); + expect(call[1]).toEqual({ + processInstanceIds: ["abcdef1234567890"], + deleteReason: "obsolete", + skipCustomListeners: true, + skipSubprocesses: true, + skipIoMappings: false, + }); + expect( + getByText("processes.instance.delete.view-batch").getAttribute("href"), + ).toBe("/batches/batch-1"); + }); + + it("instances tab hides bulk delete in history mode", () => { + mockParams = { definition_id: "proc:1", panel: "instances" }; + mockQuery = { history: "true" }; + signal_response(state.api.process.instance.list, [ + { + id: "abcdef1234567890", + startTime: "2024-01-01T00:00:00Z", + state: "COMPLETED", + businessKey: "BK-1", + }, + ]); + const { container, queryByText } = renderPage(state); + expect(queryByText("processes.instance.delete.open")).toBeNull(); + expect(container.querySelector('tbody input[type="checkbox"]')).toBeNull(); + }); + it("incidents tab fetches and renders definition incidents", () => { mockParams = { definition_id: "proc:1", panel: "incidents" }; signal_response(state.api.history.incident.by_process_definition, [ diff --git a/src/state.js b/src/state.js index f63ad2e..c0e46dc 100644 --- a/src/state.js +++ b/src/state.js @@ -122,6 +122,7 @@ const createAppState = () => { by_defintion_id: signal(null), activity_instances: signal(null), modification: signal(null), + delete_async: signal(null), saved_filters: signal(null), }, }, diff --git a/src/state.test.js b/src/state.test.js index c6b9968..7a9305e 100644 --- a/src/state.test.js +++ b/src/state.test.js @@ -53,6 +53,7 @@ describe("state", () => { const { api } = createAppState(); expect(api.process.definition.list.value).toBeNull(); expect(api.process.instance.one.value).toBeNull(); + expect(api.process.instance.delete_async.value).toBeNull(); expect(api.task.comment.list.value).toBeNull(); expect(api.authorization.all.value).toBeNull(); expect(api.batch.list.value).toBeNull();