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",