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
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import {
act,
type CSSProperties,
type ReactElement,
type ReactNode,
} from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type {
ActivityLogRecord,
ApprovalInstanceRecord,
} from '@rytass/bpm-core-client/workflow';
import { InstanceHistorySection } from './InstanceHistorySection';

Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });

// `@mezzanine-ui/core/stepper` and `@mezzanine-ui/react` are ESM-only, which
// the jest environment cannot load; the rest of this lib's component specs
// stub them the same way. The stubs keep `style` and `className` so what this
// spec asserts — the inline styles the section hands to Typography — still
// reaches the DOM.
jest.mock('@mezzanine-ui/core/stepper', () => ({
stepClasses: new Proxy(
{},
{ get: (_target, key): string => `mzn-step-${String(key)}` },
),
}));

jest.mock('@mezzanine-ui/react', () => {
function MockTypography(
props: Readonly<Record<string, unknown>>,
): ReactElement {
return (
<span
className={props.className as string | undefined}
style={props.style as CSSProperties | undefined}
>
{props.children as ReactNode}
</span>
);
}

function MockStepper(props: Readonly<Record<string, unknown>>): ReactElement {
return <div>{props.children as ReactNode}</div>;
}

function MockTooltip(props: Readonly<Record<string, unknown>>): ReactElement {
return <span>{props.children as ReactNode}</span>;
}

return {
Stepper: MockStepper,
Tooltip: MockTooltip,
Typography: MockTypography,
};
});

const ERROR_COLOR = 'var(--mzn-color-text-error)';

function createDecisionActivityLog(
id: string,
action: string,
comment: string,
): ActivityLogRecord {
return {
actorMemberId: 'member-approver',
createdAt: '2026-09-07T08:44:45.000Z',
eventType: 'TASK_DECIDED',
id,
instanceId: 'instance-1',
nodeId: 'userTask_1',
payloadJson: JSON.stringify({ action, comment }),
taskId: `task-${id}`,
} as unknown as ActivityLogRecord;
}

function renderHistory(
container: HTMLElement,
activityLogs: readonly ActivityLogRecord[],
): Root {
const root = createRoot(container);

act((): void => {
root.render(
(
<InstanceHistorySection
activityLogs={activityLogs}
instanceState={'RUNNING' as ApprovalInstanceRecord['state']}
memberProfilesById={new Map()}
signatureVerification={null}
signaturesById={new Map()}
taskDecisionsByTaskId={new Map()}
tasks={[]}
workflowSnapshot={null}
workflowTokens={[]}
/>
) as ReactElement,
);
});

return root;
}

/**
* The deepest element whose whole text is `text`. Reading the deepest one
* matters: a comment line is a `<span>` label followed by a text node, so an
* ancestor could match the same string while carrying different styles.
*/
function findDeepestByExactText(
container: HTMLElement,
text: string,
): HTMLElement | null {
const matches = Array.from(
container.querySelectorAll<HTMLElement>('*'),
).filter((element): boolean => element.textContent === text);

return matches.length > 0 ? matches[matches.length - 1] : null;
}

