diff --git a/ui-rs/src/components/EventLog/EventLog.css b/ui-rs/src/components/EventLog/EventLog.css
new file mode 100644
index 0000000..487a43e
--- /dev/null
+++ b/ui-rs/src/components/EventLog/EventLog.css
@@ -0,0 +1,93 @@
+.eventCard {
+ background-color: #ffe7ff;
+}
+
+.eventCardHeader {
+ background-color: #f4cdf4;
+ font-weight: var(--text-weight-bold);
+}
+
+.filter {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 12rem;
+}
+
+.filterCount {
+ white-space: nowrap;
+ font-weight: var(--text-weight-regular);
+ color: var(--color-text-p2);
+ font-size: var(--font-size-small);
+}
+
+.summaryDivider {
+ border-top: 1px solid var(--color-border-p2);
+ margin: 0.5rem 0;
+}
+
+.entryList {
+ display: flex;
+ flex-direction: column;
+}
+
+.entry + .entry {
+ border-top: 1px solid var(--color-border-p2);
+}
+
+.entryHeader {
+ display: grid;
+ grid-template-columns: auto auto minmax(0, 1fr) auto auto;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ padding: 0.4rem 0.5rem;
+ border: none;
+ background: transparent;
+ font: inherit;
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+
+.entryHeader:hover {
+ background-color: rgba(0, 0, 0, 0.04);
+}
+
+.entryHeader:focus-visible {
+ outline: 2px solid var(--primary);
+ outline-offset: -2px;
+}
+
+.entryCaret {
+ display: inline-flex;
+ align-items: center;
+}
+
+.entryTitle {
+ font-weight: var(--text-weight-medium);
+ white-space: nowrap;
+}
+
+.entrySummary {
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ color: var(--color-text-p2);
+}
+
+.entryTime {
+ white-space: nowrap;
+ color: var(--color-text-p2);
+ font-size: var(--font-size-small);
+}
+
+.matchLine {
+ background-color: var(--highlighter-fill);
+}
+
+.entryBody {
+ padding: 0.5rem 0.5rem 0.75rem;
+ background-color: rgba(255, 255, 255, 0.5);
+}
diff --git a/ui-rs/src/components/EventLog/EventLog.js b/ui-rs/src/components/EventLog/EventLog.js
new file mode 100644
index 0000000..92f1eb0
--- /dev/null
+++ b/ui-rs/src/components/EventLog/EventLog.js
@@ -0,0 +1,83 @@
+import { useMemo, useState } from 'react';
+import { FormattedMessage, useIntl } from 'react-intl';
+import { Card, SearchField } from '@folio/stripes/components';
+import EventLogRow from './EventLogRow';
+import css from './EventLog.css';
+
+// Shared broker event log. The filter searches the same JSON shown in each
+// entry's raw-data accordion.
+const EventLog = ({
+ events = [],
+ cardId,
+ header,
+ emptyMessageId = 'ui-rs.eventHistory.empty',
+ children,
+}) => {
+ const intl = useIntl();
+ const [query, setQuery] = useState('');
+
+ // Avoid serializing every event again on each keystroke.
+ const searchable = useMemo(
+ () => events.map((event) => [event, JSON.stringify(event, null, 2).toLowerCase()]),
+ [events]
+ );
+ const filterQuery = query.trim().toLowerCase();
+ const visible = filterQuery
+ ? searchable.filter(([, json]) => json.includes(filterQuery)).map(([event]) => event)
+ : events;
+
+ let content;
+ if (events.length === 0) {
+ content = ;
+ } else if (visible.length === 0) {
+ content = ;
+ } else {
+ content = (
+
+ {visible.map((event) => (
+
+ ))}
+
+ );
+ }
+
+ const label = intl.formatMessage({ id: 'ui-rs.eventLog.filter' });
+ const filter = events.length > 0 && (
+
+ {filterQuery && (
+
+
+
+ )}
+ setQuery(e.target.value)}
+ onClear={() => setQuery('')}
+ marginBottom0
+ />
+
+ );
+
+ return (
+
+ {children}
+ {children && }
+ {content}
+
+ );
+};
+
+export default EventLog;
diff --git a/ui-rs/src/components/EventLog/EventLog.test.js b/ui-rs/src/components/EventLog/EventLog.test.js
new file mode 100644
index 0000000..655c73f
--- /dev/null
+++ b/ui-rs/src/components/EventLog/EventLog.test.js
@@ -0,0 +1,82 @@
+import React from 'react';
+import { fireEvent, screen } from '@folio/jest-config-stripes/testing-library/react';
+
+import { renderWithRs } from '../../test/renderWithRs';
+import EventLog from './EventLog';
+
+jest.mock('@folio/stripes-components/lib/Icon', () => require('../../test/iconMock').default);
+
+// Jest cannot parse react-syntax-highlighter's ESM entry points.
+jest.mock('react-syntax-highlighter', () => ({
+ LightAsync: ({ children }) => require('react').createElement('pre', null, children),
+}));
+jest.mock('react-syntax-highlighter/dist/esm/styles/hljs', () => ({ github: { hljs: {} } }));
+
+const renderLog = (events) => renderWithRs(
+ ,
+ { messages: { 'ui-rs.eventLog.matchCount': '{count} of {total}' } }
+);
+
+const makeEvent = (over) => ({
+ id: 'ev-1',
+ timestamp: '2026-01-05T12:00:00Z',
+ eventName: 'invoke-action',
+ eventType: 'TASK',
+ eventStatus: 'SUCCESS',
+ eventData: {},
+ resultData: {},
+ ...over,
+});
+
+const events = [
+ makeEvent({ id: 'ev-1', eventData: { action: 'Request', user: 'jsmith' } }),
+ makeEvent({
+ id: 'ev-2',
+ eventData: { action: 'Cancel', user: 'jsmith', customData: { trace: 'raw-only-value' } },
+ }),
+ makeEvent({ id: 'ev-3', eventName: 'message-supplier', eventData: { user: 'apatel' } }),
+];
+
+const rows = () => screen.getAllByRole('button').filter((b) => b.className === 'entryHeader');
+const filterBox = () => screen.getByRole('searchbox');
+
+describe('EventLog', () => {
+ it('reads action, actor and message payloads off the event', () => {
+ renderLog([makeEvent({
+ eventData: { action: 'Request', user: 'jsmith', outgoingMessage: { request: {} } },
+ })]);
+
+ expect(screen.getByText(/ui-rs\.eventHistory\.event\.invokeAction: Request/)).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: /invokeAction/ }));
+
+ expect(screen.getByText('jsmith')).toBeInTheDocument();
+ expect(screen.getByText('Request')).toBeInTheDocument();
+ expect(screen.getByText('ui-rs.eventHistory.isoOutgoing')).toBeInTheDocument();
+ });
+
+ it('filters as you type on raw event JSON, counts matches, and restores on clear', () => {
+ renderLog(events);
+ expect(rows()).toHaveLength(3);
+ expect(screen.queryByText('3 of 3')).not.toBeInTheDocument();
+
+ fireEvent.focus(filterBox());
+ fireEvent.change(filterBox(), { target: { value: 'raw-only-value' } });
+ expect(rows()).toHaveLength(1);
+ expect(screen.getByText('1 of 3')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: /clearThisField/i }));
+ expect(filterBox()).toHaveValue('');
+ expect(rows()).toHaveLength(3);
+ });
+
+ it('distinguishes no matches from an empty log', () => {
+ renderLog(events);
+ fireEvent.change(filterBox(), { target: { value: 'nothing-matches-this' } });
+ expect(screen.getByText('ui-rs.eventLog.noMatches')).toBeInTheDocument();
+ expect(screen.queryByText('ui-rs.eventHistory.empty')).not.toBeInTheDocument();
+
+ renderLog([]);
+ expect(screen.getByText('ui-rs.eventHistory.empty')).toBeInTheDocument();
+ });
+});
diff --git a/ui-rs/src/components/ViewPatronRequest/sections/EventHistory/EventHistoryDetails.js b/ui-rs/src/components/EventLog/EventLogDetails.js
similarity index 60%
rename from ui-rs/src/components/ViewPatronRequest/sections/EventHistory/EventHistoryDetails.js
rename to ui-rs/src/components/EventLog/EventLogDetails.js
index a7aa134..1e7e55d 100644
--- a/ui-rs/src/components/ViewPatronRequest/sections/EventHistory/EventHistoryDetails.js
+++ b/ui-rs/src/components/EventLog/EventLogDetails.js
@@ -2,9 +2,10 @@ import { FormattedMessage } from 'react-intl';
import { LightAsync as SyntaxHighlighter } from 'react-syntax-highlighter';
import { github } from 'react-syntax-highlighter/dist/esm/styles/hljs';
import XmlBeautify from 'xml-beautify';
-import { Accordion, AccordionSet, Col, KeyValue, Row } from '@folio/stripes/components';
-import formattedDateTime from '../../../../util/formattedDateTime';
-import css from './EventHistory.css';
+import { Accordion, AccordionSet, KeyValue, Layout } from '@folio/stripes/components';
+import formattedDateTime from '../../util/formattedDateTime';
+import formatError from './formatError';
+import css from './EventLog.css';
const githubStyle = { ...github, hljs: { ...github.hljs, background: 'transparent' } };
@@ -41,18 +42,18 @@ const PayloadAccordion = ({ labelId, value }) => {
);
};
-const formatError = (err) => {
- if (!err) return null;
- if (typeof err === 'string') return err;
- if (err.Message) {
- return err.Cause ? `${err.Message}: ${err.Cause}` : err.Message;
- }
- return JSON.stringify(err, null, 2);
-};
-
-const EventHistoryDetails = ({ event }) => {
+const EventLogDetails = ({ event, filterQuery }) => {
const { eventData = {}, resultData = {} } = event;
+ // lineProps receives a usable line number only when line numbers are shown.
+ const rawJson = JSON.stringify(event, null, 2);
+ const rawLines = rawJson.split('\n');
+ const matchLineProps = (lineNumber) => (
+ rawLines[lineNumber - 1]?.toLowerCase().includes(filterQuery)
+ ? { className: css.matchLine }
+ : {}
+ );
+
const customData = eventData.customData || {};
const resultCustomData = resultData.customData || {};
const lmsOutgoing = customData.lmsOutgoingMessage || resultCustomData.lmsOutgoingMessage;
@@ -72,69 +73,43 @@ const EventHistoryDetails = ({ event }) => {
['ui-rs.eventHistory.lmsOutgoing', lmsOutgoing],
].filter(([, v]) => v);
+ const metadata = [
+ ['timestamp', 'ui-rs.eventHistory.timestamp', formattedDateTime(event.timestamp)],
+ ['eventName', 'ui-rs.eventHistory.eventName', event.eventName],
+ ['eventType', 'ui-rs.eventHistory.eventType', event.eventType],
+ ['eventStatus', 'ui-rs.eventHistory.eventStatus', event.eventStatus],
+ ['eventId', 'ui-rs.eventHistory.eventId', event.id],
+ event.parentID && ['parentId', 'ui-rs.eventHistory.parentId', event.parentID],
+ eventData.user && ['actor', 'ui-rs.eventHistory.actor', eventData.user],
+ ].filter(Boolean);
+
return (
-
- {/* 1. Metadata */}
-
-
- }
- value={formattedDateTime(event.timestamp)}
- />
-
-
+ <>
+ {/* Metadata */}
+
+ {metadata.map(([key, labelId, value]) => (
}
- value={event.eventName}
+ key={key}
+ label={}
+ value={value}
/>
-
-
- }
- value={event.eventType}
- />
-
-
- }
- value={event.eventStatus}
- />
-
-
- }
- value={event.id}
- />
-
- {event.parentID && (
-
- }
- value={event.parentID}
- />
-
- )}
- {eventData.user && (
-
- }
- value={eventData.user}
- />
-
- )}
-
-
- {/* 2. Problem / Error */}
+ ))}
+
+
+ {/* Errors and notes */}
{eventError && (
-
{formatError(eventError)}
+
{formatError(eventError, true)}
)}
{problem && (
-
{formatError(problem)}
+
{formatError(problem, true)}
)}
{note && !eventError && !problem && (
@@ -144,7 +119,7 @@ const EventHistoryDetails = ({ event }) => {
/>
)}
- {/* 3. Action Info */}
+ {/* Action */}
{event.eventName === 'invoke-action' && eventData.action && (
}
@@ -152,7 +127,7 @@ const EventHistoryDetails = ({ event }) => {
/>
)}
- {/* 4. Message Payloads (open accordions) */}
+ {/* Message payloads */}
{payloads.length > 0 && (
{payloads.map(([labelId, value]) => (
@@ -161,19 +136,26 @@ const EventHistoryDetails = ({ event }) => {
)}
- {/* 5. Raw Event (closed accordion) */}
+ {/* Raw event */}
}
>
-
- {JSON.stringify(event, null, 2)}
+
+ {rawJson}
-
+ >
);
};
-export default EventHistoryDetails;
+export default EventLogDetails;
diff --git a/ui-rs/src/components/EventLog/EventLogRow.js b/ui-rs/src/components/EventLog/EventLogRow.js
new file mode 100644
index 0000000..ca66a12
--- /dev/null
+++ b/ui-rs/src/components/EventLog/EventLogRow.js
@@ -0,0 +1,142 @@
+import { useState } from 'react';
+import { FormattedMessage, useIntl } from 'react-intl';
+import { Badge, Icon } from '@folio/stripes/components';
+import formattedDateTime from '../../util/formattedDateTime';
+import formatError from './formatError';
+import EventLogDetails from './EventLogDetails';
+import css from './EventLog.css';
+
+const STATUS_BADGE_COLOR = {
+ ERROR: 'red',
+ PROBLEM: 'red',
+ PROCESSING: 'primary',
+ NEW: 'default',
+ SUCCESS: 'default',
+};
+
+const STATUS_LABEL_IDS = {
+ NEW: 'ui-rs.eventHistory.status.NEW',
+ PROCESSING: 'ui-rs.eventHistory.status.PROCESSING',
+ SUCCESS: 'ui-rs.eventHistory.status.SUCCESS',
+ PROBLEM: 'ui-rs.eventHistory.status.PROBLEM',
+ ERROR: 'ui-rs.eventHistory.status.ERROR',
+};
+
+const EVENT_TITLE_IDS = {
+ 'invoke-action': 'ui-rs.eventHistory.event.invokeAction',
+ 'patron-request-message': 'ui-rs.eventHistory.event.patronRequestMessage',
+ 'lms-requester-message': 'ui-rs.eventHistory.event.lmsRequesterMessage',
+ 'lms-supplier-message': 'ui-rs.eventHistory.event.lmsSupplierMessage',
+};
+
+const find18626ErrorValue = (data) => {
+ const msg = data?.incomingMessage;
+ return msg?.requestConfirmation?.errorData?.errorValue
+ ?? msg?.requestingAgencyMessageConfirmation?.errorData?.errorValue
+ ?? msg?.supplyingAgencyMessageConfirmation?.errorData?.errorValue
+ ?? null;
+};
+
+const getEventTitle = (intl, event) => {
+ const { eventName, eventData = {} } = event;
+ const action = eventData.action;
+ const id = EVENT_TITLE_IDS[eventName];
+ const base = id ? intl.formatMessage({ id }) : eventName;
+ if (eventName === 'invoke-action' && action) {
+ return `${base}: ${action}`;
+ }
+ return base;
+};
+
+const getStatusLabel = (intl, status) => {
+ const id = STATUS_LABEL_IDS[status];
+ return id ? intl.formatMessage({ id }) : status;
+};
+
+// Summarize the most substantive ISO 18626 message among an event's payload
+// slots, preferring actual messages over their confirmations. Which slot
+// (incoming vs outgoing) carries the substantive message varies by event —
+// e.g. patron-request-message receives it, message-supplier sends it — so we
+// rank by message type across all slots rather than by direction.
+const summarizeIso18626 = (fmt, messages) => {
+ const present = messages.filter(Boolean);
+ if (present.some((m) => m.request)) return fmt('eventHistory.summary.request');
+ const ram = present.find((m) => m.requestingAgencyMessage);
+ if (ram) return fmt('eventHistory.summary.requesterMessage', { action: ram.requestingAgencyMessage.action || '' });
+ const sam = present.find((m) => m.supplyingAgencyMessage);
+ if (sam) {
+ return fmt('eventHistory.summary.supplierMessage', {
+ reason: sam.supplyingAgencyMessage.messageInfo?.reasonForMessage || sam.supplyingAgencyMessage.status || '',
+ });
+ }
+ if (present.some((m) => m.requestConfirmation)) return fmt('eventHistory.summary.requestConfirmation');
+ if (present.some((m) => m.requestingAgencyMessageConfirmation)) return fmt('eventHistory.summary.requesterMessageConfirmation');
+ if (present.some((m) => m.supplyingAgencyMessageConfirmation)) return fmt('eventHistory.summary.supplierMessageConfirmation');
+ return null;
+};
+
+const getEventSummary = (intl, event) => {
+ const { eventData = {}, resultData = {}, eventStatus, eventName } = event;
+ const fmt = (id, values) => intl.formatMessage({ id: `ui-rs.${id}` }, values);
+
+ if (eventStatus === 'ERROR' || eventStatus === 'PROBLEM') {
+ const err = resultData.eventError || eventData.eventError || resultData.problem || eventData.problem
+ || find18626ErrorValue(resultData) || find18626ErrorValue(eventData);
+ if (err) return formatError(err);
+ }
+
+ const iso = summarizeIso18626(fmt, [
+ eventData.incomingMessage, eventData.outgoingMessage,
+ resultData.incomingMessage, resultData.outgoingMessage,
+ ]);
+ if (iso) return iso;
+
+ if (eventName === 'invoke-action' && resultData.note) return resultData.note;
+ return null;
+};
+
+const EventLogRow = ({ event, filterQuery }) => {
+ const intl = useIntl();
+ const [open, setOpen] = useState(false);
+ const title = getEventTitle(intl, event);
+ const statusLabel = getStatusLabel(intl, event.eventStatus);
+ const summary = getEventSummary(intl, event);
+ const badgeColor = STATUS_BADGE_COLOR[event.eventStatus] || 'default';
+ const actor = event.eventData?.user;
+ const bodyId = `event-log-body-${event.id}`;
+
+ return (
+
+
+ {open && (
+
+
+
+ )}
+
+ );
+};
+
+export default EventLogRow;
diff --git a/ui-rs/src/components/EventLog/formatError.js b/ui-rs/src/components/EventLog/formatError.js
new file mode 100644
index 0000000..612906f
--- /dev/null
+++ b/ui-rs/src/components/EventLog/formatError.js
@@ -0,0 +1,10 @@
+// Format broker error/problem payloads for summaries or detail blocks.
+const formatError = (err, pretty = false) => {
+ if (!err) return null;
+ if (typeof err === 'string') return err;
+ if (err.Message) return err.Cause ? `${err.Message}: ${err.Cause}` : err.Message;
+ if (err.Kind) return err.Details ? `${err.Kind}: ${err.Details}` : err.Kind;
+ return pretty ? JSON.stringify(err, null, 2) : JSON.stringify(err);
+};
+
+export default formatError;
diff --git a/ui-rs/src/components/EventLog/index.js b/ui-rs/src/components/EventLog/index.js
new file mode 100644
index 0000000..55507c5
--- /dev/null
+++ b/ui-rs/src/components/EventLog/index.js
@@ -0,0 +1 @@
+export { default } from './EventLog';
diff --git a/ui-rs/src/components/ViewPatronRequest/sections/EventHistory/EventHistory.css b/ui-rs/src/components/ViewPatronRequest/sections/EventHistory/EventHistory.css
deleted file mode 100644
index 48bef11..0000000
--- a/ui-rs/src/components/ViewPatronRequest/sections/EventHistory/EventHistory.css
+++ /dev/null
@@ -1,36 +0,0 @@
-.eventCard {
- background-color: #ffe7ff;
-}
-
-.eventCardHeader {
- background-color: #f4cdf4;
- font-weight: bold;
-}
-
-.headerGrid {
- display: grid;
- grid-template-columns: auto minmax(0, 1fr);
- align-items: center;
- width: 100%;
-}
-
-.summaryArea {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- min-width: 0;
- padding-left: 1.2rem;
-}
-
-.summaryText {
- flex: 1 1 0px;
- min-width: 0;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- padding-right: var(--gutter-static, 1rem);
-}
-
-.eventBody {
- background-color: rgba(255, 255, 255, 0.5);
-}
diff --git a/ui-rs/src/components/ViewPatronRequest/sections/EventHistory/EventHistory.js b/ui-rs/src/components/ViewPatronRequest/sections/EventHistory/EventHistory.js
index a44be52..a0452e6 100644
--- a/ui-rs/src/components/ViewPatronRequest/sections/EventHistory/EventHistory.js
+++ b/ui-rs/src/components/ViewPatronRequest/sections/EventHistory/EventHistory.js
@@ -1,9 +1,8 @@
import { useLocation } from 'react-router-dom';
import { FormattedMessage } from 'react-intl';
import { useOkapiQuery } from '@projectreshare/stripes-reshare';
-import { Accordion, AccordionSet, Card, Loading } from '@folio/stripes/components';
-import EventHistoryRow from './EventHistoryRow';
-import css from './EventHistory.css';
+import { Accordion, Loading } from '@folio/stripes/components';
+import EventLog from '../../../EventLog';
const EventHistory = ({ record }) => {
const location = useLocation();
@@ -15,11 +14,7 @@ const EventHistory = ({ record }) => {
}
};
- const {
- data: events = [],
- isLoading,
- isSuccess,
- } = useOkapiQuery(
+ const { data: events = [], isLoading } = useOkapiQuery(
`broker/patron_requests/${record.id}/events`,
{
enabled: !!record?.id,
@@ -28,52 +23,22 @@ const EventHistory = ({ record }) => {
}
);
- // TODO: Remove once broker fixes StructToMap to flatten Go embedded structs.
- // Wire shape is currently { CommonEventData: { action, incomingMessage, ... }, customData: {...} }
- // but should be flat: { action, incomingMessage, ..., customData: {...} }
- const flattenPayload = (data) => {
- if (!data) return {};
- const { CommonEventData, ...rest } = data;
- return { ...CommonEventData, ...rest };
- };
- const normalizeEvent = (event) => ({
- ...event,
- eventData: flattenPayload(event.eventData),
- resultData: flattenPayload(event.resultData),
- });
-
const eventList = (Array.isArray(events?.items) ? events.items : [])
.slice()
- .reverse()
- .map(normalizeEvent);
-
- let content;
- if (isLoading) {
- content = ;
- } else if (isSuccess && eventList.length === 0) {
- content = ;
- } else {
- content = (
-
- {eventList.map((event) => (
-
- ))}
-
- );
- }
+ .reverse();
return (
}>
-
}
- roundedBorder
- cardClass={css.eventCard}
- headerClass={css.eventCardHeader}
- >
- {content}
-
+ {isLoading ?
: (
+
}
+ events={eventList}
+ emptyMessageId="ui-rs.eventHistory.empty"
+ />
+ )}
);
diff --git a/ui-rs/src/components/ViewPatronRequest/sections/EventHistory/EventHistoryHeader.js b/ui-rs/src/components/ViewPatronRequest/sections/EventHistory/EventHistoryHeader.js
deleted file mode 100644
index 98f1b01..0000000
--- a/ui-rs/src/components/ViewPatronRequest/sections/EventHistory/EventHistoryHeader.js
+++ /dev/null
@@ -1,92 +0,0 @@
-/*
- * Custom accordion header that replaces DefaultAccordionHeader.
- *
- * The default header renders the label and displayWhenClosed in two
- * sibling flex items. The right-hand sibling (headerDefaultContentRight)
- * has flex-shrink: 0, so its width equals its content's max-content
- * intrinsic size. That means long text inside displayWhenClosed
- * (e.g. error messages) can never be truncated by CSS — the container
- * just expands to fit, pushing the label off-screen.
- *
- * This header puts both the label and the summary content in a single
- * CSS Grid row: `grid-template-columns: auto minmax(0, 1fr)`.
- * The label column (auto) takes its natural width. The summary column
- * (minmax(0, 1fr)) gets the remaining space and is allowed to shrink
- * to zero, so children can use overflow/text-overflow to truncate.
- */
-
-import { forwardRef } from 'react';
-import { Headline, Icon } from '@folio/stripes/components';
-// TODO: Inline the needed Accordion header styles locally and remove this private CSS import.
-// eslint-disable-next-line import/no-extraneous-dependencies
-import accordionCss from '@folio/stripes-components/lib/Accordion/Accordion.css';
-import css from './EventHistory.css';
-
-const EventHistoryHeader = forwardRef(({ headerProps = { headingLevel: 3 }, ...rest }, ref) => {
- const props = { headerProps, ...rest };
-
- function handleHeaderClick(e) {
- const { id, label } = props;
- props.onToggle({ id, label });
- e.stopPropagation();
- }
-
- function handleKeyDown(e) {
- if (e.key === 'Enter') {
- e.preventDefault();
- const { id, label } = props;
- props.onToggle({ id, label });
- }
- }
-
- const {
- label,
- open,
- displayWhenOpen,
- displayWhenClosed,
- labelId,
- headerProps: { headingLevel, ...restHeaderProps },
- } = props;
-
- const headerRightContent = open ? displayWhenOpen : displayWhenClosed;
-
- return (
-
-
-
-
-
- {headerRightContent && (
-
- {headerRightContent}
-
- )}
-
-
- );
-});
-
-export default EventHistoryHeader;
diff --git a/ui-rs/src/components/ViewPatronRequest/sections/EventHistory/EventHistoryRow.js b/ui-rs/src/components/ViewPatronRequest/sections/EventHistory/EventHistoryRow.js
deleted file mode 100644
index 9b60e45..0000000
--- a/ui-rs/src/components/ViewPatronRequest/sections/EventHistory/EventHistoryRow.js
+++ /dev/null
@@ -1,148 +0,0 @@
-import { FormattedMessage, useIntl } from 'react-intl';
-import { Accordion, Badge, Layout } from '@folio/stripes/components';
-import formattedDateTime from '../../../../util/formattedDateTime';
-import EventHistoryDetails from './EventHistoryDetails';
-import EventHistoryHeader from './EventHistoryHeader';
-import css from './EventHistory.css';
-
-const STATUS_BADGE_COLOR = {
- ERROR: 'red',
- PROBLEM: 'red',
- PROCESSING: 'primary',
- NEW: 'default',
- SUCCESS: 'default',
-};
-
-const STATUS_LABEL_IDS = {
- NEW: 'ui-rs.eventHistory.status.NEW',
- PROCESSING: 'ui-rs.eventHistory.status.PROCESSING',
- SUCCESS: 'ui-rs.eventHistory.status.SUCCESS',
- PROBLEM: 'ui-rs.eventHistory.status.PROBLEM',
- ERROR: 'ui-rs.eventHistory.status.ERROR',
-};
-
-const EVENT_TITLE_IDS = {
- 'invoke-action': 'ui-rs.eventHistory.event.invokeAction',
- 'patron-request-message': 'ui-rs.eventHistory.event.patronRequestMessage',
- 'lms-requester-message': 'ui-rs.eventHistory.event.lmsRequesterMessage',
- 'lms-supplier-message': 'ui-rs.eventHistory.event.lmsSupplierMessage',
-};
-
-const formatError = (err) => {
- if (!err) return null;
- if (typeof err === 'string') return err;
- if (err.Message) return err.Cause ? `${err.Message}: ${err.Cause}` : err.Message;
- return JSON.stringify(err);
-};
-
-const find18626ErrorValue = (data) => {
- const msg = data?.incomingMessage;
- return msg?.requestConfirmation?.errorData?.errorValue
- ?? msg?.requestingAgencyMessageConfirmation?.errorData?.errorValue
- ?? msg?.supplyingAgencyMessageConfirmation?.errorData?.errorValue
- ?? null;
-};
-
-const getEventTitle = (intl, event) => {
- const { eventName, eventData = {} } = event;
- const action = eventData.action;
- const id = EVENT_TITLE_IDS[eventName];
- const base = id ? intl.formatMessage({ id }) : eventName;
- if (eventName === 'invoke-action' && action) {
- return `${base}: ${action}`;
- }
- return base;
-};
-
-const getStatusLabel = (intl, status) => {
- const id = STATUS_LABEL_IDS[status];
- return id ? intl.formatMessage({ id }) : status;
-};
-
-const getEventSummary = (intl, event) => {
- const { eventName, eventData = {}, resultData = {}, eventStatus } = event;
- const isError = eventStatus === 'ERROR' || eventStatus === 'PROBLEM';
- const fmt = (id, values) => intl.formatMessage({ id: `ui-rs.${id}` }, values);
-
- switch (eventName) {
- case 'invoke-action': {
- if (isError) {
- const err = resultData.eventError || eventData.eventError || resultData.problem || eventData.problem
- || find18626ErrorValue(resultData) || find18626ErrorValue(eventData);
- if (err) return formatError(err);
- }
- if (resultData.note) return resultData.note;
- return null;
- }
- case 'patron-request-message': {
- const msgs = [eventData.incomingMessage, eventData.outgoingMessage, resultData.incomingMessage, resultData.outgoingMessage];
- for (const msg of msgs) {
- if (msg) {
- if (msg.request) return fmt('eventHistory.summary.request');
- if (msg.requestConfirmation) return fmt('eventHistory.summary.requestConfirmation');
- if (msg.requestingAgencyMessage) {
- return fmt('eventHistory.summary.requesterMessage', { action: msg.requestingAgencyMessage.action || '' });
- }
- if (msg.requestingAgencyMessageConfirmation) return fmt('eventHistory.summary.requesterMessageConfirmation');
- if (msg.supplyingAgencyMessage) {
- return fmt('eventHistory.summary.supplierMessage', { reason: msg.supplyingAgencyMessage.messageInfo?.reasonForMessage || msg.supplyingAgencyMessage.status || '' });
- }
- if (msg.supplyingAgencyMessageConfirmation) return fmt('eventHistory.summary.supplierMessageConfirmation');
- }
- }
- return fmt('eventHistory.summary.patronRequestMessage');
- }
- case 'lms-requester-message':
- case 'lms-supplier-message': {
- if (isError) {
- const err = resultData.eventError || eventData.eventError || resultData.problem || eventData.problem;
- if (err) return formatError(err);
- }
- return null;
- }
- default:
- return fmt('eventHistory.summary.eventRecorded');
- }
-};
-
-const EventHistoryRow = ({ event }) => {
- const intl = useIntl();
- const title = getEventTitle(intl, event);
- const statusLabel = getStatusLabel(intl, event.eventStatus);
- const summary = getEventSummary(intl, event);
- const badgeColor = STATUS_BADGE_COLOR[event.eventStatus] || 'default';
- const actor = event.eventData?.user;
-
- const summaryRow = (
- <>
- {(summary || actor) && (
-
- {summary}
- {summary && actor && ' · '}
- {actor && (
-
- )}
-
- )}
-
- {formattedDateTime(event.timestamp)}
-
-
- {statusLabel}
-
- >
- );
-
- return (
-
-
-
- );
-};
-
-export default EventHistoryRow;
diff --git a/ui-rs/src/components/ViewPatronRequest/sections/TransactionLog/TransactionLog.js b/ui-rs/src/components/ViewPatronRequest/sections/TransactionLog/TransactionLog.js
new file mode 100644
index 0000000..98836d8
--- /dev/null
+++ b/ui-rs/src/components/ViewPatronRequest/sections/TransactionLog/TransactionLog.js
@@ -0,0 +1,133 @@
+import { useState } from 'react';
+import { FormattedMessage } from 'react-intl';
+import { useOkapiQuery } from '@projectreshare/stripes-reshare';
+import { Accordion, Col, KeyValue, Loading, Row } from '@folio/stripes/components';
+import EventLog from '../../../EventLog';
+import formattedDateTime from '../../../../util/formattedDateTime';
+
+// The transaction and its events are fetched lazily when the accordion opens.
+// The broker exposes transactions only to the requesting tenant, so this
+// section is limited to borrowing requests.
+const TransactionLog = ({ record = {} }) => {
+ const requesterRequestId = record.requesterRequestId;
+ const [opened, setOpened] = useState(false);
+
+ const txnQuery = useOkapiQuery('broker/ill_transactions', {
+ searchParams: { requester_req_id: requesterRequestId },
+ enabled: opened && !!requesterRequestId,
+ staleTime: 2 * 60 * 1000,
+ useErrorBoundary: false,
+ notifyOnChangeProps: 'tracked',
+ });
+ const transaction = Array.isArray(txnQuery.data?.items) ? txnQuery.data.items[0] : undefined;
+
+ // Fetch events after the transaction lookup supplies its id.
+ const eventsQuery = useOkapiQuery(`broker/ill_transactions/${transaction?.id}/events`, {
+ enabled: opened && !!transaction?.id,
+ staleTime: 2 * 60 * 1000,
+ useErrorBoundary: false,
+ notifyOnChangeProps: 'tracked',
+ });
+ const events = (Array.isArray(eventsQuery.data?.items) ? eventsQuery.data.items : [])
+ .slice()
+ .reverse();
+
+ const handleToggle = ({ open }) => {
+ if (open) setOpened(true);
+ };
+
+ // Keep the hook order stable if navigation changes the request side.
+ if (record.side !== 'borrowing') return null;
+
+ // Do not render the card until both dependent queries have resolved.
+ let body = ;
+ if (txnQuery.isError || eventsQuery.isError) {
+ body = ;
+ } else if (!requesterRequestId || (txnQuery.isSuccess && !transaction)) {
+ body = ;
+ } else if (transaction && eventsQuery.isSuccess) {
+ body = (
+
+
+
+ }
+ value={transaction.timestamp ? formattedDateTime(transaction.timestamp) : ''}
+ />
+
+
+ }
+ value={transaction.requesterSymbol}
+ />
+
+
+ }
+ value={transaction.supplierSymbol}
+ />
+
+
+
+
+ }
+ value={transaction.requesterRequestID}
+ />
+
+
+ }
+ value={transaction.supplierRequestID}
+ />
+
+
+
+
+ }
+ value={transaction.lastRequesterAction}
+ />
+
+
+ }
+ value={transaction.prevRequesterAction}
+ />
+
+
+ }
+ value={transaction.lastSupplierStatus}
+ />
+
+
+ }
+ value={transaction.prevSupplierStatus}
+ />
+
+
+
+ );
+ }
+
+ return (
+ }
+ onClickToggle={handleToggle}
+ >
+ {body}
+
+ );
+};
+
+export default TransactionLog;
diff --git a/ui-rs/src/components/ViewPatronRequest/sections/TransactionLog/TransactionLog.test.js b/ui-rs/src/components/ViewPatronRequest/sections/TransactionLog/TransactionLog.test.js
new file mode 100644
index 0000000..83a6f74
--- /dev/null
+++ b/ui-rs/src/components/ViewPatronRequest/sections/TransactionLog/TransactionLog.test.js
@@ -0,0 +1,138 @@
+import React, { useState } from 'react';
+import { fireEvent, screen } from '@folio/jest-config-stripes/testing-library/react';
+
+import { renderWithRs } from '../../../../test/renderWithRs';
+import { makeOkapiKyMock } from '../../../../test/okapiKyMock';
+import TransactionLog from './TransactionLog';
+
+// Jest permits hoisted mock factories to reference variables prefixed with mock.
+const mockOkapi = makeOkapiKyMock();
+
+jest.mock('@folio/stripes-components/lib/Icon', () => require('../../../../test/iconMock').default);
+
+// Jest cannot parse react-syntax-highlighter's ESM entry points.
+jest.mock('react-syntax-highlighter', () => ({
+ LightAsync: ({ children }) => require('react').createElement('pre', null, children),
+}));
+jest.mock('react-syntax-highlighter/dist/esm/styles/hljs', () => ({ github: { hljs: {} } }));
+
+jest.mock('@folio/stripes/core', () => require('../../../../test/stripesCore').makeStripesCoreMock(() => mockOkapi));
+
+const transactionFixture = {
+ id: 'txn-1',
+ timestamp: '2026-01-05T12:00:00Z',
+ requesterSymbol: 'ISIL:REQ',
+ supplierSymbol: 'ISIL:SUP',
+ requesterRequestID: 'REQ-101',
+ supplierRequestID: 'SUP-9',
+ lastRequesterAction: 'Request',
+ prevRequesterAction: '',
+ lastSupplierStatus: 'ExpectToSupply',
+ prevSupplierStatus: '',
+};
+
+const eventFixture = {
+ id: 'ev-1',
+ timestamp: '2026-01-05T12:01:00Z',
+ eventName: 'request-received',
+ eventType: 'NOTICE',
+ eventStatus: 'SUCCESS',
+ eventData: {},
+ resultData: {},
+};
+
+const renderSection = (record = { id: 'pr-1', side: 'borrowing', requesterRequestId: 'REQ-101' }) => (
+ renderWithRs()
+);
+
+const openSection = () => (
+ fireEvent.click(screen.getByRole('button', { name: 'ui-rs.information.heading.transactionLog' }))
+);
+
+describe('TransactionLog', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('fetches lazily on open, then shows the transaction summary and events', async () => {
+ mockOkapi.setResponses({
+ 'broker/ill_transactions': { items: [transactionFixture] },
+ 'broker/ill_transactions/txn-1/events': { items: [eventFixture] },
+ });
+ renderSection();
+
+ expect(mockOkapi).not.toHaveBeenCalled();
+
+ openSection();
+
+ expect(document.querySelector('#transaction-log-card')).toBeNull();
+ expect(screen.queryByText('ui-rs.transactionLog.noTransaction')).not.toBeInTheDocument();
+
+ expect(await screen.findByText('ISIL:SUP')).toBeInTheDocument();
+ expect(screen.getByText('SUP-9')).toBeInTheDocument();
+ expect(screen.getByText('ExpectToSupply')).toBeInTheDocument();
+
+ expect(await screen.findByText('request-received')).toBeInTheDocument();
+ });
+
+ it('shows the empty state when the transaction has no events', async () => {
+ mockOkapi.setResponses({
+ 'broker/ill_transactions': { items: [transactionFixture] },
+ 'broker/ill_transactions/txn-1/events': { items: [] },
+ });
+ renderSection();
+ openSection();
+
+ expect(await screen.findByText('ui-rs.transactionLog.empty')).toBeInTheDocument();
+ });
+
+ it('shows the no-transaction state, distinct from "no events", without wasted fetches', async () => {
+ const { unmount } = renderSection({ id: 'pr-1', side: 'borrowing' });
+ openSection();
+ expect(screen.getByText('ui-rs.transactionLog.noTransaction')).toBeInTheDocument();
+ expect(mockOkapi).not.toHaveBeenCalled();
+ unmount();
+
+ mockOkapi.setResponses({ 'broker/ill_transactions': { items: [] } });
+ renderSection();
+ openSection();
+ expect(await screen.findByText('ui-rs.transactionLog.noTransaction')).toBeInTheDocument();
+ expect(screen.queryByText('ui-rs.transactionLog.empty')).not.toBeInTheDocument();
+ expect(mockOkapi.mock.calls.map(([p]) => p)).toEqual(['broker/ill_transactions']);
+ });
+
+ it('renders nothing on a lending request, where the broker never returns the transaction', () => {
+ renderSection({ id: 'pr-1', side: 'lending', requesterRequestId: 'REQ-101' });
+
+ expect(screen.queryByText('ui-rs.information.heading.transactionLog')).not.toBeInTheDocument();
+ expect(mockOkapi).not.toHaveBeenCalled();
+ });
+
+ it('shows the right transaction when the viewed request changes (keyed per record)', async () => {
+ mockOkapi.setResponses({
+ 'broker/ill_transactions': { items: [{ ...transactionFixture, supplierSymbol: 'ISIL:SUP-A' }] },
+ 'broker/ill_transactions/txn-1/events': { items: [] },
+ });
+ const Harness = () => {
+ const [rid, setRid] = useState('REQ-101');
+ return (
+ <>
+
+
+ >
+ );
+ };
+ renderWithRs();
+ openSection();
+ expect(await screen.findByText('ISIL:SUP-A')).toBeInTheDocument();
+
+ mockOkapi.setResponses({
+ 'broker/ill_transactions': { items: [{ ...transactionFixture, supplierSymbol: 'ISIL:SUP-B' }] },
+ 'broker/ill_transactions/txn-1/events': { items: [] },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'switch' }));
+
+ expect(await screen.findByText('ISIL:SUP-B')).toBeInTheDocument();
+ expect(screen.queryByText('ISIL:SUP-A')).not.toBeInTheDocument();
+ });
+});
diff --git a/ui-rs/src/components/ViewPatronRequest/sections/TransactionLog/index.js b/ui-rs/src/components/ViewPatronRequest/sections/TransactionLog/index.js
new file mode 100644
index 0000000..67be3ca
--- /dev/null
+++ b/ui-rs/src/components/ViewPatronRequest/sections/TransactionLog/index.js
@@ -0,0 +1 @@
+export { default } from './TransactionLog';
diff --git a/ui-rs/src/components/ViewPatronRequest/sections/index.js b/ui-rs/src/components/ViewPatronRequest/sections/index.js
index 1bebcad..9d35522 100644
--- a/ui-rs/src/components/ViewPatronRequest/sections/index.js
+++ b/ui-rs/src/components/ViewPatronRequest/sections/index.js
@@ -1,5 +1,6 @@
import RequestInfo from './RequestInfo';
import EventHistory from './EventHistory';
+import TransactionLog from './TransactionLog';
import DeveloperInfo from './DeveloperInfo';
-export default [RequestInfo, EventHistory, DeveloperInfo];
+export default [RequestInfo, EventHistory, TransactionLog, DeveloperInfo];
diff --git a/ui-rs/src/routes/ViewRoute.test.js b/ui-rs/src/routes/ViewRoute.test.js
index 3777829..340e911 100644
--- a/ui-rs/src/routes/ViewRoute.test.js
+++ b/ui-rs/src/routes/ViewRoute.test.js
@@ -14,7 +14,7 @@ jest.mock('@folio/stripes-components/lib/Icon', () => require('../test/iconMock'
jest.mock('@folio/stripes-components/lib/TextArea', () => require('../test/textAreaMock').default);
// react-syntax-highlighter ships ESM jest can't parse and only renders event
-// payloads (EventHistoryDetails), which an empty-history fixture never reaches.
+// payloads (EventLogDetails), which an empty-history fixture never reaches.
// Stub both entry points at the leaf to remove the externality without losing
// coverage this route test cares about.
jest.mock('react-syntax-highlighter', () => ({
diff --git a/ui-rs/translations/ui-rs/en.json b/ui-rs/translations/ui-rs/en.json
index c49a1ed..82508fe 100644
--- a/ui-rs/translations/ui-rs/en.json
+++ b/ui-rs/translations/ui-rs/en.json
@@ -96,7 +96,7 @@
"eventHistory.empty": "No events recorded.",
"eventHistory.metadata": "Metadata",
"eventHistory.eventName": "Event name",
- "eventHistory.eventType": "Event type",
+ "eventHistory.eventType": "Type",
"eventHistory.eventStatus": "Status",
"eventHistory.timestamp": "Timestamp",
"eventHistory.eventId": "Event ID",
@@ -124,11 +124,25 @@
"eventHistory.summary.requesterMessageConfirmation": "Requester message confirmation",
"eventHistory.summary.supplierMessage": "Supplier message: {reason}",
"eventHistory.summary.supplierMessageConfirmation": "Supplier message confirmation",
- "eventHistory.summary.patronRequestMessage": "Patron request message",
- "eventHistory.summary.eventRecorded": "Event recorded",
"eventHistory.summary.byActor": "by {actor}",
"eventHistory.actor": "Actor",
"eventHistory.rawEvent": "Raw event data",
+ "eventLog.filter": "Filter entries",
+ "eventLog.matchCount": "{count} of {total}",
+ "eventLog.noMatches": "No entries match the filter.",
+ "information.heading.transactionLog": "Transaction log",
+ "transactionLog.empty": "No events recorded.",
+ "transactionLog.noTransaction": "This request is not linked to an ILL transaction.",
+ "transactionLog.error": "Unable to load the transaction log.",
+ "transactionLog.timestamp": "Started",
+ "transactionLog.requesterSymbol": "Requester symbol",
+ "transactionLog.supplierSymbol": "Supplier symbol",
+ "transactionLog.requesterRequestId": "Requester request ID",
+ "transactionLog.supplierRequestId": "Supplier request ID",
+ "transactionLog.lastRequesterAction": "Last requester action",
+ "transactionLog.prevRequesterAction": "Previous requester action",
+ "transactionLog.lastSupplierStatus": "Last supplier status",
+ "transactionLog.prevSupplierStatus": "Previous supplier status",
"information.heading.partDetails": "Part details",
"information.heading.publicationDetails": "Publication details",
"information.heading.requestedTitle": "Requested title",