From 2b03494813617c239efc6ca57994fdf66f9b0b0c Mon Sep 17 00:00:00 2001 From: Kai-Chieh Yang Date: Mon, 7 Sep 2026 18:33:43 +0800 Subject: [PATCH 1/4] feat(bpm-core-react): give an approval comment its own line in the timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every part of a timeline entry was appended to one caption-sized run joined by " · ", so what an approver wrote sat between the node label, the actor, the timestamp and the signature hash: 節點:簽核節點 1 · 操作者:陳財務經理 · 時間:… · 決議:同意 · 同意說明:測試 · 簽章:已驗證(5f412a0ccbe2…) `ActivityStepDescriptionPart` gains a `comment` member, and the four decision comments (approve / reject / return / transfer) now use it. The step renders metadata inline as before and each comment on its own line at `body` size with its label and a left rule, red for a rejection. `whiteSpace: pre-wrap` keeps line breaks a person typed, which the joined run also flattened. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018STtLMQTp1GVCYFp9GPSfg --- .../sections/InstanceHistorySection.tsx | 50 ++++++++++- .../instances/detail/sections/shared.spec.ts | 83 +++++++++++++++++++ .../views/instances/detail/sections/shared.ts | 29 +++++-- 3 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 libs/bpm-core-react/src/views/instances/detail/sections/shared.spec.ts diff --git a/libs/bpm-core-react/src/views/instances/detail/sections/InstanceHistorySection.tsx b/libs/bpm-core-react/src/views/instances/detail/sections/InstanceHistorySection.tsx index 511677d..255678c 100644 --- a/libs/bpm-core-react/src/views/instances/detail/sections/InstanceHistorySection.tsx +++ b/libs/bpm-core-react/src/views/instances/detail/sections/InstanceHistorySection.tsx @@ -44,6 +44,26 @@ const HISTORY_DANGER_TEXT_STYLE: CSSProperties = { color: 'var(--mzn-color-text-error)', }; +// 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. +const HISTORY_COMMENT_STYLE: CSSProperties = { + borderLeft: '2px solid var(--mzn-color-border)', + marginTop: 4, + paddingLeft: 8, + whiteSpace: 'pre-wrap', +}; + +const HISTORY_COMMENT_LABEL_STYLE: CSSProperties = { + color: 'var(--mzn-color-text-secondary)', + marginRight: 4, +}; + +const HISTORY_COMMENT_DANGER_STYLE: CSSProperties = { + ...HISTORY_COMMENT_STYLE, + borderLeftColor: 'var(--mzn-color-text-error)', +}; + function joinClassNames( ...classNames: readonly (string | null | undefined)[] ): string { @@ -78,6 +98,14 @@ const ActivityHistoryStep = forwardRef< ref, ): ReactElement { const displayStatus = forcePending ? 'pending' : status; + const inlineDescriptionParts = descriptionParts.filter( + (part): part is Exclude => + part.type !== 'comment', + ); + const commentDescriptionParts = descriptionParts.filter( + (part): part is Extract => + part.type === 'comment', + ); return (
- {descriptionParts.length > 0 ? ( + {inlineDescriptionParts.length > 0 ? ( - {descriptionParts.map((part, partIndex) => ( + {inlineDescriptionParts.map((part, partIndex) => ( {partIndex > 0 ? ' · ' : null} {renderActivityDescriptionPart(part)} @@ -127,13 +155,29 @@ const ActivityHistoryStep = forwardRef< ))} ) : null} + {commentDescriptionParts.map((part, partIndex) => ( + + {part.label} + {part.text} + + ))}
); }); +// Comment parts are rendered on their own line by the caller, so they never +// reach this inline renderer. function renderActivityDescriptionPart( - part: ActivityStepDescriptionPart, + part: Exclude, ): ReactElement | string { if (part.type === 'text') { return part.text; diff --git a/libs/bpm-core-react/src/views/instances/detail/sections/shared.spec.ts b/libs/bpm-core-react/src/views/instances/detail/sections/shared.spec.ts new file mode 100644 index 0000000..8506639 --- /dev/null +++ b/libs/bpm-core-react/src/views/instances/detail/sections/shared.spec.ts @@ -0,0 +1,83 @@ +import type { ActivityLogRecord } from '@rytass/bpm-core-client'; + +import { readActivityDetailParts } from './shared'; + +function createDecisionActivityLog(): ActivityLogRecord { + return { + actorMemberId: 'member-approver', + createdAt: '2026-09-07T08:44:45.000Z', + eventType: 'TASK_DECIDED', + id: 'activity-1', + instanceId: 'instance-1', + nodeId: 'userTask_1', + payloadJson: '{}', + taskId: 'task-1', + } as unknown as ActivityLogRecord; +} + +function readParts( + payload: Readonly>, +): readonly ReturnType[number][] { + return readActivityDetailParts( + createDecisionActivityLog(), + payload, + null, + new Map(), + new Map(), + null, + new Map(), + ); +} + +describe('readActivityDetailParts', () => { + it('keeps an approval comment out of the inline metadata run', () => { + const parts = readParts({ action: 'APPROVED', comment: '請注意交期' }); + + // The comment must not be folded into a `text` part: those are joined with + // " · " alongside the node, actor, timestamp and signature hash, which is + // what made a written comment unreadable in the timeline. + expect(parts).toContainEqual({ + label: '同意說明', + text: '請注意交期', + tone: 'neutral', + type: 'comment', + }); + expect( + parts.filter((part) => part.type === 'text' && part.text.includes('請注意交期')), + ).toHaveLength(0); + }); + + it('marks a rejection reason as a danger comment', () => { + const parts = readParts({ action: 'REJECTED', comment: '金額有誤' }); + + expect(parts).toContainEqual({ + label: '拒絕原因', + text: '金額有誤', + tone: 'danger', + type: 'comment', + }); + }); + + it('keeps the decision label inline', () => { + const parts = readParts({ action: 'APPROVED', comment: '請注意交期' }); + + expect(parts).toContainEqual({ text: '決議:同意', type: 'text' }); + }); + + it('emits no comment part when an approval carries no comment', () => { + const parts = readParts({ action: 'APPROVED', comment: null }); + + expect(parts.filter((part) => part.type === 'comment')).toHaveLength(0); + }); + + it('still shows a placeholder for a rejection with no comment', () => { + const parts = readParts({ action: 'REJECTED', comment: null }); + + expect(parts).toContainEqual({ + label: '拒絕原因', + text: '-', + tone: 'danger', + type: 'comment', + }); + }); +}); diff --git a/libs/bpm-core-react/src/views/instances/detail/sections/shared.ts b/libs/bpm-core-react/src/views/instances/detail/sections/shared.ts index 9e33170..e6fb0e3 100644 --- a/libs/bpm-core-react/src/views/instances/detail/sections/shared.ts +++ b/libs/bpm-core-react/src/views/instances/detail/sections/shared.ts @@ -74,6 +74,17 @@ export type ActivityStepDescriptionPart = memberId: string | null; prefix: string; type: 'member'; + }> + /** + * What an approver actually wrote. Kept apart from the `text` parts so the + * timeline can give it its own line instead of burying it mid-sentence + * between the node, the actor, the timestamp and the signature hash. + */ + | Readonly<{ + label: string; + text: string; + tone: 'danger' | 'neutral'; + type: 'comment'; }>; export interface ActivityStepRecord { @@ -417,6 +428,14 @@ export function readDangerTextDescriptionPart( return isPresentText(text) ? { text, type: 'dangerText' } : null; } +export function readCommentDescriptionPart( + label: string, + text: string | null, + tone: 'danger' | 'neutral' = 'neutral', +): ActivityStepDescriptionPart | null { + return isPresentText(text) ? { label, text, tone, type: 'comment' } : null; +} + export function readMemberDescriptionPart( prefix: string, memberId: string | null, @@ -664,13 +683,13 @@ export function readActivityDetailParts( return [ readTextDescriptionPart(decisionLabel), action === 'REJECTED' - ? readDangerTextDescriptionPart(`拒絕原因:${comment ?? '-'}`) + ? readCommentDescriptionPart('拒絕原因', comment ?? '-', 'danger') : null, - action === 'APPROVED' && comment - ? readTextDescriptionPart(`同意說明:${comment}`) + action === 'APPROVED' + ? readCommentDescriptionPart('同意說明', comment) : null, action === 'RETURNED' - ? readTextDescriptionPart(`退回說明:${comment ?? '-'}`) + ? readCommentDescriptionPart('退回說明', comment ?? '-') : null, action === 'TRANSFERRED' ? readTextDescriptionPart( @@ -681,7 +700,7 @@ export function readActivityDetailParts( ) : null, action === 'TRANSFERRED' - ? readTextDescriptionPart(`轉派說明:${comment ?? '-'}`) + ? readCommentDescriptionPart('轉派說明', comment ?? '-') : null, signature ? readTextDescriptionPart( From 4543bee7d3ea7f702efc13351efbea3c5628719f Mon Sep 17 00:00:00 2001 From: Kai-Chieh Yang Date: Mon, 7 Sep 2026 18:38:05 +0800 Subject: [PATCH 2/4] feat(bpm-core-react): let an approver add several people at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AdhocTargetInput.memberIds` has always been a list, but the picker was `mode="single"` and the handler sent `[selectedMember.id]`, so adding three countersigners meant opening the dialog three times. The picker is now `mode="multiple"` with `overflowStrategy="wrap"` so the chosen names stay visible, and the selection maps straight onto `memberIds`. Going multiple also picks up `clearable`, which this component's single mode never wired up. `menuMaxHeight` is set while the picker is open anyway: the list is the member directory, which has no upper bound, and without it the menu grows past the viewport with no scrollbar. The "which target do we send" decision moved into `readAdhocTargetDraft()` in `shared.ts` so it can be tested without rendering the dialog; that is the part that changed shape here. `onSearchTextChange` is dropped from this picker. It called `readUniqueMemberOption` to auto-select when the typed text matched exactly one option, which is a single-selection convenience — under multiple selection it would fight the list the approver is building. The transfer picker still uses it and is untouched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018STtLMQTp1GVCYFp9GPSfg --- .../detail/sections/InstanceTasksSection.tsx | 53 ++++++++------- .../instances/detail/sections/shared.spec.ts | 66 ++++++++++++++++++- .../views/instances/detail/sections/shared.ts | 46 +++++++++++++ 3 files changed, 137 insertions(+), 28 deletions(-) diff --git a/libs/bpm-core-react/src/views/instances/detail/sections/InstanceTasksSection.tsx b/libs/bpm-core-react/src/views/instances/detail/sections/InstanceTasksSection.tsx index 20cf112..0bf2317 100644 --- a/libs/bpm-core-react/src/views/instances/detail/sections/InstanceTasksSection.tsx +++ b/libs/bpm-core-react/src/views/instances/detail/sections/InstanceTasksSection.tsx @@ -43,6 +43,8 @@ import { readErrorMessage, readMemberDisplayText, readMemberOption, + isPresentMemberOption, + readAdhocTargetDraft, readMemberOptionFromValue, readNodeDisplayLabel, readReturnTargetOptions, @@ -228,7 +230,9 @@ export const InstanceTasksSection = forwardRef< // Ad-hoc modal (countersign / pre-approval / stage & completion notify) const [adhocComment, setAdhocComment] = useState(''); - const [adhocMember, setAdhocMember] = useState(null); + const [adhocMembers, setAdhocMembers] = useState( + [], + ); const [adhocMemberLoading, setAdhocMemberLoading] = useState(false); const [adhocMemberOptions, setAdhocMemberOptions] = useState< readonly MemberOption[] @@ -665,7 +669,7 @@ export const InstanceTasksSection = forwardRef< function resetAdhocModalState(): void { setAdhocComment(''); - setAdhocMember(null); + setAdhocMembers([]); setAdhocOnReject('REJECT_INSTANCE'); setAdhocTargetKind('MEMBER'); setAdhocWebhookUrl(''); @@ -709,25 +713,19 @@ export const InstanceTasksSection = forwardRef< const isNotifyMode = adhocMode === 'STAGE_NOTIFY' || adhocMode === 'COMPLETION_NOTIFY'; const useWebhookTarget = isNotifyMode && adhocTargetKind === 'WEBHOOK'; - const trimmedWebhookUrl = adhocWebhookUrl.trim(); - const selectedMember = adhocMember; - - if (useWebhookTarget && !trimmedWebhookUrl) { - setError('請輸入 Webhook URL'); - - return; - } + const targetDraft = readAdhocTargetDraft({ + memberIds: adhocMembers.map((member) => member.id), + useWebhookTarget, + webhookUrl: adhocWebhookUrl, + }); - if (!useWebhookTarget && !selectedMember) { - setError('請選擇對象成員'); + if (!targetDraft.valid) { + setError(targetDraft.error); return; } - const target = - useWebhookTarget || !selectedMember - ? { kind: 'WEBHOOK' as const, webhookUrl: trimmedWebhookUrl } - : { kind: 'MEMBER' as const, memberIds: [selectedMember.id] }; + const { target } = targetDraft; const trimmedAdhocComment = adhocComment.trim() || null; setAdhocSubmitting(true); @@ -816,7 +814,7 @@ export const InstanceTasksSection = forwardRef< const adhocConfirmDisabled = isAdhocNotifyMode && adhocTargetKind === 'WEBHOOK' ? !adhocWebhookUrl.trim() - : !adhocMember; + : adhocMembers.length === 0; const selectedAdhocOnRejectOption = ADHOC_ON_REJECT_OPTIONS.find((option) => option.id === adhocOnReject) ?? ADHOC_ON_REJECT_OPTIONS[0]; @@ -1160,25 +1158,26 @@ export const InstanceTasksSection = forwardRef< }} loading={adhocMemberLoading} loadingText="搜尋成員中..." - mode="single" - onChange={(option): void => - setAdhocMember(readMemberOptionFromValue(option)) - } - onSearch={handleSearchAdhocMembers} - onSearchTextChange={(searchText): void => - setAdhocMember( - readUniqueMemberOption(searchText, adhocMemberOptions), + menuMaxHeight={320} + mode="multiple" + onChange={(options): void => + setAdhocMembers( + options + .map(readMemberOptionFromValue) + .filter(isPresentMemberOption), ) } + onSearch={handleSearchAdhocMembers} onVisibilityChange={(open): void => { if (open) { void handleSearchAdhocMembers(''); } }} options={[...adhocMemberOptions]} - placeholder="搜尋姓名或信箱" + overflowStrategy="wrap" + placeholder="搜尋姓名或信箱,可加入多位" searchDebounceTime={300} - value={adhocMember} + value={[...adhocMembers]} /> )} diff --git a/libs/bpm-core-react/src/views/instances/detail/sections/shared.spec.ts b/libs/bpm-core-react/src/views/instances/detail/sections/shared.spec.ts index 8506639..caf1d06 100644 --- a/libs/bpm-core-react/src/views/instances/detail/sections/shared.spec.ts +++ b/libs/bpm-core-react/src/views/instances/detail/sections/shared.spec.ts @@ -1,6 +1,6 @@ import type { ActivityLogRecord } from '@rytass/bpm-core-client'; -import { readActivityDetailParts } from './shared'; +import { readActivityDetailParts, readAdhocTargetDraft } from './shared'; function createDecisionActivityLog(): ActivityLogRecord { return { @@ -81,3 +81,67 @@ describe('readActivityDetailParts', () => { }); }); }); + +describe('readAdhocTargetDraft', () => { + it('sends every selected member, not just the first', () => { + expect( + readAdhocTargetDraft({ + memberIds: ['member-a', 'member-b', 'member-c'], + useWebhookTarget: false, + webhookUrl: '', + }), + ).toEqual({ + target: { + kind: 'MEMBER', + memberIds: ['member-a', 'member-b', 'member-c'], + }, + valid: true, + }); + }); + + it('refuses an empty member selection', () => { + expect( + readAdhocTargetDraft({ + memberIds: [], + useWebhookTarget: false, + webhookUrl: '', + }), + ).toEqual({ error: '請選擇對象成員', valid: false }); + }); + + it('trims a webhook url', () => { + expect( + readAdhocTargetDraft({ + memberIds: [], + useWebhookTarget: true, + webhookUrl: ' https://example.com/hook ', + }), + ).toEqual({ + target: { kind: 'WEBHOOK', webhookUrl: 'https://example.com/hook' }, + valid: true, + }); + }); + + it('refuses a blank webhook url', () => { + expect( + readAdhocTargetDraft({ + memberIds: ['member-a'], + useWebhookTarget: true, + webhookUrl: ' ', + }), + ).toEqual({ error: '請輸入 Webhook URL', valid: false }); + }); + + it('ignores selected members once the webhook target is chosen', () => { + expect( + readAdhocTargetDraft({ + memberIds: ['member-a'], + useWebhookTarget: true, + webhookUrl: 'https://example.com/hook', + }), + ).toEqual({ + target: { kind: 'WEBHOOK', webhookUrl: 'https://example.com/hook' }, + valid: true, + }); + }); +}); diff --git a/libs/bpm-core-react/src/views/instances/detail/sections/shared.ts b/libs/bpm-core-react/src/views/instances/detail/sections/shared.ts index e6fb0e3..b699f7a 100644 --- a/libs/bpm-core-react/src/views/instances/detail/sections/shared.ts +++ b/libs/bpm-core-react/src/views/instances/detail/sections/shared.ts @@ -379,6 +379,52 @@ export function readMemberOption(profile: MemberProfileRecord): MemberOption { }; } +/** + * The target an ad-hoc directive is sent to, or the reason it cannot be built + * yet. Kept out of the component so the "which shape do we send" decision is + * testable on its own — it is the part that changed when the member picker + * went from one selection to many. + */ +export type AdhocTargetDraft = + | Readonly<{ + target: + | Readonly<{ kind: 'MEMBER'; memberIds: readonly string[] }> + | Readonly<{ kind: 'WEBHOOK'; webhookUrl: string }>; + valid: true; + }> + | Readonly<{ error: string; valid: false }>; + +export function readAdhocTargetDraft({ + memberIds, + useWebhookTarget, + webhookUrl, +}: { + readonly memberIds: readonly string[]; + readonly useWebhookTarget: boolean; + readonly webhookUrl: string; +}): AdhocTargetDraft { + if (useWebhookTarget) { + const trimmedWebhookUrl = webhookUrl.trim(); + + return trimmedWebhookUrl + ? { + target: { kind: 'WEBHOOK', webhookUrl: trimmedWebhookUrl }, + valid: true, + } + : { error: '請輸入 Webhook URL', valid: false }; + } + + return memberIds.length > 0 + ? { target: { kind: 'MEMBER', memberIds }, valid: true } + : { error: '請選擇對象成員', valid: false }; +} + +export function isPresentMemberOption( + option: MemberOption | null, +): option is MemberOption { + return Boolean(option); +} + export function readMemberOptionFromValue(value: unknown): MemberOption | null { if (!isRecord(value)) { return null; From 9f58b49f466eeee7bbce5fef9e1b4d532da2bfc5 Mon Sep 17 00:00:00 2001 From: Chia Yu Pai Date: Mon, 7 Sep 2026 21:50:32 +0800 Subject: [PATCH 3/4] fix(bpm-core-react): restore the separator and colour of a timeline comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving an approval comment onto its own line dropped two things the inline `dangerText` part used to carry. The label and the text were rendered adjacent with only a 4px margin between them, so the timeline read "拒絕原因資料不足,請補件" — the margin is invisible to `textContent`, and every other part of a step still reads "X:Y". The margin goes with it: ":" already carries its own trailing half-em. The danger tone only overrode `borderLeftColor`, so a rejection reason lost `--mzn-color-text-error` and became indistinguishable from an approval note. It now keeps that colour, label included. Writing the border as longhands is defensive rather than a fix — the spread already put the `borderLeftColor` override after the shorthand, so the result was correct — but it removes the dependency on that ordering. Also drops the inline `dangerText` part this replaced: the comment part left it with no callers, so `readDangerTextDescriptionPart`, the variant itself, `HISTORY_DANGER_TEXT_STYLE` and its renderer branch were all unreachable. Adds a render spec for the separator and both tones, since the only cover they had was e2e. Co-Authored-By: Claude Opus 5 (1M context) --- .../sections/InstanceHistorySection.spec.tsx | 193 ++++++++++++++++++ .../sections/InstanceHistorySection.tsx | 28 ++- .../views/instances/detail/sections/shared.ts | 7 - 3 files changed, 210 insertions(+), 18 deletions(-) create mode 100644 libs/bpm-core-react/src/views/instances/detail/sections/InstanceHistorySection.spec.tsx diff --git a/libs/bpm-core-react/src/views/instances/detail/sections/InstanceHistorySection.spec.tsx b/libs/bpm-core-react/src/views/instances/detail/sections/InstanceHistorySection.spec.tsx new file mode 100644 index 0000000..7034403 --- /dev/null +++ b/libs/bpm-core-react/src/views/instances/detail/sections/InstanceHistorySection.spec.tsx @@ -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>, + ): ReactElement { + return ( + + {props.children as ReactNode} + + ); + } + + function MockStepper(props: Readonly>): ReactElement { + return
{props.children as ReactNode}
; + } + + function MockTooltip(props: Readonly>): ReactElement { + return {props.children as ReactNode}; + } + + 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( + ( + + ) as ReactElement, + ); + }); + + return root; +} + +/** + * The deepest element whose whole text is `text`. Reading the deepest one + * matters: a comment line is a `` 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('*'), + ).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(''); + }); +}); diff --git a/libs/bpm-core-react/src/views/instances/detail/sections/InstanceHistorySection.tsx b/libs/bpm-core-react/src/views/instances/detail/sections/InstanceHistorySection.tsx index 255678c..28a6da2 100644 --- a/libs/bpm-core-react/src/views/instances/detail/sections/InstanceHistorySection.tsx +++ b/libs/bpm-core-react/src/views/instances/detail/sections/InstanceHistorySection.tsx @@ -40,15 +40,16 @@ const HISTORY_MEMBER_NAME_STYLE: CSSProperties = { textUnderlineOffset: 3, }; -const HISTORY_DANGER_TEXT_STYLE: CSSProperties = { - color: 'var(--mzn-color-text-error)', -}; - // 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 = { - borderLeft: '2px solid var(--mzn-color-border)', + borderLeftColor: 'var(--mzn-color-border)', + borderLeftStyle: 'solid', + borderLeftWidth: 2, marginTop: 4, paddingLeft: 8, whiteSpace: 'pre-wrap', @@ -56,12 +57,15 @@ const HISTORY_COMMENT_STYLE: CSSProperties = { const HISTORY_COMMENT_LABEL_STYLE: CSSProperties = { color: 'var(--mzn-color-text-secondary)', - marginRight: 4, }; +// 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)', }; function joinClassNames( @@ -165,7 +169,13 @@ const ActivityHistoryStep = forwardRef< } variant="body" > - {part.label} + + {part.label}: + {part.text} ))} @@ -183,10 +193,6 @@ function renderActivityDescriptionPart( return part.text; } - if (part.type === 'dangerText') { - return {part.text}; - } - if (!part.email) { return `${part.prefix}:${part.label}`; } diff --git a/libs/bpm-core-react/src/views/instances/detail/sections/shared.ts b/libs/bpm-core-react/src/views/instances/detail/sections/shared.ts index b699f7a..9b547ca 100644 --- a/libs/bpm-core-react/src/views/instances/detail/sections/shared.ts +++ b/libs/bpm-core-react/src/views/instances/detail/sections/shared.ts @@ -67,7 +67,6 @@ export type MemberOption = Readonly<{ export type ActivityStepDescriptionPart = | Readonly<{ text: string; type: 'text' }> - | Readonly<{ text: string; type: 'dangerText' }> | Readonly<{ email: string | null; label: string; @@ -468,12 +467,6 @@ export function readTextDescriptionPart( return isPresentText(text) ? { text, type: 'text' } : null; } -export function readDangerTextDescriptionPart( - text: string | null, -): ActivityStepDescriptionPart | null { - return isPresentText(text) ? { text, type: 'dangerText' } : null; -} - export function readCommentDescriptionPart( label: string, text: string | null, From 31455c73cec16d139fe0bbacae8d552f4dec2c30 Mon Sep 17 00:00:00 2001 From: Chia Yu Pai Date: Mon, 7 Sep 2026 21:50:38 +0800 Subject: [PATCH 4/4] fix(bpm-core-react): import ActivityLogRecord from the subpath that exports it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@rytass/bpm-core-client` has no root export for it, so the spec failed `tsc -p tsconfig.spec.json` even though jest was green — a type-only import is erased before ts-jest ever resolves it. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/views/instances/detail/sections/shared.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/bpm-core-react/src/views/instances/detail/sections/shared.spec.ts b/libs/bpm-core-react/src/views/instances/detail/sections/shared.spec.ts index caf1d06..18f8229 100644 --- a/libs/bpm-core-react/src/views/instances/detail/sections/shared.spec.ts +++ b/libs/bpm-core-react/src/views/instances/detail/sections/shared.spec.ts @@ -1,4 +1,4 @@ -import type { ActivityLogRecord } from '@rytass/bpm-core-client'; +import type { ActivityLogRecord } from '@rytass/bpm-core-client/workflow'; import { readActivityDetailParts, readAdhocTargetDraft } from './shared';