Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions public/locales/de-DE/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,15 @@
"latestVersion": "latestVersion",
"decisionRequirementsDefinitionKey": "decisionRequirementsDefinitionKey"
},
"instances": {
"title": "Decision-Instanzen",
"refresh": "Aktualisieren",
"empty": "Keine Decision-Instanzen gefunden.",
"id": "Decision-Instanz-ID",
"evaluation-time": "Auswertungszeit",
"process-instance": "Prozessinstanz",
"activity": "Aktivität"
},
"filter": {
"manage_title": "Decision-Filter verwalten"
}
Expand Down
9 changes: 9 additions & 0 deletions public/locales/en-US/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,15 @@
"latestVersion": "latestVersion",
"decisionRequirementsDefinitionKey": "decisionRequirementsDefinitionKey"
},
"instances": {
"title": "Decision Instances",
"refresh": "Refresh",
"empty": "No decision instances found.",
"id": "Decision Instance ID",
"evaluation-time": "Evaluation Time",
"process-instance": "Process Instance",
"activity": "Activity"
},
"filter": {
"manage_title": "Manage decision-definition filters"
}
Expand Down
19 changes: 18 additions & 1 deletion src/api/resources/history.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { GET, PAGINATED_GET } from '../helper.jsx'

const INSTANCE_PAGE_SIZE = 20
const DECISION_INSTANCE_PAGE_SIZE = 20

const instance_url = (definition_id, params = {}, { unfinished = false } = {}) => {
const merged = {
Expand Down Expand Up @@ -49,6 +50,19 @@ const get_historic_tasks_by_instance = (state, instance_id) =>
const get_historic_called_instances = (state, instance_id) =>
GET(`/history/process-instance?superProcessInstanceId=${instance_id}`, state, state.api.history.process_instance.called)

const get_decision_instances_by_definition = (state, definition_id, firstResult = 0) =>
PAGINATED_GET(
`/history/decision-instance?${new URLSearchParams({
decisionDefinitionId: definition_id,
sortBy: 'evaluationTime',
sortOrder: 'desc',
}).toString()}`,
state,
state.api.history.decision_instance.list,
firstResult,
DECISION_INSTANCE_PAGE_SIZE,
)

/**
* Task History
*/
Expand All @@ -72,7 +86,10 @@ const history = {
task: {
by_process_instance: get_historic_tasks_by_instance,
},
decision_instance: {
by_decision_definition: get_decision_instances_by_definition,
},
get_user_operation
}

export default history
export default history
11 changes: 11 additions & 0 deletions src/api/resources/history.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,15 @@ describe("api/resources/history", () => {
signal: state.api.history.process_instance.called,
});
});

it("decision_instance.by_decision_definition() PAGINATED_GETs historic decision instances", () => {
history.decision_instance.by_decision_definition(state, "decision-1", 20);
expect_api_call(PAGINATED_GET, {
url: "/history/decision-instance?decisionDefinitionId=decision-1&sortBy=evaluationTime&sortOrder=desc",
state,
signal: state.api.history.decision_instance.list,
});
expect(PAGINATED_GET.mock.lastCall[3]).toBe(20);
expect(PAGINATED_GET.mock.lastCall[4]).toBe(20);
});
});
107 changes: 104 additions & 3 deletions src/pages/Decisions.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,18 @@
void engine_rest.decision.get_decision_definitions(state, params)
}

const load_decision_instances = (state, decision_id, firstResult = 0) => {
if (!decision_id) return
void engine_rest.history.decision_instance.by_decision_definition(
state,
decision_id,
firstResult,
)
}

