Skip to content

Commit c1240a3

Browse files
feat(ui): the timeline's Changes chip holds the audit's field changes only; the CRM opens field history on its four main objects (#30294)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBVmFoQ6NLiPf5VfPaWXaynQ)_ Fixes #30074 Supersedes #30158 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** 记录动态里的「变更」只放审计日志读出的字段变更(带旧值→新值);「创建了 / 更新了 / 删除了 / 分配了 / 共享了」这类活动事件一律归「活动」,不论对象是否开启字段历史。面板下拉的「字段变更」用同一规则。另保留 #30158 的裁决 A 部分:CRM 示例四个主对象开启 `enable.trackHistory`,未开启历史的对象不显示「变更」筛选。 - **为什么改:** 按裁决 C(6649643659,维护者原话 「C」),它细化裁决 A(6631104393):分类由记录本身的来源决定,不随开关变;没开历史时「变更」本就无内容,隐藏它名副其实。 - **风险与回滚:** 风险低:只改前端筛选规则与示例标志,不改数据、接口、spec,也没动 `ACTIVITY_TYPE_TO_FEED_TYPE` 映射表;回滚即撤销本 PR。注意:在非默认记录页上(自取 `sys_activity` 的面板),下拉选「字段变更」今后为空,因为那里不读审计(见 Acceptance notes)。 - **先看:** `ui/packages/plugin-detail/src/recordTimelineStream.ts`(新判定 `isFieldHistoryEntry` 与 `filterTimelineStream`)、`ui/packages/plugin-detail/src/RecordActivityTimeline.tsx`(面板 `changes_only` 同规则)、`ui/packages/plugin-detail/src/__tests__/recordTimelineStream-27055.test.tsx`(翻转的「四个筛选」pin 与新 pin)。 - **两分钟验证:** `pnpm --dir ui exec vitest run packages/plugin-detail/src/__tests__/recordTimelineStream-27055.test.tsx`,预期全部通过;或 `pnpm dev:crm` 后新建一条线索并改公司名,打开动态页签:「变更」里是「公司 旧 → 新」,「活动」里是「创建了 线索…」。 - **需要你做的:** 无,席位落地。 ## What changes Resumed from `claude/issue-30074-crm-track-history@8c5b89c` (draft PR #30158, option A as built), merged with `origin/main` at `9b8a5867`, then the ruling C delta on top. - **Ruling C, the stream.** New `isFieldHistoryEntry(item)` in `recordTimelineStream.ts`: an item is a field-history entry when it is a `field_change` read from the audit log (`source: 'sys_audit_log'`, which `auditEntriesToFeedItems` stamps). `filterTimelineStream` uses it: Changes holds those only, Activity holds everything that is neither a comment nor one of those. The `sys_activity` rows that share the `field_change` presentation (`created`, `updated`, `deleted`, `assigned`, `shared`) are therefore Activity, whatever the history flag. `ACTIVITY_TYPE_TO_FEED_TYPE` is not edited. - **Ruling C, the panel.** `RecordActivityTimeline`'s `changes_only` dropdown filter uses the same predicate, so the panel and the stream agree on what a change is. - **Kept from #30158 (ruling A):** the CRM example's `crm_lead`, `crm_account`, `crm_contact`, `crm_opportunity` declare `enable: { trackHistory: true }`; `RecordFieldHistoryContext` plus `timelineStreamFilters()` hide the Changes chip while the record shows no field history, a stream left on Changes falls back to All, and `RecordDetailView` hands over its `historyEnabled`. - Changeset `.changeset/30074-crm-track-history.md` (from #30158) amended to ruling C (patch, `Clause-②: no`, `Metadata-protocol: none`); plugin-detail README says what Changes holds. File surface: #30158's 13 files plus two existing plugin-detail test files whose pins read the old classification (`renderers/__tests__/record-activity.test.tsx`, `renderers/__tests__/recordChatterFilterModeMentions-8968.test.tsx`); see Tests. No server, REST or spec change. ## Tests All readings on head `93bbdd78`, the final commit. - **Flipped pin** ("the four chips", `recordTimelineStream-27055.test.tsx`): the activity `created` row now sits under Activity, Changes holds the audit change alone, and every entry lands under exactly one of Activity / Discussion / Changes. - **New pins** (same file, top-level describe): with history on AND off, Activity lists every one of the five activity events and no audit change; CONTROL with history on, Changes holds the field change with its old and new values and none of the events; the panel's `changes_only` keeps the same rule as the stream. - **Fixture triage, two existing files.** `record-activity.test.tsx`: its "filterMode seeds which slice" pin seeded `changes_only` and expected the activity `updated` row, which ruling C moves out; it now seeds `tasks_only` (still proves the seed), and a new pin asserts `changes_only` holds no activity event. `recordChatterFilterModeMentions-8968.test.tsx`: its field-change fixture row gains `source: 'sys_audit_log'`, the stamp of the one producer of `fieldChanges` in the tree (`auditEntriesToFeedItems`); its assertions are unchanged. - **Ablation** via `scripts/ablation-replace.mjs` on `recordTimelineStream.ts`, anchor `item.type === 'field_change' && item.source === 'sys_audit_log'` replaced by `item.type === 'field_change'` (the subject resolves to `src` through the `ui/vitest.config.mts` aliases, so no dist leg): anchor 1 to 0, blob `16c475ce1d79` to `aee54ae67d0a`; 6 tests red, exactly the flipped and new pins; the 8968 file green (its row is a real field change either way). Restored: blob equals HEAD `16c475ce1d79`, `git diff HEAD` empty. - **Live** (example CRM, `objectstack dev --seed-admin --fresh`, Vite console, zh-CN): a new `crm_lead` (history on) edited once through the data API: chips 全部 / 活动 / 讨论 / 变更; 变更 shows 「公司 Ruling C Ltd → Ruling C Holdings」 and the creation's field values; 活动 shows 「创建了 线索 "Live Check Lead"」 only. A new `crm_activity` (history off) edited once: chips 全部 / 活动 / 讨论, no 变更; 活动 shows 「更新了 活动 …:主题」 and 「创建了 活动 …」. On the history-on lead the `updated` activity row steps aside because the audit says the same update with values (`mergeTimelineStream`, unchanged). <details><summary>Test readings</summary> - Targeted, the three touched test files: Test Files 3 passed (3), Tests 69 passed (69), lock VERDICT command-exit 0. - `@object-ui/plugin-detail` whole package plus the app-shell pin file `RecordDetailView.recordPageTiers-26678.test.tsx`: Test Files 370 passed, 1 skipped (371); Tests 3292 passed, 18 skipped (3310). - Type-check: `@object-ui/plugin-detail` exit 0; `@object-ui/app-shell` exit 0 after the full build (both `tsc --noEmit` and `tsc -p tsconfig.test.json`). A first app-shell run before the full build failed on unbuilt `@object-ui/*` declarations (TS2307), not on this diff. - Ablation run: Test Files 2 failed, 1 passed (3); Tests 6 failed, 63 passed (69); restore proven as above. </details> ## Acceptance notes - **The panel's `changes_only` off the default record page.** Off the synthesized record page the `record:activity` panel holds `sys_activity` rows only and reads no audit, so under ruling C its 字段变更 slice is empty there; before, it listed the activity `created` / `updated` rows. This follows the ruling's "same rule for the panel" and is pinned; whether that panel should offer the option at all where no audit is read is left to the seat. - **Bears on #30122 (not folded).** On a history-on lead created through the data API, 全部 says the creation twice: the activity row 「创建了 线索…」 and an audit entry listing each initial value (「负责人 — → Dev Admin; 线索名称 — → …」). `auditEntriesToFeedItems` keeps any audit entry with changes, the create entry included, although its doc says the creation is the activity stream's own row. Under ruling C the audit half sits under 变更 and the activity half under 活动. Compact rows did name the actor in this run (「· Dev Admin · 刚刚」). - `main` moved 14 commits after the merge at `9b8a5867`; none touches a path in this diff (`git diff --name-only HEAD...origin/main` against the 15 paths: zero hits) and `git merge-tree` is clean, so the branch was not re-merged. CI runs the merge ref. - Screenshots: kept in the project files, not committed. ## Verification Gates from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, derived at `93bbdd78` (53 commands). <details><summary>Gate readings</summary> - 53 of 53 run serially after a full build, each exit code captured before any pipe: all 53 exit 0. `--ran`: "53 derived famil(ies) accounted for — 53 run, 0 NOT-MEASURED (a DERIVED zero — all 53 recorded an exit code and none of them is 3)". - `node scripts/check-sdui-manifest.mjs` exit 0; `pnpm --dir ui exec vitest run scripts/`: Test Files 170 passed, 2 skipped (172); Tests 4985 passed, 2 skipped. - `pnpm --dir ui run check:new-line-citations`: 0 new citations, exit 0. - `check-platform-checklist` not owed: no export renamed or removed (`isFieldHistoryEntry` is added to the module, not to the package entry). - Not run locally, CI's: Build Core, Type Check UI and the workspace type-check lanes, Test Core, Test UI (whole app-shell), the repo-wide lint. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzic59DvGAbu7U78pZt)_ _#30158 closed as superseded by the seat._ <!-- signed: domain:ui#1 · os-support-ai · session_01WGufzfE4V9fzZgJLrLDmMC --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fccde75 commit c1240a3

15 files changed

Lines changed: 256 additions & 18 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
---
4+
5+
feat(record page): the timeline's Changes chip holds the audit's field changes only, and is offered only while the record shows its field history
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **`@object-ui/plugin-detail`: what Changes holds.** The timeline stream's Changes chip, and the activity panel's `changes_only` filter, hold the field changes read from the audit log (each with its before and after values) and nothing else. The activity rows that share their look (created, updated, deleted, assigned, shared) are events, so they sit under Activity whether or not the record shows its field history; before, they sat under Changes and, on the panel, under `changes_only`.
11+
- **`@object-ui/plugin-detail`: `RecordFieldHistoryContext`.** A host tells the record page's timeline stream whether it shows the record's field history; while it says `false`, the stream draws the All / Activity / Discussion chips and no Changes chip, and a stream left on Changes goes back to All. With no provider every chip draws, as before. `RecordActivityTimeline` takes the chips to offer as `streamFilters` (all four when absent).
12+
- **The Console's record page** hands over its `enable.trackHistory` gate, so an object that does not opt in no longer offers a Changes chip on its timeline tab.
13+
- **The CRM example** opens `enable.trackHistory` on `crm_lead`, `crm_account`, `crm_contact` and `crm_opportunity`, so their record pages show each field change with its before and after values. The flag stays opt-in for every other object.

‎examples/app-crm/src/objects/account.object.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ export const Account = ObjectSchema.create({
1515
description: 'A company that the org sells to or supports.',
1616
// What the record page shows at a glance, under the title.
1717
highlightFields: ['industry', 'annual_revenue', 'website'],
18+
// Field history on the record page: the timeline shows each field change
19+
// with its before and after values, and offers the Changes filter. The audit
20+
// log records every change either way; this opt-in flag is what makes the
21+
// record page show it.
22+
enable: { trackHistory: true },
1823

1924
fields: {
2025
name: Field.text({

‎examples/app-crm/src/objects/contact.object.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ export const Contact = ObjectSchema.create({
2121
nameField: 'full_name',
2222
// What the record page shows at a glance, under the title.
2323
highlightFields: ['account', 'email'],
24+
// Field history on the record page: the timeline shows each field change
25+
// with its before and after values, and offers the Changes filter. The audit
26+
// log records every change either way; this opt-in flag is what makes the
27+
// record page show it.
28+
enable: { trackHistory: true },
2429

2530
fields: {
2631
first_name: Field.text({

‎examples/app-crm/src/objects/lead.object.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ export const Lead = ObjectSchema.create({
1717
// What the record page shows at a glance, under the title. The stage is
1818
// not here: the record page already draws it as the stage path.
1919
highlightFields: ['company', 'source', 'lead_score', 'assigned_to'],
20+
// Field history on the record page: the timeline shows each field change
21+
// with its before and after values, and offers the Changes filter. The audit
22+
// log records every change either way; this opt-in flag is what makes the
23+
// record page show it.
24+
enable: { trackHistory: true },
2025

2126
fields: {
2227
name: Field.text({

‎examples/app-crm/src/objects/opportunity.object.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ export const Opportunity = ObjectSchema.create({
1717
// What the record page shows at a glance, under the title. The stage is
1818
// not here: the record page already draws it as the stage path.
1919
highlightFields: ['account', 'amount', 'close_date', 'probability'],
20+
// Field history on the record page: the timeline shows each field change
21+
// with its before and after values, and offers the Changes filter. The audit
22+
// log records every change either way; this opt-in flag is what makes the
23+
// record page show it.
24+
enable: { trackHistory: true },
2025

2126
fields: {
2227
name: Field.text({

‎ui/packages/app-shell/src/views/RecordDetailView.recordPageTiers-26678.test.tsx‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,12 +155,18 @@ function makeDataSource() {
155155
};
156156
}
157157

158-
function renderPage({ width, embedded = false, search = '', windowWidth = 1440 }: { width: number; embedded?: boolean; search?: string; windowWidth?: number }) {
158+
function renderPage({
159+
width,
160+
embedded = false,
161+
search = '',
162+
windowWidth = 1440,
163+
objects = OBJECTS,
164+
}: { width: number; embedded?: boolean; search?: string; windowWidth?: number; objects?: typeof OBJECTS }) {
159165
rootWidth = width;
160166
Object.defineProperty(window, 'innerWidth', { value: windowWidth, configurable: true, writable: true });
161167
dataSource = makeDataSource();
162168
const metadata = {
163-
objects: OBJECTS,
169+
objects,
164170
pages: [],
165171
loading: false,
166172
error: null,
@@ -175,7 +181,7 @@ function renderPage({ width, embedded = false, search = '', windowWidth = 1440 }
175181
<MetadataCtx.Provider value={metadata}>
176182
<RecordDetailView
177183
dataSource={dataSource as never}
178-
objects={OBJECTS}
184+
objects={objects}
179185
onEdit={() => {}}
180186
objectNameOverride={PARENT}
181187
recordIdOverride={RECORD_ID}
@@ -358,3 +364,28 @@ describe('RecordDetailView — one record page, laid out by its width (objectsta
358364
await waitFor(() => expect(auditReads()).toBeGreaterThan(0));
359365
});
360366
});
367+
368+
describe('RecordDetailView — the 动态 tab offers 变更 only while the record shows its field history (objectstack#30074)', () => {
369+
/** The full stream's chips, in order, once the 动态 tab has drawn. */
370+
const streamChips = async (container: HTMLElement) =>
371+
waitFor(
372+
() => {
373+
const chips = container.querySelectorAll('[data-related-enlarged="timeline"] [data-stream-filter]');
374+
expect(chips.length).toBeGreaterThan(0);
375+
return Array.from(chips).map((chip) => chip.getAttribute('data-stream-filter'));
376+
},
377+
{ timeout: 5000 },
378+
);
379+
380+
it('an object with `enable.trackHistory`: 全部 / 活动 / 讨论 / 变更', async () => {
381+
const { container } = renderPage({ width: 1440, search: '?related=timeline' });
382+
expect(await streamChips(container)).toEqual(['all', 'activity', 'discussion', 'changes']);
383+
});
384+
385+
it('the same object without it: no 变更 chip, and no audit read', async () => {
386+
const untracked = OBJECTS.map((o) => (o.name === PARENT ? { ...o, enable: undefined } : o)) as typeof OBJECTS;
387+
const { container } = renderPage({ width: 1440, search: '?related=timeline', objects: untracked });
388+
expect(await streamChips(container)).toEqual(['all', 'activity', 'discussion']);
389+
expect(auditReads()).toBe(0);
390+
});
391+
});

‎ui/packages/app-shell/src/views/RecordDetailView.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import { useState, useEffect, useLayoutEffect, useCallback, useContext, useMemo, useRef, type ReactElement } from 'react';
1212
import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom';
13-
import { activityRowToFeedItem, auditEntriesToFeedItems, bodyTableIdsOf, claimQueuedRecord, RECORD_PAGE_TIMELINE_PANEL, RECORD_TIMELINE_PANEL_MAX_ENTRIES, RecordFieldChangesContext, RecordTimelineTotalContext, DefaultRecordPageContext, EMPTY_RELATED_ARRANGEMENT, InlineEditSaveBar, RecordBodyTablesContext, RecordMetaFooter, RecordPageColumns, buildDefaultPageColumns, buildDefaultPageSchema, extractMentions, isRefusedFeedRead, recordPageTierHasRightColumn, recordSitsInQueue, resolveTitleField, useQueueClaimOffered, useRecordEditable, useRecordPageTier } from '@object-ui/plugin-detail';
13+
import { activityRowToFeedItem, auditEntriesToFeedItems, bodyTableIdsOf, claimQueuedRecord, RECORD_PAGE_TIMELINE_PANEL, RECORD_TIMELINE_PANEL_MAX_ENTRIES, RecordFieldChangesContext, RecordFieldHistoryContext, RecordTimelineTotalContext, DefaultRecordPageContext, EMPTY_RELATED_ARRANGEMENT, InlineEditSaveBar, RecordBodyTablesContext, RecordMetaFooter, RecordPageColumns, buildDefaultPageColumns, buildDefaultPageSchema, extractMentions, isRefusedFeedRead, recordPageTierHasRightColumn, recordSitsInQueue, resolveTitleField, useQueueClaimOffered, useRecordEditable, useRecordPageTier } from '@object-ui/plugin-detail';
1414
import { Empty, EmptyTitle, EmptyDescription, Button, PageHeaderActions, PageHeaderPhoneContext, PageHeaderTrailingContext, PanelToggles, RecordTopBar, cn, useRecordStepKeys, type PanelToggleItem, type RecordTopBarStepper } from '@object-ui/components';
1515
import { useAuth, createAuthenticatedFetch } from '@object-ui/auth';
1616
import { usePermissions } from '@object-ui/permissions';
@@ -5044,6 +5044,9 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
50445044
{/* objectstack#27055 — the field changes of the timeline stream,
50455045
for the synthesized page's `record:activity`. */}
50465046
<RecordFieldChangesContext.Provider value={assignedPage ? null : timelineFieldChanges}>
5047+
{/* objectstack#30074 — whether this record shows its field history:
5048+
the stream offers its 变更 chip only while it does. */}
5049+
<RecordFieldHistoryContext.Provider value={historyEnabled}>
50475050
{/* objectstack#28737 — the whole stream's count, for the 动态 panel,
50485051
its tab and the tab row's probe. */}
50495052
<RecordTimelineTotalContext.Provider value={assignedPage ? null : timelineTotal}>
@@ -5321,6 +5324,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
53215324
{modalElement}
53225325
</ActionProvider>
53235326
</RecordTimelineTotalContext.Provider>
5327+
</RecordFieldHistoryContext.Provider>
53245328
</RecordFieldChangesContext.Provider>
53255329
</DiscussionContextProvider>
53265330
</HighlightFieldsProvider>

‎ui/packages/plugin-detail/README.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -816,8 +816,11 @@ the header row opens the panel's whole face, the host's `?related=`): first
816816
**动态** (Activity) — one stream of the discussion, the activity and, with
817817
`enable.trackHistory`, the field changes, shown as a one-line composer, the
818818
newest entries and 「查看全部 N 条 →」, which opens the stream in full with its
819-
All / Activity / Discussion / Changes chips and paging (`?related=timeline`) —
820-
then each related list in the reader's arrangement, then Approvals and
819+
All / Activity / Discussion chips, a Changes chip while the record shows its
820+
field history (the host's `RecordFieldHistoryContext`; it holds the audit's
821+
field changes with their before and after values, and every activity event,
822+
`created` and `updated` included, sits under Activity), and paging
823+
(`?related=timeline`) — then each related list in the reader's arrangement, then Approvals and
821824
Attachments. The reader's ⊟ (`rightColumn`) takes the right column out of the
822825
two-column tiers; there is no tabbed tier.
823826

‎ui/packages/plugin-detail/src/RecordActivityTimeline.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { foldFeedItems, type ActivityTimelineEntry } from './renderers/recordAct
4545
import {
4646
TIMELINE_STREAM_FILTERS,
4747
filterTimelineStream,
48+
isFieldHistoryEntry,
4849
isSystemStreamEntry,
4950
type TimelineStreamFilter,
5051
} from './recordTimelineStream';
@@ -158,6 +159,8 @@ export interface RecordActivityTimelineProps {
158159
variant?: 'panel' | 'stream' | 'peek';
159160
/** The stream's chip (controlled); `all` when absent. */
160161
streamFilter?: TimelineStreamFilter;
162+
/** The chips the stream offers, in order; all four when absent (objectstack#30074). */
163+
streamFilters?: readonly TimelineStreamFilter[];
161164
onStreamFilterChange?: (filter: TimelineStreamFilter) => void;
162165
/** How many entries the whole stream holds, for the peek's 「查看全部 N 条 →」. */
163166
totalCount?: number;
@@ -282,8 +285,10 @@ function filterItems(items: FeedItem[], mode: FeedFilterMode): FeedItem[] {
282285
switch (mode) {
283286
case 'comments_only':
284287
return items.filter((i) => i.type === 'comment');
288+
// The stream's 变更 rule (objectstack#30074, ruling C): the audit's field
289+
// changes only, so the panel and the stream agree on what a change is.
285290
case 'changes_only':
286-
return items.filter((i) => i.type === 'field_change');
291+
return items.filter(isFieldHistoryEntry);
287292
case 'tasks_only':
288293
return items.filter((i) => i.type === 'task');
289294
default:
@@ -339,6 +344,7 @@ export const RecordActivityTimeline: React.FC<RecordActivityTimelineProps> = ({
339344
onRetryFeed,
340345
variant = 'panel',
341346
streamFilter = 'all',
347+
streamFilters = TIMELINE_STREAM_FILTERS,
342348
onStreamFilterChange,
343349
totalCount,
344350
onViewAll,
@@ -979,7 +985,7 @@ export const RecordActivityTimeline: React.FC<RecordActivityTimelineProps> = ({
979985
className="flex flex-wrap items-center gap-1"
980986
data-stream-filters=""
981987
>
982-
{TIMELINE_STREAM_FILTERS.map((chip) => (
988+
{streamFilters.map((chip) => (
983989
<button
984990
key={chip}
985991
type="button"

‎ui/packages/plugin-detail/src/__tests__/recordTimelineStream-27055.test.tsx‎

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
filterTimelineStream,
2626
mergeTimelineStream,
2727
RecordFieldChangesContext,
28+
RecordFieldHistoryContext,
2829
} from '../recordTimelineStream';
2930
import { RecordActivityTimeline } from '../RecordActivityTimeline';
3031
import { RecordActivityRenderer } from '../renderers/record-activity';
@@ -58,12 +59,17 @@ describe('the stream, pure (objectstack#27055)', () => {
5859
expect(mergeTimelineStream([comment, updated], null).map((i) => i.id)).toEqual(['a2', 'c1']);
5960
});
6061

61-
it('the four chips', () => {
62+
it("the four chips: 变更 holds the audit's field changes, the activity's `created` row is 活动 (objectstack#30074, ruling C)", () => {
6263
const stream = mergeTimelineStream([comment, call, created], audit);
6364
expect(filterTimelineStream(stream, 'all')).toHaveLength(4);
6465
expect(filterTimelineStream(stream, 'discussion').map((i) => i.id)).toEqual(['c1']);
65-
expect(filterTimelineStream(stream, 'changes').map((i) => i.id)).toEqual(['audit:1', 'a3']);
66-
expect(filterTimelineStream(stream, 'activity').map((i) => i.id)).toEqual(['a1']);
66+
// Judged by source, not by the shared `field_change` presentation: the
67+
// `created` row (a3) is an event, so it is not a change.
68+
expect(filterTimelineStream(stream, 'changes').map((i) => i.id)).toEqual(['audit:1']);
69+
expect(filterTimelineStream(stream, 'activity').map((i) => i.id)).toEqual(['a1', 'a3']);
70+
// Every entry sits under exactly one of 活动 / 讨论 / 变更.
71+
const sliced = (['activity', 'discussion', 'changes'] as const).flatMap((chip) => filterTimelineStream(stream, chip));
72+
expect(sliced.map((i) => i.id).sort()).toEqual(stream.map((i) => i.id).sort());
6773
});
6874
});
6975

@@ -139,3 +145,104 @@ describe('record:activity on the default record page is the stream', () => {
139145
expect(container.querySelector('[data-stream-system-row]')).toBeNull();
140146
});
141147
});
148+
149+
describe('the 变更 chip is offered only while the record shows its field history (objectstack#30074)', () => {
150+
const chips = (container: HTMLElement) =>
151+
Array.from(container.querySelectorAll('[data-stream-filter]')).map((chip) => chip.getAttribute('data-stream-filter'));
152+
const page = (fieldHistory: boolean | undefined) => {
153+
const block = (
154+
<DiscussionContextProvider items={[call, comment, created] as any[]} loading={false}>
155+
<RecordFieldChangesContext.Provider value={fieldHistory === false ? null : audit}>
156+
<RecordActivityRenderer schema={{ type: 'record:activity', showCompleted: true } as any} />
157+
</RecordFieldChangesContext.Provider>
158+
</DiscussionContextProvider>
159+
);
160+
return (
161+
<DefaultRecordPageContext.Provider value>
162+
{fieldHistory === undefined ? block : <RecordFieldHistoryContext.Provider value={fieldHistory}>{block}</RecordFieldHistoryContext.Provider>}
163+
</DefaultRecordPageContext.Provider>
164+
);
165+
};
166+
167+
it('history shown: the four chips', () => {
168+
const { container } = render(page(true));
169+
expect(chips(container)).toEqual(['all', 'activity', 'discussion', 'changes']);
170+
});
171+
172+
it('history not shown: no 变更 chip, and 全部 still draws every entry', () => {
173+
const { container } = render(page(false));
174+
expect(chips(container)).toEqual(['all', 'activity', 'discussion']);
175+
expect(container.querySelector('[data-stream-filter="all"]')?.getAttribute('aria-pressed')).toBe('true');
176+
expect(screen.getByText('Logged a call')).toBeTruthy();
177+
expect(screen.getByText('Budget is set')).toBeTruthy();
178+
});
179+
180+
it('CONTROL: no host saying, every chip draws, as before', () => {
181+
const { container } = render(page(undefined));
182+
expect(chips(container)).toEqual(['all', 'activity', 'discussion', 'changes']);
183+
});
184+
185+
it('a 变更 chip taken away while chosen leaves the stream on 全部', () => {
186+
const { container, rerender } = render(page(true));
187+
fireEvent.click(container.querySelector('[data-stream-filter="changes"]')!);
188+
expect(screen.queryByText('Logged a call')).toBeNull();
189+
rerender(page(false));
190+
expect(container.querySelector('[data-stream-filter="all"]')?.getAttribute('aria-pressed')).toBe('true');
191+
expect(screen.getByText('Logged a call')).toBeTruthy();
192+
});
193+
});
194+
195+
describe('an activity row sits under 活动 whatever the history flag; 变更 holds the audit only (objectstack#30074, ruling C)', () => {
196+
// The five activity types that share the `field_change` presentation.
197+
const events = (['created', 'updated', 'deleted', 'assigned', 'shared'] as const).map(
198+
(activityType, i) =>
199+
({
200+
id: `ev-${activityType}`,
201+
type: 'field_change',
202+
actor: 'Dev',
203+
body: `Event ${activityType}`,
204+
createdAt: new Date(Date.UTC(2026, 9, 10, 0, i)).toISOString(),
205+
activityType,
206+
}) as FeedItem,
207+
);
208+
const page = (fieldHistory: boolean) => (
209+
<DefaultRecordPageContext.Provider value>
210+
<RecordFieldHistoryContext.Provider value={fieldHistory}>
211+
<DiscussionContextProvider items={[call, ...events] as any[]} loading={false}>
212+
<RecordFieldChangesContext.Provider value={fieldHistory ? audit : null}>
213+
<RecordActivityRenderer schema={{ type: 'record:activity', showCompleted: true } as any} />
214+
</RecordFieldChangesContext.Provider>
215+
</DiscussionContextProvider>
216+
</RecordFieldHistoryContext.Provider>
217+
</DefaultRecordPageContext.Provider>
218+
);
219+
const choose = (container: HTMLElement, chip: string) =>
220+
fireEvent.click(container.querySelector(`[data-stream-filter="${chip}"]`)!);
221+
222+
for (const fieldHistory of [true, false]) {
223+
it(`history ${fieldHistory ? 'on' : 'off'}: 活动 lists every activity event, and no audit change`, () => {
224+
const { container } = render(page(fieldHistory));
225+
choose(container, 'activity');
226+
// With history on the audit says the update with its values, so the
227+
// activity's own `updated` row steps aside (mergeTimelineStream).
228+
const shown = events.filter((e) => !(fieldHistory && e.activityType === 'updated'));
229+
for (const event of shown) expect(screen.getByText(`Event ${event.activityType}`)).toBeTruthy();
230+
expect(screen.getByText('Logged a call')).toBeTruthy();
231+
expect(screen.queryByText(/New → Contacted/)).toBeNull();
232+
});
233+
}
234+
235+
it('CONTROL, history on: 变更 holds the field change with its old and new values, and no activity event', () => {
236+
const { container } = render(page(true));
237+
choose(container, 'changes');
238+
expect(screen.getByText(/Status New → Contacted/)).toBeTruthy();
239+
for (const event of events) expect(screen.queryByText(`Event ${event.activityType}`)).toBeNull();
240+
});
241+
242+
it("the panel's `changes_only` keeps the same rule as the stream", () => {
243+
render(<RecordActivityTimeline items={[call, created, ...audit]} filterMode="changes_only" />);
244+
expect(screen.getByText('Status')).toBeTruthy();
245+
expect(screen.queryByText('Created the lead')).toBeNull();
246+
expect(screen.queryByText('Logged a call')).toBeNull();
247+
});
248+
});

0 commit comments

Comments
 (0)