describe('InstanceHistorySection comment rendering', () => {
let container: HTMLElement;
let root: Root | null = null;

beforeEach((): void => {
container = document.createElement('div');
document.body.appendChild(container);
});

afterEach((): void => {
if (root) {
act((): void => {
root?.unmount();
});

root = null;
}

container.remove();
});

it('separates a comment label from its text with a full-width colon', () => {
root = renderHistory(container, [
createDecisionActivityLog('a1', 'APPROVED', '核准,請依核定條件執行'),
]);

// Without the separator the timeline reads "同意說明核准,請依核定條件執行",
// which is both unreadable and inconsistent with every other part of the
// step, all of which are rendered as "X:Y".
expect(
findDeepestByExactText(container, '同意說明:核准,請依核定條件執行'),
).not.toBeNull();
});

it('keeps a rejection reason in the error colour', () => {
root = renderHistory(container, [
createDecisionActivityLog('r1', 'REJECTED', '資料不足,請補件'),
]);

const rejectionReason = findDeepestByExactText(
container,
'拒絕原因:資料不足,請補件',
);

expect(rejectionReason).not.toBeNull();
// The colour is the only cue that separates a rejection reason from an
// approval note once both are rendered as their own comment line.
expect(rejectionReason?.style.color).toBe(ERROR_COLOR);
expect(rejectionReason?.style.borderLeftColor).toBe(ERROR_COLOR);
});

it('does not grey out the label of a rejection reason', () => {
root = renderHistory(container, [
createDecisionActivityLog('r2', 'REJECTED', '資料不足,請補件'),
]);

const label = findDeepestByExactText(container, '拒絕原因:');

expect(label).not.toBeNull();
expect(label?.style.color).toBe('');
});

it('leaves an approval note in the default text colour', () => {
root = renderHistory(container, [
createDecisionActivityLog('a2', 'APPROVED', '核准,請依核定條件執行'),
]);

const approvalNote = findDeepestByExactText(
container,
'同意說明:核准,請依核定條件執行',
);

expect(approvalNote?.style.color).toBe('');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,31 @@ const HISTORY_MEMBER_NAME_STYLE: CSSProperties = {
textUnderlineOffset: 3,
};

const HISTORY_DANGER_TEXT_STYLE: CSSProperties = {
// An approval comment is the one part of a timeline entry a person wrote, so
// it gets its own line and a readable size instead of being appended to the
// metadata run.
// The border is written as longhands so the danger tone can override only its
// colour; mixing `borderLeft` with a `borderLeftColor` override in one style
// object makes the result depend on key order and warns in React dev mode.
const HISTORY_COMMENT_STYLE: CSSProperties = {
borderLeftColor: 'var(--mzn-color-border)',
borderLeftStyle: 'solid',
borderLeftWidth: 2,
marginTop: 4,
paddingLeft: 8,
whiteSpace: 'pre-wrap',
};

const HISTORY_COMMENT_LABEL_STYLE: CSSProperties = {
color: 'var(--mzn-color-text-secondary)',
};

// A rejection reason has to read as a rejection, so it carries the error colour
// itself — label included, which is why the label drops its own colour in this
// tone.
const HISTORY_COMMENT_DANGER_STYLE: CSSProperties = {
...HISTORY_COMMENT_STYLE,
borderLeftColor: 'var(--mzn-color-text-error)',
color: 'var(--mzn-color-text-error)',
};

Expand Down Expand Up @@ -78,6 +102,14 @@ const ActivityHistoryStep = forwardRef<
ref,
): ReactElement {
const displayStatus = forcePending ? 'pending' : status;
const inlineDescriptionParts = descriptionParts.filter(
(part): part is Exclude<ActivityStepDescriptionPart, { type: 'comment' }> =>
part.type !== 'comment',
);
const commentDescriptionParts = descriptionParts.filter(
(part): part is Extract<ActivityStepDescriptionPart, { type: 'comment' }> =>
part.type === 'comment',
);

return (
<div
Expand Down Expand Up @@ -117,32 +149,50 @@ const ActivityHistoryStep = forwardRef<
{title}
<span className={stepClasses.titleConnectLine} />
</Typography>
{descriptionParts.length > 0 ? (
{inlineDescriptionParts.length > 0 ? (
<Typography className={stepClasses.description} variant="caption">
{descriptionParts.map((part, partIndex) => (
{inlineDescriptionParts.map((part, partIndex) => (
<Fragment key={`${part.type}-${partIndex}`}>
{partIndex > 0 ? ' · ' : null}
{renderActivityDescriptionPart(part)}
</Fragment>
))}
</Typography>
) : null}
{commentDescriptionParts.map((part, partIndex) => (
<Typography
key={`comment-${partIndex}`}
style={
part.tone === 'danger'
? HISTORY_COMMENT_DANGER_STYLE
: HISTORY_COMMENT_STYLE
}
variant="body"
>
<span
style={
part.tone === 'danger' ? undefined : HISTORY_COMMENT_LABEL_STYLE
}
>
{part.label}:
</span>
{part.text}
</Typography>
))}
</div>
</div>
);
});

// Comment parts are rendered on their own line by the caller, so they never
// reach this inline renderer.
function renderActivityDescriptionPart(
part: ActivityStepDescriptionPart,
part: Exclude<ActivityStepDescriptionPart, { type: 'comment' }>,
): ReactElement | string {
if (part.type === 'text') {
return part.text;
}

if (part.type === 'dangerText') {
return <span style={HISTORY_DANGER_TEXT_STYLE}>{part.text}</span>;
}

if (!part.email) {
return `${part.prefix}:${part.label}`;
}
Expand Down
Loading