const DecisionsPage = () => {
const state = useContext(AppState),
{ api: { decision: { definition, dmn } } } = state,
{ api: { decision: { definition, dmn }, history: { decision_instance } } } = state,
{ params: { decision_id }, query } = useRoute()

useEffect(() => {
Expand All @@ -80,12 +89,14 @@
if (decision_id) {
void engine_rest.decision.get_decision_definition(state, decision_id)
void engine_rest.decision.get_dmn_xml(state, decision_id)
load_decision_instances(state, decision_id)
}
// Clear stale per-decision data so navigating between decisions doesn't
// render the previous decision's metadata or DMN diagram briefly.
// render the previous decision's metadata, DMN diagram or instances briefly.
return () => {
definition.value = null
dmn.value = null
decision_instance.list.value = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [decision_id])
Expand Down Expand Up @@ -182,7 +193,6 @@
const {
id, key, name, version, versionTag, tenantId, deploymentId,
decisionRequirementsDefinitionId, historyTimeToLive,
resource
} = definition.value.data

return <div>
Expand Down Expand Up @@ -217,9 +227,100 @@
signal={dmn}
on_nothing={() => <p class="info-box">{t("decisions.select-diagram")}</p>}
on_success={() => <DmnViewer xml={dmn.value.data.dmnXml} container="#diagram-container" />} />

{decision_id ? <DecisionInstances decision_id={decision_id} /> : null}
</div>
}

const DecisionInstances = ({ decision_id }) => {
const state = useContext(AppState),
[t] = useTranslation(),
list = state.api.history.decision_instance.list

const load_more = () =>
load_decision_instances(state, decision_id, list.value?.data?.length ?? 0)

const process_instance_link = (instance) =>
instance.processDefinitionId && instance.processInstanceId
? `/processes/${instance.processDefinitionId}/instances/${instance.processInstanceId}/vars?history=true`
: null

const formatted_time = (value) =>
value ? new Date(Date.parse(value)).toLocaleString() : '—'

return (
<section id="decision-instances">
<header>
<h3>{t("decisions.instances.title")}</h3>
<button
type="button"
class="secondary"
onClick={() => load_decision_instances(state, decision_id)}
>
{t("decisions.instances.refresh")}
</button>
</header>
<RequestState
signal={list}
on_success={() => {
const rows = list.value?.data ?? []
if (rows.length === 0) {
return <p class="info-box">{t("decisions.instances.empty")}</p>
}
return (
<>
<table>
<thead>
<tr>
<th>{t("decisions.instances.id")}</th>
<th>{t("decisions.instances.evaluation-time")}</th>
<th>{t("decisions.instances.process-instance")}</th>
<th>{t("decisions.instances.activity")}</th>
<th>{t("processes.tenant-id")}</th>
</tr>
</thead>
<tbody>
{rows.map((instance) => {
const process_link = process_instance_link(instance)
return (
<tr key={instance.id}>
<td class="font-mono">{instance.id?.substring(0, 8)}</td>
<td>
<time datetime={instance.evaluationTime}>
{formatted_time(instance.evaluationTime)}
</time>
</td>
<td class="font-mono">
{process_link ? (
<a href={process_link}>
{instance.processInstanceId.substring(0, 8)}
</a>
) : (
instance.processInstanceId ?? '—'
)}
</td>
<td>{instance.activityId ?? '—'}</td>
<td>{instance.tenantId ?? '—'}</td>
</tr>
)
})}
</tbody>
</table>
{list.value?.hasMore === true ? (
<button class="load-more" onClick={load_more}>
{t("tasks.load-more")}
</button>
) : list.value?.hasMore === false ? (
<small class="load-more-end">{t("tasks.no-more-items")}</small>
) : null}

Check warning on line 315 in src/pages/Decisions.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=operaton_web-apps&issues=AZ7R4yV_BW70q7L2A53I&open=AZ7R4yV_BW70q7L2A53I&pullRequest=67
</>
)
}}
/>
</section>
)
}

const DecisionsManage = () => {
const state = useContext(AppState),
{ route } = useLocation(),
Expand Down
63 changes: 61 additions & 2 deletions src/pages/Decisions.test.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { h } from "preact";
import { render, cleanup } from "@testing-library/preact";
import { render, cleanup, fireEvent } from "@testing-library/preact";

// Spy all engine_rest API functions but keep RequestState/RESPONSE_STATE real.
vi.mock("../api/engine_rest.jsx", async (importOriginal) => {
Expand Down Expand Up @@ -60,11 +60,14 @@ describe("DecisionsPage", () => {
expect(link.getAttribute("href")).toBe("/decisions/d1");
});

it("fetches the selected decision + DMN xml when a decision_id is in the route", () => {
it("fetches the selected decision, DMN xml and decision instances when a decision_id is in the route", () => {
mockParams = { decision_id: "d1" };
renderPage(state);
expect(engine_rest.decision.get_decision_definition).toHaveBeenCalled();
expect(engine_rest.decision.get_dmn_xml).toHaveBeenCalled();
expect(
engine_rest.history.decision_instance.by_decision_definition,
).toHaveBeenCalled();
});

it("renders the DMN viewer with the fetched xml", () => {
Expand All @@ -79,4 +82,60 @@ describe("DecisionsPage", () => {
const { getByTestId } = renderPage(state);
expect(getByTestId("dmn-viewer").textContent).toBe("<dmn>xml</dmn>");
});

it("renders decision instances for the selected decision", () => {
mockParams = { decision_id: "d1" };
signal_response(state.api.decision.definition, {
id: "d1",
key: "risk",
name: "Risk",
version: 1,
});
signal_response(state.api.decision.dmn, { dmnXml: "<dmn>xml</dmn>" });
signal_response(state.api.history.decision_instance.list, [
{
id: "decision-instance-1",
evaluationTime: "2024-01-01T10:00:00Z",
processDefinitionId: "proc:1",
processInstanceId: "abcdef1234567890",
activityId: "BusinessRuleTask_1",
tenantId: "tenant-a",
},
]);

const { getByText } = renderPage(state);
expect(getByText("decision")).toBeTruthy();
const process = getByText("abcdef12");
expect(process.getAttribute("href")).toBe(
"/processes/proc:1/instances/abcdef1234567890/vars?history=true",
);
expect(getByText("BusinessRuleTask_1")).toBeTruthy();
expect(getByText("tenant-a")).toBeTruthy();
});

it("loads more decision instances from the current result length", () => {
mockParams = { decision_id: "d1" };
signal_response(state.api.decision.definition, {
id: "d1",
key: "risk",
name: "Risk",
version: 1,
});
signal_response(state.api.decision.dmn, { dmnXml: "<dmn>xml</dmn>" });
signal_response(state.api.history.decision_instance.list, [
{ id: "di-1", evaluationTime: "2024-01-01T10:00:00Z" },
{ id: "di-2", evaluationTime: "2024-01-02T10:00:00Z" },
]);
state.api.history.decision_instance.list.value = {
...state.api.history.decision_instance.list.value,
hasMore: true,
};

const { getByText } = renderPage(state);
fireEvent.click(getByText("tasks.load-more"));
expect(
engine_rest.history.decision_instance.by_decision_definition.mock
.lastCall[2],
).toBe(2);
});
});
3 changes: 3 additions & 0 deletions src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,9 @@ const createAppState = () => {
process_instance: {
called: signal(null),
},
decision_instance: {
list: signal(null),
},
user_operation: signal(null),
},
job_definition: {
Expand Down
1 change: 1 addition & 0 deletions src/state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.history.decision_instance.list.value).toBeNull();
expect(api.task.comment.list.value).toBeNull();
expect(api.authorization.all.value).toBeNull();
expect(api.batch.list.value).toBeNull();
Expand Down