diff --git a/public/locales/de-DE/translation.json b/public/locales/de-DE/translation.json index 5f7d6a9..ef84db3 100644 --- a/public/locales/de-DE/translation.json +++ b/public/locales/de-DE/translation.json @@ -335,6 +335,15 @@ "groups": "Gruppen", "users": "Benutzer" }, + "variables": { + "add": "Variable hinzufügen", + "name": "Variablenname", + "type": "Variablentyp", + "value": "Variablenwert", + "confirm-delete": "Variable {{name}} löschen?", + "success-updated": "Variable aktualisiert.", + "success-deleted": "Variable gelöscht." + }, "called-instances": { "called-process-instance": "Aufgerufene Prozessinstanz", "process-definition": "Prozessdefinition" diff --git a/public/locales/en-US/translation.json b/public/locales/en-US/translation.json index ac20fbe..ea0d4ee 100644 --- a/public/locales/en-US/translation.json +++ b/public/locales/en-US/translation.json @@ -335,6 +335,15 @@ "groups": "Groups", "users": "Users" }, + "variables": { + "add": "Add Variable", + "name": "Variable Name", + "type": "Variable Type", + "value": "Variable Value", + "confirm-delete": "Delete variable {{name}}?", + "success-updated": "Variable updated.", + "success-deleted": "Variable deleted." + }, "called-instances": { "called-process-instance": "Called Process Instance", "process-definition": "Process Definition" diff --git a/src/api/resources/process_instance.js b/src/api/resources/process_instance.js index d1d6c02..1f7a964 100644 --- a/src/api/resources/process_instance.js +++ b/src/api/resources/process_instance.js @@ -1,4 +1,4 @@ -import { GET, POST } from "../helper.jsx"; +import { DELETE, GET, POST, PUT } from "../helper.jsx"; const get_process_instance = (state, instance_id) => GET( @@ -33,6 +33,22 @@ const get_process_instance_variables = (state, instance_id) => state.api.process.instance.variables, ); +const update_process_instance_variable = (state, instance_id, variable_name, body) => + PUT( + `/process-instance/${instance_id}/variables/${encodeURIComponent(variable_name)}`, + body, + state, + state.api.process.instance.variable_update, + ); + +const delete_process_instance_variable = (state, instance_id, variable_name) => + DELETE( + `/process-instance/${instance_id}/variables/${encodeURIComponent(variable_name)}`, + null, + state, + state.api.process.instance.variable_delete, + ); + const get_called_process_instances = (state, instance_id) => GET( `/process-instance?superProcessInstance=${instance_id}`, @@ -105,6 +121,8 @@ const modify_process_instance_async = ( const process_instance = { one: get_process_instance, variables: get_process_instance_variables, + update_variable: update_process_instance_variable, + delete_variable: delete_process_instance_variable, called: get_called_process_instances, count: get_process_instance_count, all: get_all_process_instances, diff --git a/src/api/resources/process_instance.test.js b/src/api/resources/process_instance.test.js index 90bb054..3d27de7 100644 --- a/src/api/resources/process_instance.test.js +++ b/src/api/resources/process_instance.test.js @@ -1,11 +1,13 @@ import { describe, it, vi, beforeEach } from "vitest"; vi.mock("../helper.jsx", () => ({ + DELETE: vi.fn(), GET: vi.fn(), POST: vi.fn(), + PUT: vi.fn(), })); -import { GET, POST } from "../helper.jsx"; +import { DELETE, GET, POST, PUT } from "../helper.jsx"; import { create_mock_state, expect_api_call } from "../../test/helpers.js"; import process_instance from "./process_instance.js"; @@ -33,6 +35,27 @@ describe("api/resources/process_instance", () => { }); }); + it("update_variable() PUTs a variable payload", () => { + const body = { type: "Integer", value: 42 }; + process_instance.update_variable(state, "inst-1", "amount total", body); + expect_api_call(PUT, { + url: "/process-instance/inst-1/variables/amount%20total", + body, + state, + signal: state.api.process.instance.variable_update, + }); + }); + + it("delete_variable() DELETEs the variable endpoint", () => { + process_instance.delete_variable(state, "inst-1", "amount total"); + expect_api_call(DELETE, { + url: "/process-instance/inst-1/variables/amount%20total", + body: null, + state, + signal: state.api.process.instance.variable_delete, + }); + }); + it("called() GETs sub process instances by superProcessInstance", () => { process_instance.called(state, "inst-1"); expect_api_call(GET, { diff --git a/src/pages/Processes.jsx b/src/pages/Processes.jsx index d6ac79e..be7b5c4 100644 --- a/src/pages/Processes.jsx +++ b/src/pages/Processes.jsx @@ -686,7 +686,7 @@ const DefinitionsEmpty = () => { {t("processes.empty.how-to")} @@ -928,8 +928,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 ( @@ -1005,11 +1004,47 @@ const ProcessInstance = ({ id, startTime, state, businessKey }) => { ); }; +const parse_variable_value = (type, value) => { + if (value === "") return value; + const normalized = type?.toLowerCase(); + if (normalized === "boolean") { + if (value.toLowerCase() === "true") return true; + if (value.toLowerCase() === "false") return false; + } + if (["integer", "long", "short"].includes(normalized)) { + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? value : parsed; + } + if (normalized === "double") { + const parsed = Number.parseFloat(value); + return Number.isNaN(parsed) ? value : parsed; + } + return value; +}; + +const format_variable_value = (value) => { + if (value === null) return "null"; + if (typeof value === "object") return JSON.stringify(value); + return `${value}`; +}; + +const VariableActionResult = ({ signal, success }) => ( + null} + on_success={() =>

{success}

} + /> +); + const InstanceVariables = () => { const state = useContext(AppState), { params, query } = useRoute(), history_mode = query.history === "true", [t] = useTranslation(), + creating = useSignal(false), + editing = useSignal(null), + create_form = useSignal({ name: "", type: "String", value: "" }), + edit_form = useSignal({ type: "", value: "", valueInfo: undefined }), selection_exists = state.api.process.instance.variables.value !== null && state.api.process.instance.variables.value.data !== null && @@ -1027,42 +1062,232 @@ const InstanceVariables = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [params.selection_id, history_mode]); + const refresh = () => + engine_rest.process_instance.variables(state, params.selection_id); + + const variable_payload = (form) => ({ + type: form.type, + value: parse_variable_value(form.type, form.value), + ...(form.valueInfo ? { valueInfo: form.valueInfo } : {}), + }); + + const update_variable = (name, form) => + Promise.resolve( + engine_rest.process_instance.update_variable( + state, + params.selection_id, + name, + variable_payload(form), + ), + ).then(() => refresh()); + + const start_edit = (name, variable) => { + editing.value = name; + edit_form.value = { + type: variable.type, + value: format_variable_value(variable.value), + valueInfo: variable.valueInfo, + }; + }; + + const submit_create = (e) => { + e.preventDefault(); + void update_variable(create_form.value.name, create_form.value).then(() => { + creating.value = false; + create_form.value = { name: "", type: "String", value: "" }; + }); + }; + + const submit_edit = (e, name) => { + e.preventDefault(); + void update_variable(name, edit_form.value).then(() => { + editing.value = null; + }); + }; + + const delete_variable = (name) => { + if (!window.confirm(t("processes.variables.confirm-delete", { name }))) { + return; + } + void Promise.resolve( + engine_rest.process_instance.delete_variable( + state, + params.selection_id, + name, + ), + ).then(() => refresh()); + }; + + const live_rows = () => + Object.entries(state.api.process.instance.variables.value.data).map( + ([name, variable], index) => { + const form_id = `variable-edit-${index}`; + return ( + + {name} + {editing.value === name ? ( + <> + + + (edit_form.value = { + ...edit_form.peek(), + type: e.currentTarget.value, + }) + } + required + /> + + + + (edit_form.value = { + ...edit_form.peek(), + value: e.currentTarget.value, + }) + } + /> + + +
submit_edit(e, name)}> +
+ + +
+
+ + + ) : ( + <> + {variable.type} + {format_variable_value(variable.value)} + +
+ + +
+ + + )} + + ); + }, + ); + + const history_rows = () => + state.api.process.instance.variables.value.data.map( + ({ name, type, value }) => ( + + {name} + {type} + {format_variable_value(value)} + + ), + ); + return (
+ {!history_mode ? ( + <> + + +
+ +
+ {creating.value ? ( +
+ + + (create_form.value = { + ...create_form.peek(), + name: e.currentTarget.value, + }) + } + required + /> + + + (create_form.value = { + ...create_form.peek(), + type: e.currentTarget.value, + }) + } + required + /> + + + (create_form.value = { + ...create_form.peek(), + value: e.currentTarget.value, + }) + } + /> +
+ + +
+
+ ) : null} + + ) : null} - + {!history_mode ? : null} {selection_exists - ? !history_mode - ? Object.entries( - state.api.process.instance.variables.value.data, - ).map( - // eslint-disable-next-line react/jsx-key - ([name, { type, value }]) => ( - - - - - - ), - ) - : state.api.process.instance.variables.value.data.map( - // eslint-disable-next-line react/jsx-key - ({ name, type, value }) => ( - - - - - - ), - ) + ? !history_mode ? live_rows() : history_rows() : t("common.loading")}
{t("common.name")} {t("common.type")} {t("common.value")}{t("common.actions")}{t("common.actions")}
{name}{type}{value}
{name}{type}{value}
@@ -1464,13 +1689,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..079659e 100644 --- a/src/pages/Processes.test.jsx +++ b/src/pages/Processes.test.jsx @@ -405,6 +405,106 @@ describe("ProcessesPage — instance details", () => { expect(getByText("42")).toBeTruthy(); }); + it("adds a live variable", async () => { + mockParams = { + definition_id: "proc:1", + panel: "instances", + selection_id: "inst-9999", + sub_panel: "vars", + }; + signal_response(state.api.process.instance.variables, {}); + const { container, getByLabelText, getByText } = renderPage(state); + + fireEvent.click(getByText("processes.variables.add")); + fireEvent.input(getByLabelText("processes.variables.name"), { + target: { value: "priority" }, + }); + fireEvent.input(getByLabelText("processes.variables.type"), { + target: { value: "Integer" }, + }); + fireEvent.input(getByLabelText("processes.variables.value"), { + target: { value: "100" }, + }); + fireEvent.submit(container.querySelector("form")); + await Promise.resolve(); + await Promise.resolve(); + + expect(engine_rest.process_instance.update_variable).toHaveBeenCalled(); + expect(engine_rest.process_instance.update_variable.mock.lastCall[0]).toBe( + state, + ); + expect(engine_rest.process_instance.update_variable.mock.lastCall[1]).toBe( + "inst-9999", + ); + expect(engine_rest.process_instance.update_variable.mock.lastCall[2]).toBe( + "priority", + ); + expect(engine_rest.process_instance.update_variable.mock.lastCall[3]).toEqual( + { type: "Integer", value: 100 }, + ); + }); + + it("updates a live variable", async () => { + mockParams = { + definition_id: "proc:1", + panel: "instances", + selection_id: "inst-9999", + sub_panel: "vars", + }; + signal_response(state.api.process.instance.variables, { + amount: { type: "Integer", value: 42 }, + }); + const { container, getByLabelText, getByText } = renderPage(state); + + fireEvent.click(getByText("common.edit")); + fireEvent.input(getByLabelText("processes.variables.value"), { + target: { value: "99" }, + }); + fireEvent.submit(container.querySelector("form")); + await Promise.resolve(); + await Promise.resolve(); + + expect(engine_rest.process_instance.update_variable).toHaveBeenCalled(); + expect(engine_rest.process_instance.update_variable.mock.lastCall[1]).toBe( + "inst-9999", + ); + expect(engine_rest.process_instance.update_variable.mock.lastCall[2]).toBe( + "amount", + ); + expect(engine_rest.process_instance.update_variable.mock.lastCall[3]).toEqual( + { type: "Integer", value: 99 }, + ); + }); + + it("deletes a live variable after confirmation", async () => { + mockParams = { + definition_id: "proc:1", + panel: "instances", + selection_id: "inst-9999", + sub_panel: "vars", + }; + signal_response(state.api.process.instance.variables, { + amount: { type: "Integer", value: 42 }, + }); + const prev = window.confirm; + window.confirm = vi.fn(() => true); + const { getByText } = renderPage(state); + + fireEvent.click(getByText("common.delete")); + await Promise.resolve(); + await Promise.resolve(); + + expect(window.confirm).toHaveBeenCalled(); + expect(engine_rest.process_instance.delete_variable).toHaveBeenCalled(); + expect(engine_rest.process_instance.delete_variable.mock.lastCall[1]).toBe( + "inst-9999", + ); + expect(engine_rest.process_instance.delete_variable.mock.lastCall[2]).toBe( + "amount", + ); + window.confirm = prev; + }); + it("variables sub-panel fetches history variables in history mode", () => { mockParams = { definition_id: "proc:1", @@ -423,6 +523,23 @@ describe("ProcessesPage — instance details", () => { expect(getByText("amount")).toBeTruthy(); }); + it("keeps history variables read-only", () => { + mockParams = { + definition_id: "proc:1", + panel: "instances", + selection_id: "inst-9999", + sub_panel: "vars", + }; + mockQuery = { history: "true" }; + signal_response(state.api.process.instance.variables, [ + { name: "amount", type: "Integer", value: 7 }, + ]); + const { queryByText } = renderPage(state); + + expect(queryByText("processes.variables.add")).toBeNull(); + expect(queryByText("common.actions")).toBeNull(); + }); + it("instance-incidents sub-panel fetches and renders incidents", () => { mockParams = { definition_id: "proc:1", diff --git a/src/state.js b/src/state.js index f63ad2e..a3b86d5 100644 --- a/src/state.js +++ b/src/state.js @@ -119,6 +119,8 @@ const createAppState = () => { list: signal(null), count: signal(null), variables: signal(null), + variable_update: signal(null), + variable_delete: signal(null), by_defintion_id: signal(null), activity_instances: signal(null), modification: signal(null),