diff --git a/.changeset/28737-record-page-timeline-count.md b/.changeset/28737-record-page-timeline-count.md new file mode 100644 index 00000000000..9dc31fd4642 --- /dev/null +++ b/.changeset/28737-record-page-timeline-count.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': minor +'@objectstack/rest': minor +--- + +feat(rest): the record page payload serves the record's 动态 count, so the activity part asks only the panel's rows (#28737) + +Clause-②: yes (widening) +Metadata-protocol: added + +`POST /api/v1/data/{object}/{id}/page` counts the record's 动态 stream on the server, so a record page no longer reads 200 activity rows to name a count. + +- **`timeline: { discussion, activity, fieldChanges }`** (request) → **`timeline`** (response): `{ total }` — the number of entries the record's 动态 stream holds for the caller, from the sources named: the comment thread (`sys_comment`), the activity rows the stream draws (`sys_activity` without the types in `RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES`) and the ledger's field changes (`sys_audit_log` writes). With field changes counted, the activity's `updated` rows step aside for them, as the stream does. Each source is counted as the caller's own read, behind its object's exposure gate; a refused comment or activity read refuses the part (`RecordPageStreamRefusalSchema`), a refused ledger read counts no field change, and a source the boot does not register holds no entry. + +`RECORD_PAGE_MAX_ACTIVITY_ROWS` stays 200 and `RecordPageActivityRequestSchema` keeps its bounds: the 动态 tab still asks the window it draws. New exports in `@objectstack/spec/api`: `RecordPageTimelineRequestSchema`, `RecordPageTimelineCountSchema`, `RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES`, `RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE`, and their types. The console's activity reading maps exactly those undrawn types to no entry, so the server's count and the drawn stream share one definition. diff --git a/content/docs/frontend/plugins/plugin-detail.mdx b/content/docs/frontend/plugins/plugin-detail.mdx index 69007726c84..7760b524267 100644 --- a/content/docs/frontend/plugins/plugin-detail.mdx +++ b/content/docs/frontend/plugins/plugin-detail.mdx @@ -103,9 +103,12 @@ declare const items: FeedItem[]; In the Console app, `RecordChatterPanel` is integrated with record detail views. A record page that draws its feed when it opens takes the discussion from the one request it makes on open, `POST /api/v1/data/{object}/{id}/page` (the [record page payload](/docs/references/api/record-page)). -That request asks `discussion` (the record's comment thread and those comments' reactions) -and `activity` (its newest activity rows) beside the record, and the page reads neither -`sys_comment` nor `sys_activity` on its own: +That request asks `discussion` (the record's comment thread and those comments' reactions), +`activity` (its newest activity rows: the 动态 panel's five, or the 200 the 动态 tab draws when +the page opens on it) and `timeline` (the count of the whole 动态 stream, which the panel's +count chip and the tab's count name) beside the record, and the page reads neither +`sys_comment` nor `sys_activity` on its own. Opening the 动态 tab later reads the activity +window it draws only when the record holds more rows than the panel's: ```tsx import type { DataSource, FeedItem } from '@object-ui/types'; @@ -115,12 +118,16 @@ declare const objectName: string; declare const recordId: string; declare const dataSource: DataSource; -// The record page's one request; the discussion and the activity are two of its parts. +// The record page's one request; the discussion, the activity and the 动态 count are three of its parts. const page = await dataSource.getRecordPage?.(objectName, recordId, { discussion: true, - activity: { top: 200 }, + activity: { top: 5 }, + timeline: { discussion: true, activity: true, fieldChanges: true }, }); +// The count of the whole stream, not of the rows held. +const total = page?.timeline && 'total' in page.timeline ? page.timeline.total : null; + // A part is its rows, or the refusal of the read it replaces. const discussion = page?.discussion; const comments = discussion && 'comments' in discussion ? discussion.comments : []; diff --git a/content/docs/references/api/record-page.mdx b/content/docs/references/api/record-page.mdx index 6ff1f240e7b..a8ccdcc3153 100644 --- a/content/docs/references/api/record-page.mdx +++ b/content/docs/references/api/record-page.mdx @@ -1,7 +1,7 @@ --- title: Record Page schema — API Protocol reference navTitle: Record Page -description: "Record page payload — POST /api/v1/data/{object}/{id}/page. Reference for RecordPageActivityRequest and 16 more: every property with its type and default." +description: "Record page payload — POST /api/v1/data/{object}/{id}/page. Reference for RecordPageActivityRequest and 18 more: every property with its type and default." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -39,7 +39,10 @@ The three stream parts are each that read exactly as the table on `RecordPageActivityRequestSchema` spells it: the route composes the record's scope and the order, and the request names only whether a stream is wanted and how many rows. Paging further, posting, reacting and uploading keep -their own routes. Object permissions, row-level security, sharing and +their own routes. [#28737] The `timeline` part is the size of the record's +动态 stream — the comments, the activity and the field changes it merges — +counted by those reads as the caller (`RecordPageTimelineRequestSchema`), so +a page draws the stream's count without holding every row of it. Object permissions, row-level security, sharing and field-level security therefore apply exactly as they apply to those reads. A part whose read refuses carries that refusal in place of an answer — a related list the caller may not read carries neither a count nor rows — and @@ -54,8 +57,8 @@ no part discloses a record the caller could not open. ## TypeScript Usage ```typescript -import { RecordPageActivityRequestSchema, RecordPageApprovalsSchema, RecordPageAttachmentsRequestSchema, RecordPageDiscussionSchema, RecordPageDiscussionReactionsSchema, RecordPageExplainAnswerSchema, RecordPageExplainOperationSchema, RecordPagePartErrorSchema, RecordPagePartRefusalSchema, RecordPageRelatedAnswerSchema, RecordPageRelatedRefusalSchema, RecordPageRelatedRequestSchema, RecordPageRelatedRowsSchema, RecordPageRequestSchema, RecordPageResponseSchema, RecordPageStreamRefusalSchema, RecordPageStreamRowsSchema } from '@objectstack/spec/api'; -import type { RecordPageActivityRequest, RecordPageApprovals, RecordPageAttachmentsRequest, RecordPageDiscussion, RecordPageDiscussionReactions, RecordPageExplainAnswer, RecordPageExplainOperation, RecordPagePartError, RecordPagePartRefusal, RecordPageRelatedAnswer, RecordPageRelatedRefusal, RecordPageRelatedRequest, RecordPageRelatedRows, RecordPageRequest, RecordPageResponse, RecordPageStreamRefusal, RecordPageStreamRows } from '@objectstack/spec/api'; +import { RecordPageActivityRequestSchema, RecordPageApprovalsSchema, RecordPageAttachmentsRequestSchema, RecordPageDiscussionSchema, RecordPageDiscussionReactionsSchema, RecordPageExplainAnswerSchema, RecordPageExplainOperationSchema, RecordPagePartErrorSchema, RecordPagePartRefusalSchema, RecordPageRelatedAnswerSchema, RecordPageRelatedRefusalSchema, RecordPageRelatedRequestSchema, RecordPageRelatedRowsSchema, RecordPageRequestSchema, RecordPageResponseSchema, RecordPageStreamRefusalSchema, RecordPageStreamRowsSchema, RecordPageTimelineCountSchema, RecordPageTimelineRequestSchema } from '@objectstack/spec/api'; +import type { RecordPageActivityRequest, RecordPageApprovals, RecordPageAttachmentsRequest, RecordPageDiscussion, RecordPageDiscussionReactions, RecordPageExplainAnswer, RecordPageExplainOperation, RecordPagePartError, RecordPagePartRefusal, RecordPageRelatedAnswer, RecordPageRelatedRefusal, RecordPageRelatedRequest, RecordPageRelatedRows, RecordPageRequest, RecordPageResponse, RecordPageStreamRefusal, RecordPageStreamRows, RecordPageTimelineCount, RecordPageTimelineRequest } from '@objectstack/spec/api'; // Validate data const result = RecordPageActivityRequestSchema.parse(data); @@ -397,6 +400,7 @@ This schema accepts one of the following structures: | **discussion** | `boolean` | optional | Return the record's discussion: its comment thread and those comments' reactions (`RecordPageDiscussionSchema`). | | **activity** | `{ top: integer }` | optional | Return the record's newest activity rows and their count (`RecordPageStreamRowsSchema`). | | **attachments** | `{ top: integer }` | optional | Return the record's newest attachments and their count (`RecordPageStreamRowsSchema`). | +| **timeline** | `{ discussion: boolean; activity: boolean; fieldChanges: boolean }` | optional | Return the size of the record's timeline stream over the sources named (`RecordPageTimelineCountSchema`). | ### Nested Shape: `RecordPageRequest.related[number]` @@ -421,6 +425,14 @@ This schema accepts one of the following structures: | :--- | :--- | :--- | :--- | | **top** | `integer` | ✅ | How many of the record's newest attachments to return with their count; 0 returns the count alone. | +### Nested Shape: `RecordPageRequest.timeline` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **discussion** | `boolean` | ✅ | Count the record's comment thread: every comment and reply. | +| **activity** | `boolean` | ✅ | Count the record's activity rows the stream draws an entry for (not `RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES`). | +| **fieldChanges** | `boolean` | ✅ | Count the record's field changes (its `sys_audit_log` writes); with any, its `updated` activity rows step aside. | + --- @@ -440,6 +452,7 @@ This schema accepts one of the following structures: | **discussion** | `{ comments: Record[]; reactions: object \| object } \| { error: object }` | optional | The discussion part, when the request asked for it: the thread and its reactions, or the refusal of the comment read. | | **activity** | `{ total: integer; records: Record[] } \| { error: object }` | optional | The activity part, when the request asked for it: the newest rows and their count, or the refusal of their read. | | **attachments** | `{ total: integer; records: Record[] } \| { error: object }` | optional | The attachments part, when the request asked for it: the newest rows and their count, or the refusal of their read. | +| **timeline** | `{ total: integer } \| { error: object }` | optional | The timeline part, when the request asked for it: the stream's size, or the refusal of a comment or activity read. | ### Nested Shape: `RecordPageResponse.related[number][option 1]` @@ -526,6 +539,18 @@ This schema accepts one of the following structures: | :--- | :--- | :--- | :--- | | **error** | `{ code: string; status: integer; message: string }` | ✅ | The refusal the replaced read answers, in place of this part's answer. | +### Nested Shape: `RecordPageResponse.timeline[option 1]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **total** | `integer` | ✅ | How many entries the record's timeline stream holds for this caller, over the sources the request named. | + +### Nested Shape: `RecordPageResponse.timeline[option 2]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **error** | `{ code: string; status: integer; message: string }` | ✅ | The refusal the replaced read answers, in place of this part's answer. | + --- @@ -560,3 +585,27 @@ This schema accepts one of the following structures: --- +## RecordPageTimelineCount + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **total** | `integer` | ✅ | How many entries the record's timeline stream holds for this caller, over the sources the request named. | + + +--- + +## RecordPageTimelineRequest + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **discussion** | `boolean` | ✅ | Count the record's comment thread: every comment and reply. | +| **activity** | `boolean` | ✅ | Count the record's activity rows the stream draws an entry for (not `RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES`). | +| **fieldChanges** | `boolean` | ✅ | Count the record's field changes (its `sys_audit_log` writes); with any, its `updated` activity rows step aside. | + + +--- + diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 74dc8adcecf..194eeb5d8af 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1594 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1596 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 34 | 469 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 34 | 471 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 15 | 78 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 31 | 174 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 175 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **205** | **1594** | 14 protocol modules | +| **Total** | **205** | **1596** | 14 protocol modules | --- @@ -63,7 +63,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **34 pages, 469 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **34 pages, 471 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -96,7 +96,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`query-adapter.zod.ts`](/docs/references/api/query-adapter) | `ODataQueryAdapter`, `OperatorMapping`, `QueryAdapterConfig`, `QueryAdapterTarget`, `RestQueryAdapter` | | [`realtime.zod.ts`](/docs/references/api/realtime) | `RealtimeConfig`, `RealtimeEvent`, `RealtimeEventType`, `RealtimePresence`, `Subscription`, `SubscriptionEvent`, `TransportProtocol` | | [`realtime-shared.zod.ts`](/docs/references/api/realtime-shared) | `BasePresence`, `PresenceStatus`, `RealtimeRecordAction` | -| [`record-page.zod.ts`](/docs/references/api/record-page) | `RecordPageActivityRequest`, `RecordPageApprovals`, `RecordPageAttachmentsRequest`, `RecordPageDiscussion`, `RecordPageDiscussionReactions`, `RecordPageExplainAnswer`, `RecordPageExplainOperation`, `RecordPagePartError`, `RecordPagePartRefusal`, `RecordPageRelatedAnswer`, `RecordPageRelatedRefusal`, `RecordPageRelatedRequest`, `RecordPageRelatedRows`, `RecordPageRequest`, `RecordPageResponse`, `RecordPageStreamRefusal`, `RecordPageStreamRows` | +| [`record-page.zod.ts`](/docs/references/api/record-page) | `RecordPageActivityRequest`, `RecordPageApprovals`, `RecordPageAttachmentsRequest`, `RecordPageDiscussion`, `RecordPageDiscussionReactions`, `RecordPageExplainAnswer`, `RecordPageExplainOperation`, `RecordPagePartError`, `RecordPagePartRefusal`, `RecordPageRelatedAnswer`, `RecordPageRelatedRefusal`, `RecordPageRelatedRequest`, `RecordPageRelatedRows`, `RecordPageRequest`, `RecordPageResponse`, `RecordPageStreamRefusal`, `RecordPageStreamRows`, `RecordPageTimelineCount`, `RecordPageTimelineRequest` | | [`rest-server.zod.ts`](/docs/references/api/rest-server) | `BatchEndpointsConfig`, `CrudEndpointsConfig`, `CrudOperation`, `EndpointRegistry`, `GeneratedEndpoint`, `MetadataEndpointsConfig`, `RestApiConfig`, `RestServerConfig`, `RouteGenerationConfig` | | [`router.zod.ts`](/docs/references/api/router) | `ConflictResolutionStrategy`, `HttpMethod`, `RouteCategory`, `RouteDefinition`, `RouterConfig` | | [`sortability.zod.ts`](/docs/references/api/sortability) | `FieldSortability`, `ObjectSortability` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md index 2c0c689bc41..757d7a92bc5 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `api/` | 484 | +| `api/` | 486 | diff --git a/packages/qa/dogfood/test/record-page-timeline.dogfood.test.ts b/packages/qa/dogfood/test/record-page-timeline.dogfood.test.ts new file mode 100644 index 00000000000..e626b174366 --- /dev/null +++ b/packages/qa/dogfood/test/record-page-timeline.dogfood.test.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#28737] The record page payload's `timeline` part — the size of the +// record's 动态 stream — through the real HTTP + security stack, on an +// org-bound boot with plugin-audit's real writers: the comments, the activity +// mirror and the ledger rows are the ones the member's own writes produce. +// +// The count is of rows the caller could read, and of no other: it equals the +// sum of the separate list reads it stands for, asked as the same member (the +// spec's timeline table), and the activity's `updated` rows step aside once a +// field change is counted. A refused ledger read counting no field change is +// pinned at the route (`record-page-routes.test.ts`): on this boot the +// platform's member floor reads the ledger rows of a record the member reads. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { AuditPlugin } from '@objectstack/plugin-audit'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; +import { RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES, RecordPageResponseSchema } from '@objectstack/spec/api'; +import { commentsFixtureStack, cmtFixtureBaselineSet } from './fixtures/comments-fixture.js'; +import { armedWhen, assertArmed } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; + +const SYS = { isSystem: true } as const; +const AUTHOR_EMAIL = 'timeline-author@verify.test'; + +/** Object-level reads of the activity stream and the ledger. */ +const streamReaderSet: PermissionSet = PermissionSetSchema.parse({ + name: 'tl_stream_reader', + label: 'Timeline fixture — object-level sys_activity and sys_audit_log read', + objects: { sys_activity: { allowRead: true }, sys_audit_log: { allowRead: true } }, +}); + +const ALL_SOURCES = { discussion: true, activity: true, fieldChanges: true } as const; + +describe('[#28737] the timeline part counts the 动态 stream the caller could read', () => { + let stack: VerifyStack; + let ql: any; + let authorTok: string; + let recordId: string; + + const grant = async (email: string, set: PermissionSet) => { + const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; + const served = await catalogPermissionSet(ql, set.name); + expect(served, `fixture permission set ${set.name} served by the security catalog`).toBeTruthy(); + await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: served.name }, { context: { ...SYS } }); + }; + + beforeAll(async () => { + stack = await bootStack(commentsFixtureStack as never, { + security: new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, cmtFixtureBaselineSet, streamReaderSet], + fallbackPermissionSet: cmtFixtureBaselineSet.name, + }), + extraPlugins: [new AuditPlugin()], + orgContext: true, + }); + await stack.signIn(); + authorTok = await stack.signUp(AUTHOR_EMAIL); + ql = await stack.kernel.getServiceAsync('objectql'); + await grant(AUTHOR_EMAIL, streamReaderSet); + + // The record's stream, from its real producers: a create and two renames + // (ledger rows and their activity mirror), two comments and a reply. + const created = await stack.apiAs(authorTok, 'POST', '/data/cmt_open', { name: 'shared board' }); + expect(created.status, await created.clone().text()).toBeLessThan(300); + const body: any = await created.json(); + recordId = String(body.id ?? body.record?.id ?? body.data?.id); + for (const name of ['shared board, renamed', 'shared board, renamed again']) { + expect((await stack.apiAs(authorTok, 'PATCH', `/data/cmt_open/${recordId}`, { name })).status).toBeLessThan(300); + } + const thread = `cmt_open:${recordId}`; + const first = await stack.apiAs(authorTok, 'POST', '/data/sys_comment', { thread_id: thread, body: 'first' }); + expect(first.status, await first.clone().text()).toBe(201); + const firstId = String(((await first.json()) as any).id ?? ''); + expect((await stack.apiAs(authorTok, 'POST', '/data/sys_comment', { thread_id: thread, body: 'second' })).status).toBe(201); + expect((await stack.apiAs(authorTok, 'POST', '/data/sys_comment', { thread_id: thread, body: 'a reply', parent_id: firstId })).status).toBe(201); + + await assertArmed([ + armedWhen({ + control: 'the stores at rest hold the stream this file counts, from their real producers', + disarmedBy: 'a writer that stopped writing would let every count below agree on zero', + observe: async () => ({ + comments: (await ql.find('sys_comment', { where: { thread_id: thread }, context: SYS })).length, + ledger: (await ql.find('sys_audit_log', { where: { object_name: 'cmt_open', record_id: recordId }, context: SYS })).length, + updates: (await ql.find('sys_activity', { where: { object_name: 'cmt_open', record_id: recordId, type: 'updated' }, context: SYS })).length, + }), + armed: (o) => o.comments === 3 && o.ledger >= 3 && o.updates === 2, + }), + armedWhen({ + control: 'the member reads each source the count stands for', + disarmedBy: 'a refused source read would leave the sum below a statement about a stream nobody counted', + observe: async () => ({ + comments: (await stack.apiAs(authorTok, 'GET', '/data/sys_comment?$top=1')).status, + activity: (await stack.apiAs(authorTok, 'GET', '/data/sys_activity?$top=1')).status, + ledger: (await stack.apiAs(authorTok, 'GET', '/data/sys_audit_log?$top=1')).status, + }), + armed: (o) => o.comments === 200 && o.activity === 200 && o.ledger === 200, + }), + ]); + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + /** A list read's total as `token` — or `null` when the read is refused. */ + const totalAs = async (token: string, object: string, filter: Record): Promise => { + const res = await stack.apiAs(token, 'GET', `/data/${object}?$filter=${encodeURIComponent(JSON.stringify(filter))}&$top=1&$count=true`); + if (res.status >= 400) return null; + const json: any = await res.json(); + return Number(json?.data?.total ?? json?.total); + }; + /** The sum the spec's timeline table spells, from the separate list reads as `token`. */ + const expectedAs = async (token: string) => { + const ofRecord = { object_name: 'cmt_open', record_id: recordId }; + const comments = await totalAs(token, 'sys_comment', { thread_id: `cmt_open:${recordId}` }); + const changes = (await totalAs(token, 'sys_audit_log', { ...ofRecord, action: { $in: ['create', 'update', 'delete'] } })) ?? 0; + const drawn = await totalAs(token, 'sys_activity', { ...ofRecord, type: { $nin: [...RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES] } }); + const updates = await totalAs(token, 'sys_activity', { ...ofRecord, type: 'updated' }); + expect(comments, 'the comment read answers').not.toBeNull(); + expect(drawn, 'the activity read answers').not.toBeNull(); + return { comments: comments!, changes, drawn: drawn!, updates: updates!, total: comments! + changes + drawn! - (changes > 0 ? updates! : 0) }; + }; + const timelineAs = async (token: string) => { + const res = await stack.apiAs(token, 'POST', `/data/cmt_open/${recordId}/page`, { timeline: ALL_SOURCES }); + const json: any = await res.json(); + expect(res.status, JSON.stringify(json)).toBe(200); + return RecordPageResponseSchema.parse(json.data).timeline; + }; + + it('counts the stream as the separate reads answer it, and the updates step aside for the field changes', async () => { + const expected = await expectedAs(authorTok); + expect(expected.comments).toBe(3); + expect(expected.changes).toBeGreaterThanOrEqual(3); + expect(expected.updates).toBe(2); + expect(await timelineAs(authorTok)).toEqual({ total: expected.total }); + }, 60_000); + + it('without the field changes, the updates stay in the count', async () => { + const expected = await expectedAs(authorTok); + const res = await stack.apiAs(authorTok, 'POST', `/data/cmt_open/${recordId}/page`, { + timeline: { discussion: true, activity: true, fieldChanges: false }, + }); + expect(res.status).toBe(200); + expect(RecordPageResponseSchema.parse(((await res.json()) as any).data).timeline).toEqual({ total: expected.comments + expected.drawn }); + }, 60_000); + + it('counts only the sources named', async () => { + const res = await stack.apiAs(authorTok, 'POST', `/data/cmt_open/${recordId}/page`, { + timeline: { discussion: true, activity: false, fieldChanges: false }, + }); + expect(res.status).toBe(200); + expect(RecordPageResponseSchema.parse(((await res.json()) as any).data).timeline).toEqual({ total: 3 }); + }, 60_000); +}); diff --git a/packages/rest/src/record-page-routes.test.ts b/packages/rest/src/record-page-routes.test.ts index b1629d4d5ba..3438568c418 100644 --- a/packages/rest/src/record-page-routes.test.ts +++ b/packages/rest/src/record-page-routes.test.ts @@ -22,14 +22,17 @@ * - [#28470] a related list's `expand` is its read's own `$expand`, and each * stream part — the discussion (the thread, then its reactions), the * activity and the attachments — is the `findData` the console's own read of - * it makes, behind that object's exposure gate, refused on its own. + * it makes, behind that object's exposure gate, refused on its own; + * - [#28737] the timeline part is a count of each source the body names, by + * that source's own read as the caller, summed as the spec's timeline table + * spells it. * * The end-to-end half, under row-level and field-level security as a member who * is not an admin, is `record-page.dogfood.test.ts`. */ import { describe, it, expect, vi } from 'vitest'; -import { RecordPageResponseSchema } from '@objectstack/spec/api'; +import { RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES, RecordPageResponseSchema } from '@objectstack/spec/api'; import { RestServer } from './rest-server.js'; import { RECORD_PAGE_PARAMS } from './record-page-routes.js'; import { unknownQueryParamMessage } from './query-allowlist.js'; @@ -522,3 +525,141 @@ describe('[#28470] the record\'s streams and the related rows\' names ride in th expect(findData).not.toHaveBeenCalled(); }); }); + +describe('[#28737] the timeline part is the 动态 stream\'s size, counted by the caller\'s own reads', () => { + const items: Record = { ...ITEMS, sys_audit_log: systemObject('sys_audit_log') }; + // Each count read answers its own total, so a sum that took one source's + // count for another's would show. + const countOf = (req: any) => { + const where = req.query?.where ?? {}; + if (req.object === 'sys_comment') return { records: [], total: 3 }; + if (req.object === 'sys_audit_log') return { records: [], total: 40 }; + if (req.object === 'sys_activity') return { records: [], total: where.type === 'updated' ? 30 : 250 }; + return { records: [], total: 0 }; + }; + const ofRecord = { object_name: 'account', record_id: 'a1' }; + + it('counts each named source as the caller, and the updates step aside once a field change is counted', async () => { + const { call, findData } = boot({ items, findData: async (req) => countOf(req) }); + const answer = await call({ timeline: { discussion: true, activity: true, fieldChanges: true } }); + expect(answer.status).toBe(200); + const data = RecordPageResponseSchema.parse(answer.body.data); + // 3 comments + 40 field changes + 250 drawn activity rows − their 30 updates. + expect(data.timeline).toEqual({ total: 263 }); + const reads = findData.mock.calls.map(([req]) => req); + expect(reads).toEqual(expect.arrayContaining([ + { object: 'sys_comment', query: { object: 'sys_comment', where: { thread_id: 'account:a1' }, limit: 0, count: true }, context: CTX }, + { + object: 'sys_audit_log', + query: { object: 'sys_audit_log', where: { ...ofRecord, action: { $in: ['create', 'update', 'delete'] } }, limit: 0, count: true }, + context: CTX, + }, + { + object: 'sys_activity', + query: { + object: 'sys_activity', + where: { ...ofRecord, type: { $nin: [...RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES] } }, + limit: 0, + count: true, + }, + context: CTX, + }, + { + object: 'sys_activity', + query: { object: 'sys_activity', where: { ...ofRecord, type: 'updated' }, limit: 0, count: true }, + context: CTX, + }, + ])); + expect(reads).toHaveLength(4); + }); + + it('counts only the sources named, and no update steps aside without a field change', async () => { + const { call, findData } = boot({ items, findData: async (req) => countOf(req) }); + const activityOnly = RecordPageResponseSchema.parse((await call({ timeline: { discussion: false, activity: true, fieldChanges: false } })).body.data); + expect(activityOnly.timeline).toEqual({ total: 250 }); + expect(findData.mock.calls.map(([req]) => req.object)).toEqual(['sys_activity']); + + const noChanges = boot({ + items, + findData: async (req) => (req.object === 'sys_audit_log' ? { records: [], total: 0 } : countOf(req)), + }); + const data = RecordPageResponseSchema.parse((await noChanges.call({ timeline: { discussion: true, activity: true, fieldChanges: true } })).body.data); + expect(data.timeline).toEqual({ total: 253 }); + }); + + it('a record with no entry counts 0', async () => { + const { call } = boot({ items, findData: async () => ({ records: [], total: 0 }) }); + const data = RecordPageResponseSchema.parse((await call({ timeline: { discussion: true, activity: true, fieldChanges: true } })).body.data); + expect(data.timeline).toEqual({ total: 0 }); + }); + + it('a refused comment or activity count refuses the part; the record and the other parts answer', async () => { + for (const refused of ['sys_comment', 'sys_activity']) { + const { call } = boot({ + items, + findData: async (req) => { + if (req.object === refused) throw permissionDenied(); + return countOf(req); + }, + }); + const answer = await call({ attachments: { top: 5 }, timeline: { discussion: true, activity: true, fieldChanges: true } }); + expect(answer.status, refused).toBe(200); + const data = RecordPageResponseSchema.parse(answer.body.data); + expect(data.timeline, refused).toEqual({ error: { code: 'PERMISSION_DENIED', status: 403, message: expect.any(String) } }); + expect(data.record).toEqual(RECORD); + expect(data.attachments).toEqual({ total: 0, records: [] }); + } + }); + + it('a refused ledger count is no field change, so the updates stay in the count', async () => { + const { call } = boot({ + items, + findData: async (req) => { + if (req.object === 'sys_audit_log') throw permissionDenied(); + return countOf(req); + }, + }); + const data = RecordPageResponseSchema.parse((await call({ timeline: { discussion: true, activity: true, fieldChanges: true } })).body.data); + expect(data.timeline).toEqual({ total: 253 }); + }); + + it('a source this deployment does not serve holds no entry', async () => { + const withoutLedger = { ...items }; + delete (withoutLedger as Record).sys_audit_log; + delete (withoutLedger as Record).sys_comment; + const { call } = boot({ + items: withoutLedger, + findData: async (req) => { + if (req.object === 'sys_audit_log' || req.object === 'sys_comment') throw objectNotFound(req.object); + return countOf(req); + }, + }); + const data = RecordPageResponseSchema.parse((await call({ timeline: { discussion: true, activity: true, fieldChanges: true } })).body.data); + expect(data.timeline).toEqual({ total: 250 }); + }); + + it('a source on an object the API does not expose is refused as its list read is, and never read', async () => { + const hidden = { ...items, sys_activity: { ...items.sys_activity, enable: { apiEnabled: false } } }; + const { call, findData } = boot({ items: hidden, findData: async (req) => countOf(req) }); + const data = RecordPageResponseSchema.parse((await call({ timeline: { discussion: false, activity: true, fieldChanges: false } })).body.data); + expect(data.timeline).toEqual({ error: expect.objectContaining({ code: 'OBJECT_API_DISABLED', status: 404 }) }); + expect(findData).not.toHaveBeenCalled(); + }); + + it('the timeline body is strict: a missing or non-boolean source, or an undeclared key, is a 400 and nothing is read', async () => { + const { call, getData, findData } = boot({ items }); + for (const body of [ + { timeline: true }, + { timeline: {} }, + { timeline: { discussion: true, activity: true } }, + { timeline: { discussion: 'yes', activity: true, fieldChanges: true } }, + { timeline: { discussion: true, activity: true, fieldChanges: true, top: 5 } }, + ]) { + const answer = await call(body); + expect(answer.status, JSON.stringify(body)).toBe(400); + expect(answer.body.error.code).toBe('VALIDATION_FAILED'); + } + expect(getData).not.toHaveBeenCalled(); + expect(findData).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/rest/src/record-page-routes.ts b/packages/rest/src/record-page-routes.ts index ab438860680..19b6ad4840e 100644 --- a/packages/rest/src/record-page-routes.ts +++ b/packages/rest/src/record-page-routes.ts @@ -18,7 +18,11 @@ * streams — `discussion`, `activity`, `attachments` — are `findData` over * `sys_comment` (then `sys_comment_reaction`), `sys_activity` and * `sys_attachment`, each after that object's own exposure gate, scoped to this - * record and ordered as the spec module's stream table spells it. Object + * record and ordered as the spec module's stream table spells it. [#28737] + * The `timeline` part is the size of the record's 动态 stream: a `findData` + * count (`limit: 0`, `count: true`) of each source the request names, over + * the same objects behind the same gates, as the spec's timeline table spells + * it — so the count is of rows the caller could read, and of no other. Object * permissions, row-level security, sharing and field-level security are * therefore whatever those calls apply, by construction. * @@ -30,6 +34,8 @@ */ import { + RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES, + RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE, RecordPageRequestSchema, zodIssuesToFields, type GetDataRequest, @@ -56,6 +62,10 @@ const COMMENT_OBJECT = 'sys_comment'; const COMMENT_REACTION_OBJECT = 'sys_comment_reaction'; const ACTIVITY_OBJECT = 'sys_activity'; const ATTACHMENT_OBJECT = 'sys_attachment'; +/** [#28737] The record's field changes the timeline counts: the ledger's own object. */ +const AUDIT_LOG_OBJECT = 'sys_audit_log'; +/** [#28737] The ledger's write actions — the entries the 动态 stream draws as field changes. */ +const FIELD_CHANGE_ACTIONS = ['create', 'update', 'delete']; /** * A `$expand` list spelled as the relation map the QueryAST declares, as the @@ -163,11 +173,15 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath: const related = body.related ?? []; // [#28470] The stream objects' items too, for their exposure // gates — read in the same metadata wave as the children's. - const streamObjects = [ + // [#28737] And the timeline's sources, each object read once. + const streamObjects = [...new Set([ ...(body.discussion ? [COMMENT_OBJECT, COMMENT_REACTION_OBJECT] : []), ...(body.activity ? [ACTIVITY_OBJECT] : []), ...(body.attachments ? [ATTACHMENT_OBJECT] : []), - ]; + ...(body.timeline?.discussion ? [COMMENT_OBJECT] : []), + ...(body.timeline?.activity ? [ACTIVITY_OBJECT] : []), + ...(body.timeline?.fieldChanges ? [AUDIT_LOG_OBJECT] : []), + ])]; const [childItems, streamItemList] = await measureServerTiming('meta', () => Promise.all([ Promise.all(related.map((entry) => this.loadObjectItem(p, environmentId, entry.object))), Promise.all(streamObjects.map((name) => this.loadObjectItem(p, environmentId, name))), @@ -322,6 +336,40 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath: return 'error' in answer ? answer : { total: answer.total, records: answer.records }; }; + // [#28737] The 动态 stream's size: each named source counted by + // its read as the caller (`readStream`, so behind that object's + // gate), as the spec's timeline table spells it. A source this + // deployment does not serve holds no entry, as the page reads + // such a stream as an empty one; a refused comment or activity + // count refuses the part, and a refused ledger count is no + // field change, as the stream then merges none. + const answerTimeline = async ( + sources: NonNullable, + ): Promise<{ total: number } | { error: PartRefusal }> => { + const count = async (name: string, where: Record): Promise => { + const answer = await readStream(name, { where, limit: 0, count: true }); + if (!('error' in answer)) return answer.total; + return answer.error.code === 'OBJECT_NOT_FOUND' ? 0 : answer; + }; + const ofRecord = { object_name: object, record_id: id }; + const [comments, fieldChanges, activity, updates] = await Promise.all([ + sources.discussion ? count(COMMENT_OBJECT, { thread_id: `${object}:${id}` }) : 0, + sources.fieldChanges ? count(AUDIT_LOG_OBJECT, { ...ofRecord, action: { $in: FIELD_CHANGE_ACTIONS } }) : 0, + sources.activity + ? count(ACTIVITY_OBJECT, { ...ofRecord, type: { $nin: [...RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES] } }) + : 0, + // The updates that step aside once a field change is counted. + sources.activity && sources.fieldChanges + ? count(ACTIVITY_OBJECT, { ...ofRecord, type: RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE }) + : 0, + ]); + if (typeof comments !== 'number') return comments; + if (typeof activity !== 'number') return activity; + if (typeof updates !== 'number') return updates; + const changes = typeof fieldChanges === 'number' ? fieldChanges : 0; + return { total: comments + changes + activity - (changes > 0 ? updates : 0) }; + }; + const answerFieldAccess = async () => { try { const security = await this.resolveSecurityService(environmentId, req); @@ -386,7 +434,7 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath: } }; - const [relatedAnswers, fieldAccess, explain, approvals, discussion, activity, attachments] = await Promise.all([ + const [relatedAnswers, fieldAccess, explain, approvals, discussion, activity, attachments, timeline] = await Promise.all([ Promise.all(related.map(answerRelated)), body.fieldAccess ? answerFieldAccess() : Promise.resolve(undefined), body.explain && body.explain.length > 0 ? answerExplain() : Promise.resolve(undefined), @@ -394,6 +442,7 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath: body.discussion ? answerDiscussion() : Promise.resolve(undefined), body.activity ? answerActivity(body.activity.top) : Promise.resolve(undefined), body.attachments ? answerAttachments(body.attachments.top) : Promise.resolve(undefined), + body.timeline ? answerTimeline(body.timeline) : Promise.resolve(undefined), ]); sendOk(res, { @@ -407,6 +456,7 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath: ...(discussion !== undefined ? { discussion } : {}), ...(activity !== undefined ? { activity } : {}), ...(attachments !== undefined ? { attachments } : {}), + ...(timeline !== undefined ? { timeline } : {}), }); } catch (error: any) { const mapped = mapDataError(error, object); @@ -415,7 +465,7 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath: } }, metadata: { - summary: 'Record page payload: the record, its related lists\' counts and first rows, its field projection, access verdicts, approval state, discussion, activity and attachments in one request', + summary: 'Record page payload: the record, its related lists\' counts and first rows, its field projection, access verdicts, approval state, discussion, activity, attachments and timeline count in one request', tags: ['data'], }, }); diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 45fb85e1e06..49e0ba93f57 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -849,6 +849,8 @@ "RECORD_PAGE_MAX_RELATED (const)", "RECORD_PAGE_MAX_RELATED_ROWS (const)", "RECORD_PAGE_MAX_RELATED_SORT (const)", + "RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES (const)", + "RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE (const)", "REGISTERED_ERROR_CODES (const)", "RawUploadResponse (type)", "RawUploadResponseParsed (type)", @@ -937,6 +939,12 @@ "RecordPageStreamRows (type)", "RecordPageStreamRowsParsed (type)", "RecordPageStreamRowsSchema (const)", + "RecordPageTimelineCount (type)", + "RecordPageTimelineCountParsed (type)", + "RecordPageTimelineCountSchema (const)", + "RecordPageTimelineRequest (type)", + "RecordPageTimelineRequestParsed (type)", + "RecordPageTimelineRequestSchema (const)", "RefreshTokenRequest (type)", "RefreshTokenRequestSchema (const)", "RegisterDeviceRequest (type)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 5281170fcd3..9471c74aa49 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1501,6 +1501,7 @@ "api/RecordPageRequest:explain", "api/RecordPageRequest:fieldAccess", "api/RecordPageRequest:related", + "api/RecordPageRequest:timeline", "api/RecordPageResponse:activity", "api/RecordPageResponse:approvals", "api/RecordPageResponse:attachments", @@ -1511,9 +1512,14 @@ "api/RecordPageResponse:object", "api/RecordPageResponse:record", "api/RecordPageResponse:related", + "api/RecordPageResponse:timeline", "api/RecordPageStreamRefusal:error", "api/RecordPageStreamRows:records", "api/RecordPageStreamRows:total", + "api/RecordPageTimelineCount:total", + "api/RecordPageTimelineRequest:activity", + "api/RecordPageTimelineRequest:discussion", + "api/RecordPageTimelineRequest:fieldChanges", "api/RefreshTokenRequest:refreshToken", "api/RegisterDeviceRequest:deviceId", "api/RegisterDeviceRequest:name", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index 7dc7834fd01..8fe294f4f8d 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -683,6 +683,10 @@ "RecordPageStreamRefusalSchema": "api/RecordPageStreamRefusal", "RecordPageStreamRows": "api/RecordPageStreamRows", "RecordPageStreamRowsSchema": "api/RecordPageStreamRows", + "RecordPageTimelineCount": "api/RecordPageTimelineCount", + "RecordPageTimelineCountSchema": "api/RecordPageTimelineCount", + "RecordPageTimelineRequest": "api/RecordPageTimelineRequest", + "RecordPageTimelineRequestSchema": "api/RecordPageTimelineRequest", "RefreshTokenRequest": "api/RefreshTokenRequest", "RefreshTokenRequestSchema": "api/RefreshTokenRequest", "RegisterDeviceRequest": "api/RegisterDeviceRequest", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 1644454b500..d034a0295fe 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -820,6 +820,8 @@ "RECORD_PAGE_MAX_RELATED": "src/api/record-page.zod.ts#RECORD_PAGE_MAX_RELATED (const)", "RECORD_PAGE_MAX_RELATED_ROWS": "src/api/record-page.zod.ts#RECORD_PAGE_MAX_RELATED_ROWS (const)", "RECORD_PAGE_MAX_RELATED_SORT": "src/api/record-page.zod.ts#RECORD_PAGE_MAX_RELATED_SORT (const)", + "RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES": "src/api/record-page.zod.ts#RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES (const)", + "RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE": "src/api/record-page.zod.ts#RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE (const)", "REGISTERED_ERROR_CODES": "src/api/error-code-ledger.zod.ts#REGISTERED_ERROR_CODES (const)", "RawUploadResponse": "src/api/storage.zod.ts#RawUploadResponse (type)", "RawUploadResponseParsed": "src/api/storage.zod.ts#RawUploadResponseParsed (type)", @@ -906,6 +908,12 @@ "RecordPageStreamRows": "src/api/record-page.zod.ts#RecordPageStreamRows (type)", "RecordPageStreamRowsParsed": "src/api/record-page.zod.ts#RecordPageStreamRowsParsed (type)", "RecordPageStreamRowsSchema": "src/api/record-page.zod.ts#RecordPageStreamRowsSchema (const)", + "RecordPageTimelineCount": "src/api/record-page.zod.ts#RecordPageTimelineCount (type)", + "RecordPageTimelineCountParsed": "src/api/record-page.zod.ts#RecordPageTimelineCountParsed (type)", + "RecordPageTimelineCountSchema": "src/api/record-page.zod.ts#RecordPageTimelineCountSchema (const)", + "RecordPageTimelineRequest": "src/api/record-page.zod.ts#RecordPageTimelineRequest (type)", + "RecordPageTimelineRequestParsed": "src/api/record-page.zod.ts#RecordPageTimelineRequestParsed (type)", + "RecordPageTimelineRequestSchema": "src/api/record-page.zod.ts#RecordPageTimelineRequestSchema (const)", "RefreshTokenRequest": "src/api/auth.zod.ts#RefreshTokenRequest (type)", "RefreshTokenRequestSchema": "src/api/auth.zod.ts#RefreshTokenRequestSchema (const)", "RegisterDeviceRequest": "src/api/protocol.zod.ts#RegisterDeviceRequest (type)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index 8eb2355c6f9..f39f4ac6218 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -370,6 +370,8 @@ "api/RecordPageResponse", "api/RecordPageStreamRefusal", "api/RecordPageStreamRows", + "api/RecordPageTimelineCount", + "api/RecordPageTimelineRequest", "api/RefreshTokenRequest", "api/RegisterDeviceRequest", "api/RegisterDeviceResponse", diff --git a/packages/spec/src/api/record-page.test.ts b/packages/spec/src/api/record-page.test.ts index 66c5488760c..94d3af71eca 100644 --- a/packages/spec/src/api/record-page.test.ts +++ b/packages/spec/src/api/record-page.test.ts @@ -1,8 +1,11 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, expect, it } from 'vitest'; +import { SYS_ACTIVITY_BUILTIN_TYPES } from '../data/feed.zod'; import { RECORD_PAGE_MAX_ACTIVITY_ROWS, + RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES, + RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE, RECORD_PAGE_MAX_ATTACHMENT_ROWS, RecordPageDiscussionSchema, RecordPageExplainOperationSchema, @@ -112,3 +115,34 @@ describe('[#28470] a stream part answers its rows or its refusal, never both and } }); }); + +describe('[#28737] the timeline part names its sources strictly and answers the stream\'s size or a refusal', () => { + const base = { object: 'account', id: 'a1', record: { id: 'a1' }, related: [] }; + const all = { discussion: true, activity: true, fieldChanges: true }; + + it('takes the three sources, each a boolean, and nothing else', () => { + expect(RecordPageRequestSchema.safeParse({ timeline: all }).success).toBe(true); + expect(RecordPageRequestSchema.safeParse({ timeline: { ...all, fieldChanges: false } }).success).toBe(true); + for (const timeline of [true, {}, { discussion: true, activity: true }, { ...all, discussion: 'yes' }, { ...all, top: 5 }]) { + expect(RecordPageRequestSchema.safeParse({ timeline }).success, JSON.stringify(timeline)).toBe(false); + } + }); + + it('answers a non-negative whole count, or a refusal alone', () => { + const parses = (timeline: unknown) => RecordPageResponseSchema.safeParse({ ...base, timeline }).success; + expect(parses({ total: 0 })).toBe(true); + expect(parses({ total: 263 })).toBe(true); + expect(parses({ error: refusal })).toBe(true); + for (const timeline of [{ total: -1 }, { total: 1.5 }, {}, { total: 1, error: refusal }, { total: 1, records: [] }]) { + expect(parses(timeline), JSON.stringify(timeline)).toBe(false); + } + }); + + it('leaves out only built-in activity types, and an update is not one of them', () => { + for (const type of RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES) { + expect(SYS_ACTIVITY_BUILTIN_TYPES as readonly string[], type).toContain(type); + } + expect(SYS_ACTIVITY_BUILTIN_TYPES as readonly string[]).toContain(RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE); + expect(RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES as readonly string[]).not.toContain(RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE); + }); +}); diff --git a/packages/spec/src/api/record-page.zod.ts b/packages/spec/src/api/record-page.zod.ts index 35f46b9f4d5..1eec560f183 100644 --- a/packages/spec/src/api/record-page.zod.ts +++ b/packages/spec/src/api/record-page.zod.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; import { ExplainDecisionSchema } from '../security/explain.zod'; import { GetRecordFieldAccessResponseSchema } from './protocol.zod'; +import type { SysActivityBuiltinType } from '../data/feed.zod'; /** * Record page payload — `POST /api/v1/data/{object}/{id}/page`. @@ -39,7 +40,10 @@ import { GetRecordFieldAccessResponseSchema } from './protocol.zod'; * `RecordPageActivityRequestSchema` spells it: the route composes the record's * scope and the order, and the request names only whether a stream is wanted * and how many rows. Paging further, posting, reacting and uploading keep - * their own routes. Object permissions, row-level security, sharing and + * their own routes. [#28737] The `timeline` part is the size of the record's + * 动态 stream — the comments, the activity and the field changes it merges — + * counted by those reads as the caller (`RecordPageTimelineRequestSchema`), so + * a page draws the stream's count without holding every row of it. Object permissions, row-level security, sharing and * field-level security therefore apply exactly as they apply to those reads. * A part whose read refuses carries that refusal in place of an answer — a * related list the caller may not read carries neither a count nor rows — and @@ -56,13 +60,37 @@ export const RECORD_PAGE_MAX_RELATED_ROWS = 50; export const RECORD_PAGE_MAX_RELATED_SORT = 8; /** * The most activity rows the `activity` part may ask for: the window the - * console's record feed reads (`$top: 200`), which its count and its 动态 tab - * are drawn from. + * console's record feed reads (`$top: 200`), which its 动态 tab draws in full. + * The stream's count is the `timeline` part's, never these rows' (#28737). */ export const RECORD_PAGE_MAX_ACTIVITY_ROWS = 200; /** The most attachment rows the `attachments` part may ask for: the Attachments panel's window. */ export const RECORD_PAGE_MAX_ATTACHMENT_ROWS = 100; +/** + * [#28737] The `sys_activity.type` values the record page's 动态 stream draws + * no entry for: a comment and a mention are the discussion's own rows + * (`sys_comment`), and a sign-in and a sign-out are account events, not the + * record's. The one definition: the `timeline` part's count leaves these out, + * and the console's activity reading (`ACTIVITY_TYPE_TO_FEED_TYPE`) maps + * exactly these to no entry. Every other value, an author-extended one + * included, is an entry. + */ +export const RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES = [ + 'commented', + 'mentioned', + 'login', + 'logout', +] as const satisfies readonly SysActivityBuiltinType[]; + +/** + * [#28737] The `sys_activity.type` of an update, which steps aside in the + * 动态 stream once the stream merges the record's field changes: the audit + * entry says the same update with its before and after values, so an update + * is one entry. + */ +export const RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE = 'updated' satisfies SysActivityBuiltinType; + /** The record-grained operations a record page's header asks about. */ export const RecordPageExplainOperationSchema = z.enum(['update', 'delete', 'transfer']); export type RecordPageExplainOperation = z.input; @@ -125,6 +153,39 @@ export const RecordPageActivityRequestSchema = lazySchema(() => z.object({ export type RecordPageActivityRequest = z.input; export type RecordPageActivityRequestParsed = z.infer; +/** + * [#28737] The `timeline` part of a request: which of the record's sources its + * 动态 stream merges, each counted by its read as the caller, behind that + * object's own API exposure gate (the record is `{object}` / `{id}`): + * + * | source | the count | + * | -------------- | ------------------------------------------------------------------------------------------- | + * | `discussion` | `sys_comment` where `thread_id` = `{object}:{id}`: every comment and reply of the thread | + * | `activity` | `sys_activity` where `object_name` = `{object}`, `record_id` = `{id}` and `type` is not one | + * | | of `RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES`; with any field change counted, not | + * | | `RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE` either | + * | `fieldChanges` | `sys_audit_log` where `object_name` = `{object}`, `record_id` = `{id}` and `action` is a | + * | | write (`create`, `update`, `delete`) | + * + * The total is their sum. A source this deployment does not serve (its read + * answers `OBJECT_NOT_FOUND`) holds no entry, as the page reads such a stream + * as an empty one. The field changes are the history the stream merges only + * where the caller may read it: a refused `sys_audit_log` read counts none, as + * the stream then draws none. A refused comment or activity read refuses the + * part, so a refused stream is never counted as an empty one. + */ +export const RecordPageTimelineRequestSchema = lazySchema(() => z.object({ + discussion: z.boolean().describe('Count the record\'s comment thread: every comment and reply.'), + activity: z.boolean().describe( + 'Count the record\'s activity rows the stream draws an entry for (not `RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES`).', + ), + fieldChanges: z.boolean().describe( + 'Count the record\'s field changes (its `sys_audit_log` writes); with any, its `updated` activity rows step aside.', + ), +}).strict()); +export type RecordPageTimelineRequest = z.input; +export type RecordPageTimelineRequestParsed = z.infer; + /** The `attachments` part of a request: how many of the newest attachments. */ export const RecordPageAttachmentsRequestSchema = lazySchema(() => z.object({ top: z.number().int().min(0).max(RECORD_PAGE_MAX_ATTACHMENT_ROWS).describe( @@ -161,6 +222,9 @@ export const RecordPageRequestSchema = lazySchema(() => z.object({ attachments: RecordPageAttachmentsRequestSchema.optional().describe( 'Return the record\'s newest attachments and their count (`RecordPageStreamRowsSchema`).', ), + timeline: RecordPageTimelineRequestSchema.optional().describe( + 'Return the size of the record\'s timeline stream over the sources named (`RecordPageTimelineCountSchema`).', + ), }).strict()); export type RecordPageRequest = z.input; export type RecordPageRequestParsed = z.infer; @@ -240,6 +304,15 @@ export const RecordPageStreamRowsSchema = lazySchema(() => z.object({ export type RecordPageStreamRows = z.input; export type RecordPageStreamRowsParsed = z.infer; +/** [#28737] The `timeline` part: how many entries the record's 动态 stream holds. */ +export const RecordPageTimelineCountSchema = lazySchema(() => z.object({ + total: z.number().int().min(0).describe( + 'How many entries the record\'s timeline stream holds for this caller, over the sources the request named.', + ), +}).strict()); +export type RecordPageTimelineCount = z.input; +export type RecordPageTimelineCountParsed = z.infer; + /** The reactions of the discussion's comments: their rows, or the refusal of their read. */ export const RecordPageDiscussionReactionsSchema = lazySchema(() => z.union([ z.object({ @@ -299,6 +372,9 @@ export const RecordPageResponseSchema = lazySchema(() => z.object({ attachments: z.union([RecordPageStreamRowsSchema, RecordPageStreamRefusalSchema]).optional().describe( 'The attachments part, when the request asked for it: the newest rows and their count, or the refusal of their read.', ), + timeline: z.union([RecordPageTimelineCountSchema, RecordPageStreamRefusalSchema]).optional().describe( + 'The timeline part, when the request asked for it: the stream\'s size, or the refusal of a comment or activity read.', + ), })); export type RecordPageResponse = z.input; export type RecordPageResponseParsed = z.infer; diff --git a/ui/packages/app-shell/src/views/RecordDetailView.recordOpenOneRequest-28470.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.recordOpenOneRequest-28470.test.tsx index 0d6754a9dc8..5b3755e7f26 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.recordOpenOneRequest-28470.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.recordOpenOneRequest-28470.test.tsx @@ -226,7 +226,13 @@ describe('objectstack#28470 — one record open is one request', () => { // The request names what the page draws: the streams, and each related // list's lookups but the parent's. const [page] = held.filter(isPage); - expect(page.body).toMatchObject({ discussion: true, activity: { top: 200 }, attachments: { top: 100 } }); + // objectstack#28737 — the 动态 panel's rows, and the stream's count. + expect(page.body).toMatchObject({ + discussion: true, + activity: { top: 5 }, + attachments: { top: 100 }, + timeline: { discussion: true, activity: true, fieldChanges: false }, + }); expect(page.body.related).toEqual([expect.objectContaining({ object: CHILD, field: 'contact', expand: ['owner'] })]); // Every part is drawn from the payload: the comment, the attachment row diff --git a/ui/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx index dbebb02cc11..915860a04a4 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx @@ -702,7 +702,10 @@ describe('the timeline, comments, attachments and user list wait for their panel const table = waveTable(held); expect(screen.queryByRole('tab', { name: /^Timeline/ }), table).toBeNull(); expect(document.querySelector('[data-record-timeline-panel]'), table).not.toBeNull(); - expect(pageStreams(held), table).toMatchObject({ discussion: true, activity: 200 }); + // objectstack#28737 — the panel draws its newest rows, and the page + // payload serves the stream's count, so the activity part asks the + // panel's rows only. + expect(pageStreams(held), table).toMatchObject({ discussion: true, activity: 5 }); for (const isRead of [isActivityRead, isCommentRead, isUserListRead]) { expect(held.filter(isRead), table).toEqual([]); } diff --git a/ui/packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx index 51c8da8cbbe..d5177b963ab 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx @@ -257,8 +257,10 @@ describe('objectstack#27627 — one request opens a record page', () => { for (const c of CHILDREN) expect(RelatedCountStore.get(c, 'contact', 'c1')).toBe(TOTAL[c]); // The 动态 panel's sources are the payload's parts (objectstack#28470): - // the open reads none of them on its own, nor the mention list. - expect(page.body).toMatchObject({ discussion: true, activity: { top: 200 } }); + // the open reads none of them on its own, nor the mention list. The + // activity part asks the panel's rows; the count is the payload's + // `timeline` part (objectstack#28737). + expect(page.body).toMatchObject({ discussion: true, activity: { top: 5 }, timeline: { discussion: true, activity: true } }); expect(streamReads(held)).toEqual([]); }); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.timelineCount-28737.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.timelineCount-28737.test.tsx new file mode 100644 index 00000000000..e482dfe342a --- /dev/null +++ b/ui/packages/app-shell/src/views/RecordDetailView.timelineCount-28737.test.tsx @@ -0,0 +1,274 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#28737 — the record page draws the 动态 stream's count the server + * counted, and asks only the activity rows the 动态 panel draws. + * + * Before: the page asked the activity window (`activity: { top: 200 }`) on + * every open, because the panel's count chip and the 动态 tab's count were + * the size of the merged rows it held — so a busy record carried 200 rows for + * a panel that draws five, and the count stopped at the window. + * + * Pinned here, through the real adapter under StrictMode: the page asks the + * panel's rows and the `timeline` count; a record with more than 200 merged + * entries names the served total on the chip and reads the window only when + * the 动态 tab opens; a record with none names 0 and reads nothing more; a + * page opening on the 动态 tab asks the window itself and reads nothing more. + */ +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, act, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }), +})); +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); + +import { MetadataCtx } from '@object-ui/react'; +import { RelatedCountStore } from '@object-ui/components'; +import { ObjectStackAdapter } from '@object-ui/data-objectstack'; +import { __clearRecordEditableCache } from '../../../plugin-detail/src/useRecordEditable'; +import { __resetRecordApprovalsStore } from '../hooks/useRecordApprovals'; +import { RecordDetailView } from './RecordDetailView'; +// The `record:attachments` block the synthesized page places for `enable.files` +// (registered by the package entry, which this file does not import). +import './record-attachments-renderer'; + +const BASE = 'http://test.local'; +const OBJECT = 'os_28737_contact'; +const CHILD = 'os_28737_task'; + +const contactDef = { + name: OBJECT, + label: 'Contact', + enable: { files: true }, + fields: { + id: { label: 'Id', type: 'text' }, + name: { label: 'Name', type: 'text' }, + account: { label: 'Account', type: 'lookup', reference: 'os_28737_account' }, + }, +}; +const accountDef = { name: 'os_28737_account', label: 'Account', fields: { id: { label: 'Id', type: 'text' }, name: { label: 'Name', type: 'text' } } }; +const childDef = { + name: CHILD, + label: 'Task', + fields: { + id: { label: 'Id', type: 'text' }, + name: { label: 'Name', type: 'text' }, + contact: { label: 'Contact', type: 'lookup', reference: OBJECT }, + owner: { label: 'Owner', type: 'lookup', reference: 'sys_user' }, + }, +}; +const ALL = [contactDef, accountDef, childDef]; + +const COMMENT = { id: 'cm1', thread_id: `${OBJECT}:c1`, author_id: 'u2', author_name: 'Grace', body: 'hello from the payload', created_at: '2026-10-01T10:00:00.000Z' }; +const ACTIVITY = { id: 'ac1', type: 'created', object_name: OBJECT, record_id: 'c1', actor_name: 'Grace', summary: 'created the contact', timestamp: '2026-09-30T10:00:00.000Z' }; +const ATTACHMENT = { id: 'f1', parent_object: OBJECT, parent_id: 'c1', file_id: 'file-1', file_name: 'contract.pdf', created_at: '2026-10-02T10:00:00.000Z' }; + +interface Answers { + /** Answer the related rows' owner as a bare id even when the request expands it. */ + ignoreExpand?: boolean; + activity?: unknown; + /** The discussion's comments (default: one). */ + comments?: unknown[]; + /** The timeline part's answer (default: none). */ + timeline?: unknown; +} + +interface Held { method: string; path: string; body: any; released: boolean; release: () => void } + +const json = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); + +function pageAnswer(id: string, body: any, answers: Answers) { + return { + success: true, + data: { + object: OBJECT, + id, + record: { id, name: `Contact ${id}`, account: 'a1' }, + related: (body.related ?? []).map((r: any) => ({ + object: r.object, + field: r.field, + total: 2, + records: [0, 1].slice(0, r.top).map((i) => ({ + id: `t${i}`, + name: `Task ${i}`, + contact: id, + owner: !answers.ignoreExpand && r.expand?.includes('owner') ? { id: 'u9', name: 'Una Owner' } : 'u9', + })), + })), + ...(body.fieldAccess ? { fieldAccess: { object: OBJECT, recordId: id, readable: ['name', 'account'], editable: ['name'], writable: true } } : {}), + ...(body.explain ? { explain: Object.fromEntries(body.explain.map((op: string) => [op, { allowed: true, record: { recordId: id, visible: true } }])) } : {}), + ...(body.approvals ? { approvals: { requests: [], lock: { locked: false } } } : {}), + ...(body.discussion ? { discussion: { comments: answers.comments ?? [COMMENT], reactions: { records: [] } } } : {}), + ...(body.activity ? { activity: answers.activity ?? { total: 1, records: [ACTIVITY] } } : {}), + ...(body.attachments ? { attachments: { total: 3, records: [ATTACHMENT] } } : {}), + ...(body.timeline && answers.timeline !== undefined ? { timeline: answers.timeline } : {}), + }, + }; +} + +function answerFor(h: Held, answers: Answers): unknown { + const meta = /\/api\/v1\/meta\/object\/([^/?]+)/.exec(h.path); + if (meta) return { success: true, data: ALL.find((o) => o.name === meta[1]) ?? null }; + const page = /\/api\/v1\/data\/[^/?]+\/([^/?]+)\/page$/.exec(h.path); + if (page) return pageAnswer(page[1], h.body, answers); + const one = new RegExp(`/api/v1/data/${OBJECT}\\?.*\\["id","=","([^"]+)"\\]`).exec(h.path); + if (one) return { success: true, data: { records: [{ id: one[1], name: `Contact ${one[1]}`, account: 'a1' }], total: 1 } }; + if (h.path.startsWith('/api/v1/data/sys_user?')) return { success: true, data: { records: [{ id: 'u9', name: 'Una Owner' }], total: 1 } }; + return { success: true, data: { records: [], total: 0 } }; +} + +function heldTransport(answers: Answers) { + const held: Held[] = []; + const fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const method = (init?.method ?? 'GET').toUpperCase(); + const path = decodeURIComponent(url.replace(BASE, '')); + if (path.endsWith('/api/v1/discovery')) return Promise.resolve(json({ success: true, data: { capabilities: {}, routes: {} } })); + return new Promise((resolve) => { + const h: Held = { + method, path, + body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined, + released: false, + release: () => { if (h.released) return; h.released = true; resolve(json(answerFor(h, answers))); }, + }; + held.push(h); + }); + }); + return { fetch, held }; +} + +async function idle() { + await act(async () => { for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 0)); }); +} +async function settle(held: Held[]) { + await idle(); + for (let round = 0; round < 12; round++) { + const pending = held.filter((h) => !h.released); + if (pending.length === 0) return; + await act(async () => { for (const h of pending) h.release(); await new Promise((r) => setTimeout(r, 0)); }); + await idle(); + } +} + +const isMeta = (h: Held) => h.path.startsWith('/api/v1/meta/'); +const isPage = (h: Held) => h.method === 'POST' && /\/api\/v1\/data\/[^/?]+\/[^/?]+\/page$/.test(h.path); +const readsOf = (held: Held[], object: string) => held.filter((h) => h.method === 'GET' && h.path.startsWith(`/api/v1/data/${object}?`)); + +beforeEach(() => { + cleanup(); + __clearRecordEditableCache(); + __resetRecordApprovalsStore(); + RelatedCountStore._reset(); + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 }); +}); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +async function boot(answers: Answers = {}, entry = `/apps/demo/${OBJECT}/record/c1`) { + const { fetch, held } = heldTransport(answers); + vi.stubGlobal('fetch', fetch); + const ds = new ObjectStackAdapter({ baseUrl: BASE, fetch, autoReconnect: false }); + await ds.connect(); + const metadata = { + objects: ALL, pages: [], loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, + ensureType: async () => [], getItem: async () => null, getItemsByType: () => [], + } as never; + const open = () => render( + + + + {}} objectNameOverride={OBJECT} recordIdOverride="c1" /> + + + , + ); + return { held, open, ds }; +} + + +const ROWS = (n: number) => Array.from({ length: n }, (_, i) => ({ + ...ACTIVITY, + id: `ac${i}`, + type: 'system', + summary: `step ${i}`, + timestamp: new Date(Date.UTC(2026, 8, 30, 10, 59 - i)).toISOString(), +})); +const activityReads = (held: Held[]) => readsOf(held, 'sys_activity'); +const chipOf = (container: HTMLElement) => + container.querySelector('[data-record-timeline-panel] [data-record-timeline-count]')?.textContent ?? null; + +describe('objectstack#28737 — the 动态 count is the server\'s, and the page asks only the panel\'s rows', () => { + it('a record with more than 200 merged entries shows the served total, and the request asks only the panel\'s rows', async () => { + const { held, open } = await boot({ activity: { total: 330, records: ROWS(5) }, timeline: { total: 341 } }); + const view = open(); + await settle(held); + + const [page] = held.filter(isPage); + expect(page.body.activity).toEqual({ top: 5 }); + expect(page.body.timeline).toEqual({ discussion: true, activity: true, fieldChanges: false }); + expect(chipOf(view.container)).toBe('341'); + expect(activityReads(held)).toEqual([]); + + // The 动态 tab draws the whole stream: it reads the window once, and the + // count stays the served one. + const openTab = view.container.querySelector('[data-record-timeline-panel-open]') as HTMLButtonElement; + await act(async () => { fireEvent.click(openTab); }); + await settle(held); + expect(activityReads(held)).toHaveLength(1); + expect(activityReads(held)[0].path).toMatch(/[?&]\$?top=200\b/); + expect(view.container.textContent).toContain('341'); + }); + + it('a record with none shows 0 and makes no fallback read, the 动态 tab opened included', async () => { + const { held, open } = await boot({ activity: { total: 0, records: [] }, comments: [], timeline: { total: 0 } }); + const view = open(); + await settle(held); + expect(chipOf(view.container)).toBe('0'); + const openTab = view.container.querySelector('[data-record-timeline-panel-open]') as HTMLButtonElement; + await act(async () => { fireEvent.click(openTab); }); + await settle(held); + expect(held.filter((h) => !isMeta(h)).map((h) => `${h.method} ${h.path}`)).toEqual([`POST /api/v1/data/${OBJECT}/c1/page`]); + }); + + it('a page opening on the 动态 tab asks the whole window and the count, and reads nothing more', async () => { + const { held, open } = await boot( + { activity: { total: 330, records: ROWS(200) }, timeline: { total: 341 } }, + `/apps/demo/${OBJECT}/record/c1?related=timeline`, + ); + open(); + await settle(held); + const [page] = held.filter(isPage); + expect(page.body.activity).toEqual({ top: 200 }); + expect(page.body.timeline).toEqual({ discussion: true, activity: true, fieldChanges: false }); + expect(activityReads(held)).toEqual([]); + }); + + it('ablation — a payload without the count leaves the chip on the rows the page holds', async () => { + const { held, open } = await boot({ activity: { total: 330, records: ROWS(5) } }); + const view = open(); + await settle(held); + // One comment + five activity rows: what the page holds, not the record's stream. + expect(chipOf(view.container)).toBe('6'); + }); +}); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index dfb8e3e56b1..3cc07506ff5 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -10,7 +10,7 @@ import { useState, useEffect, useLayoutEffect, useCallback, useContext, useMemo, useRef, type ReactElement } from 'react'; import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'; -import { activityRowToFeedItem, auditEntriesToFeedItems, bodyTableIdsOf, claimQueuedRecord, RecordFieldChangesContext, DefaultRecordPageContext, EMPTY_RELATED_ARRANGEMENT, InlineEditSaveBar, RecordBodyTablesContext, RecordMetaFooter, RecordPageColumns, buildDefaultPageColumns, buildDefaultPageSchema, extractMentions, isRefusedFeedRead, recordPageTierHasRightColumn, recordSitsInQueue, resolveTitleField, useQueueClaimOffered, useRecordEditable, useRecordPageTier } from '@object-ui/plugin-detail'; +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'; import { Empty, EmptyTitle, EmptyDescription, Button, PageHeaderActions, PageHeaderPhoneContext, PageHeaderTrailingContext, PanelToggles, RecordTopBar, cn, useRecordStepKeys, type PanelToggleItem, type RecordTopBarStepper } from '@object-ui/components'; import { useAuth, createAuthenticatedFetch } from '@object-ui/auth'; import { usePermissions } from '@object-ui/permissions'; @@ -44,6 +44,7 @@ import { recordPageComments, recordPageRelatedLists, recordPageRequestOf, + recordPageTimeline, seedRecordPageReaders, } from './recordPagePayload.js'; import { withPageTabsUrlSync } from '../utils/pageTabsUrlSync.js'; @@ -402,6 +403,41 @@ function mergeFeedRows(prev: readonly FeedItem[], incoming: readonly FeedItem[]) }); } +/** + * The record feed's own read of the activity window (`$top: + * RECORD_PAGE_ACTIVITY_ROWS`, newest first): the feed's read where the page + * payload carries no activity, and the 动态 tab's when it opens on a feed that + * holds only the 动态 panel's rows (objectstack#28737). The actor's record in + * place of its id, so a row with no `actor_name` names its user instead of + * reading "System" (objectui#12067); the `record:activity` block's own read + * passes the same expand. + */ +function activityWindowQuery(objectName: string, recordId: string) { + return { + $filter: { object_name: objectName, record_id: recordId }, + $orderby: { timestamp: 'desc' as const }, + $top: RECORD_PAGE_ACTIVITY_ROWS, + $expand: ['actor_id'], + }; +} + +/** + * `sys_activity` rows as feed items, through `record:activity`'s own reading + * (`activityRowToFeedItem`). `null` is the ONE outcome that drops a row, and + * it means exactly one thing: a type the shared table maps to no entry ON + * PURPOSE (`RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES` — their content lives in + * `sys_comment`, or they are account events rather than record activity). + * Everything else comes back as an item, including a type nobody has mapped. + */ +function activityRowsToFeedItems(rows: readonly any[], systemActorLabel: string): FeedItem[] { + const mapped: FeedItem[] = []; + for (const row of rows) { + const item = activityRowToFeedItem(row, systemActorLabel); + if (item) mapped.push(item); + } + return mapped; +} + /** * The user ids a feed row's reaction holds in `sys_comment.reactions`. Every * reaction on a row carries them: the read keeps each emoji's stored list as @@ -831,6 +867,12 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // rows (objectui#11195), so a refusal on record A never reaches record B's // panel. const [feedRefusalByRecord, setFeedRefusalByRecord] = useState>({}); + // objectstack#28737 — the 动态 stream's count the page payload answered, per + // record (`recordPageTimeline`): `null` when it carried none. + const [timelineCountByRecord, setTimelineCountByRecord] = useState>({}); + // objectstack#28737 — the records whose feed holds only the 动态 panel's + // activity rows of more: the 动态 tab reads the window when it opens. + const [activityPeekByRecord, setActivityPeekByRecord] = useState>({}); const [mentionSuggestions, setMentionSuggestions] = useState([]); // Screen-flow runtime: a paused `screen`-node flow launched from a record action. const [screenFlow, setScreenFlow] = useState(null); @@ -1099,6 +1141,13 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri feedKey: string; discussion: boolean; activity: boolean; + /** + * objectstack#28737 — how many activity rows the payload asked (fewer + * than the window when it asked the 动态 panel's), and whether it asked + * the stream's count. + */ + activityRows: number; + timeline: boolean; } | null>(null); // objectstack#26589 — the record this viewer just decided an approval on, // as `objectName` + `recordId`. A pending approver with no standing access @@ -2244,13 +2293,25 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // list names the lookups of its rows to resolve, as `RelatedList`'s own read // expands them (every declared reference of the child but the parent's, // gated by field-level security), so its rows carry the names the list draws. + // The audit history gate (the audit read below explains its three + // preconditions), read here first: the 动态 stream's count names the field + // changes as one of its sources (objectstack#28737). + const historyEnabled = useMemo(() => { + if (!objectDef) return false; + if (objectDef.name === 'sys_audit_log') return false; + if (objectDef.enable?.trackHistory !== true) return false; + return objects.some((o: any) => o.name === 'sys_audit_log'); + }, [objectDef, objects]); + const feedPageOnOpen = useMemo( + () => (!effectivePage ? null : objectDef?.enable?.feeds !== false ? effectivePage : stripDiscussionNodes(effectivePage)), + [effectivePage, objectDef], + ); const feedOnOpen = useMemo(() => { - if (!effectivePage) return false; - const page = objectDef?.enable?.feeds !== false ? effectivePage : stripDiscussionNodes(effectivePage); + const page = feedPageOnOpen; if (page == null) return false; return (pageColumnsEligible && buildDefaultPageColumns(page) !== null) || pageShowsNodeOnOpen(page, FEED_CONSUMER_TYPES, activeTabParam); - }, [effectivePage, objectDef, pageColumnsEligible, activeTabParam]); + }, [feedPageOnOpen, pageColumnsEligible, activeTabParam]); const recordPagePlan = useMemo(() => { if (!hasPage || !objectName || !pureRecordId || typeof dataSource?.getRecordPage !== 'function') return ''; if (objectName === SYS_APPROVAL_REQUEST_OBJECT) return ''; @@ -2279,12 +2340,26 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // draws the 动态 panel the assigned one may not) changes them and asks nothing // again (objectui#11699). The feed effect reads which streams the payload // carries off `recordPageRead`, and asks the rest itself. + // + // objectstack#28737 — a page that draws the 动态 panel (the synthesized page + // laid out in columns) draws the stream's count beside it: the payload counts + // the stream (`timeline`, over the sources the stream merges), so the page + // asks only the activity rows the panel draws, unless it opens on the 动态 + // tab, which draws the whole window. The tab reads that window itself when + // it opens later (the window effect below). + const timelineDrawnOnOpen = feedOnOpen && pageColumnsEligible && !!feedPageOnOpen && buildDefaultPageColumns(feedPageOnOpen) !== null; + const timelinePeekOnOpen = timelineDrawnOnOpen && enlargedRelatedParam !== RECORD_PAGE_TIMELINE_PANEL; + const streamDiscussion = feedOnOpen && objectDef?.enable?.feeds !== false; + const streamActivity = feedOnOpen && objectDef?.enable?.activities !== false; const recordPageStreams = { - ...(feedOnOpen && objectDef?.enable?.feeds !== false ? { discussion: true } : {}), - ...(feedOnOpen && objectDef?.enable?.activities !== false ? { activity: RECORD_PAGE_ACTIVITY_ROWS } : {}), + ...(streamDiscussion ? { discussion: true } : {}), + ...(streamActivity ? { activity: timelinePeekOnOpen ? RECORD_TIMELINE_PANEL_MAX_ENTRIES : RECORD_PAGE_ACTIVITY_ROWS } : {}), ...(objectDef?.enable?.files === true || hasExplicitAttachments(effectivePage as any) ? { attachments: RECORD_PAGE_ATTACHMENT_ROWS } : {}), + ...(timelineDrawnOnOpen && (streamDiscussion || streamActivity) + ? { timeline: { discussion: streamDiscussion, activity: streamActivity, fieldChanges: historyEnabled } } + : {}), }; const recordPageStreamsRef = useRef(recordPageStreams); recordPageStreamsRef.current = recordPageStreams; @@ -2316,6 +2391,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri feedKey: `${objectName}:${pureRecordId}`, discussion: plan.discussion === true, activity: plan.activity !== undefined, + activityRows: plan.activity ?? 0, + timeline: plan.timeline !== undefined, }; seedRecordPageReaders({ payload, objectName, recordId: pureRecordId, principal: recordPagePrincipal, ...plan }); }, [recordPagePlan, objectName, pureRecordId, dataSource, recordPagePrincipal]); @@ -2480,12 +2557,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // browser never receives serialized old_value/new_value payloads, which // can contain restricted fields. Field-level redaction in PR2 will harden // this further once a backend-scoped audit endpoint exists. - const historyEnabled = useMemo(() => { - if (!objectDef) return false; - if (objectDef.name === 'sys_audit_log') return false; - if (objectDef.enable?.trackHistory !== true) return false; - return objects.some((o: any) => o.name === 'sys_audit_log'); - }, [objectDef, objects]); + // (`historyEnabled` itself is read further up, where the page payload's + // `timeline` part names the field changes as a source of the 动态 stream.) // ── Capability gates: enable.feeds / enable.activities (objectstack-ai/objectstack#2707) ───── // Both are opt-OUT capabilities (spec default `true`): absent enable @@ -3109,35 +3182,34 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri const activityRead: Promise | null = !activitiesEnabled ? null : fromPage?.activity - // objectstack#28470 — the payload's activity: the newest - // RECORD_PAGE_ACTIVITY_ROWS rows, the actor expanded. + // objectstack#28470 — the payload's activity: the newest rows it + // asked (the window, or the 动态 panel's, objectstack#28737), the + // actor expanded. ? recordPageActivity(fromPage.payload) - : dataSource.find('sys_activity', { - $filter: { object_name: objectName, record_id: pureRecordId }, - $orderby: { timestamp: 'desc' }, - $top: RECORD_PAGE_ACTIVITY_ROWS, - // The actor's record in place of its id, so a row with no `actor_name` - // names its user instead of reading "System" (objectui#12067). The - // shared constructor reads it; the `record:activity` block's own read - // passes the same expand. - $expand: ['actor_id'], - }); + : dataSource.find('sys_activity', activityWindowQuery(objectName, pureRecordId)); + // objectstack#28737 — the stream's count, when the payload asked it. + // Collected with the reads, so the panel leaves its loading state with the + // count it names. A read without the payload holds the whole window and + // counts its own rows. + if (fromPage?.timeline) { + inFlight.push(recordPageTimeline(fromPage.payload).then((count) => { + setTimelineCountByRecord(prev => ({ ...prev, [threadId]: count })); + })); + } else { + setTimelineCountByRecord(prev => (prev[threadId] == null ? prev : { ...prev, [threadId]: null })); + } if (activityRead) inFlight.push(activityRead .then((res: any) => { recordRefusal('activity', false); + // objectstack#28737 — the payload asked only the 动态 panel's rows and + // the record holds more: the 动态 tab reads the window when it opens. + const peek = !!fromPage?.activity + && fromPage.activityRows < RECORD_PAGE_ACTIVITY_ROWS + && typeof res?.total === 'number' + && res.total > (res?.data?.length ?? 0); + setActivityPeekByRecord(prev => ((prev[threadId] ?? false) === peek ? prev : { ...prev, [threadId]: peek })); if (!res?.data?.length) return; - const systemActorLabel = t('detail.systemActor', { defaultValue: 'System' }); - const mapped: FeedItem[] = []; - for (const row of res.data) { - // `null` is the ONE outcome that drops a row, and it means exactly - // one thing: a type the shared table maps to `undefined` ON PURPOSE - // (`commented` / `mentioned` / `login` / `logout` — their content - // lives in `sys_comment`, or they are account events rather than - // record activity). Everything else comes back as an item, including - // a type nobody has mapped yet. - const item = activityRowToFeedItem(row, systemActorLabel); - if (item) mapped.push(item); - } + const mapped = activityRowsToFeedItems(res.data, t('detail.systemActor', { defaultValue: 'System' })); if (!mapped.length) return; // Merge by id into THIS record's slice (timeline events are // append-only); `mergeFeedRows` sorts by createdAt ascending so the @@ -3165,6 +3237,50 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri return () => { cancelled = true; }; }, [dataSource, objectName, pureRecordId, currentUser, feedsEnabled, activitiesEnabled, feedFetchKey, reactionStore, feedWanted, feedPlanReady]); + // ── The 动态 tab's activity window (objectstack#28737) ───────────────── + // + // A page that opened on the 动态 panel asked only the activity rows the + // panel draws. The 动态 tab draws the whole stream, so when it opens on a + // record whose feed holds fewer activity rows than the record has, the + // window is read here — once per record, merged into its slice by id as the + // feed's own read merges. A record whose rows the payload held whole (none, + // or fewer than the panel's) reads nothing. + const timelineTabOpen = enlargedRelatedParam === RECORD_PAGE_TIMELINE_PANEL; + const activityPeekHeld = feedRecordKey ? activityPeekByRecord[feedRecordKey] === true : false; + useEffect(() => { + if (!timelineTabOpen || !activityPeekHeld || !dataSource || !objectName || !pureRecordId || !activitiesEnabled) return; + const threadId = `${objectName}:${pureRecordId}`; + setActivityPeekByRecord(prev => ({ ...prev, [threadId]: false })); + // Written under the record it was read for, so an answer landing after a + // step to another record fills this one's slice, never the one on screen. + Promise.resolve(dataSource.find('sys_activity', activityWindowQuery(objectName, pureRecordId))) + .then((res: any) => { + const mapped = activityRowsToFeedItems(res?.data ?? [], t('detail.systemActor', { defaultValue: 'System' })); + if (!mapped.length) return; + setFeedItemsByRecord(prev => ({ + ...prev, + [threadId]: mergeFeedRows(prev[threadId] ?? EMPTY_FEED, mapped), + })); + }) + .catch((err: unknown) => { + // The tab keeps the panel's rows; the stream's count stays the + // payload's, so it still names the whole stream. + console.warn('[RecordDetailView] Failed to read the activity window for the 动态 tab:', err); + }); + // `t` is identity-stable per language. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [timelineTabOpen, activityPeekHeld, dataSource, objectName, pureRecordId, activitiesEnabled]); + + // The 动态 stream's count where the payload answered one (objectstack#28737): + // its total with the comments it counted replaced by the comments the feed + // holds now, which this view keeps whole and current through its own + // comment writes. `null` (no count answered): the stream counts its rows. + const timelineServed = feedRecordKey ? timelineCountByRecord[feedRecordKey] ?? null : null; + const heldCommentCount = useMemo(() => feedItems.filter((item) => item.type === 'comment').length, [feedItems]); + const timelineTotal = timelineServed + ? Math.max(0, timelineServed.total - timelineServed.comments + heldCommentCount) + : null; + /** * Note: comment-mention → notification fan-out lives on the server * (`@objectstack/plugin-audit` registers a `sys_comment` afterInsert hook @@ -4745,6 +4861,9 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri {/* objectstack#27055 — the field changes of the timeline stream, for the synthesized page's `record:activity`. */} + {/* objectstack#28737 — the whole stream's count, for the 动态 panel, + its tab and the tab row's probe. */} + {/* No `approval` handler in the set below (objectui#3055): the record page has no bespoke approval action TYPE any more. A decision is an ordinary `type:'api'` action declared on `sys_approval_request` and @@ -5016,6 +5135,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri )} {modalElement} + diff --git a/ui/packages/app-shell/src/views/recordPagePayload.ts b/ui/packages/app-shell/src/views/recordPagePayload.ts index 11c69aa9ad9..4ba4c6d23bc 100644 --- a/ui/packages/app-shell/src/views/recordPagePayload.ts +++ b/ui/packages/app-shell/src/views/recordPagePayload.ts @@ -31,7 +31,10 @@ * - the discussion and the activity → the page's feed, which takes them * from the payload (`recordPageComments` / `recordPageActivity`) instead * of reading `sys_comment`, `sys_comment_reaction` and `sys_activity` - * (objectstack#28470). + * (objectstack#28470); + * - the 动态 stream's count → the page's 动态 count (`recordPageTimeline`), + * so the page asks only the activity rows the 动态 panel draws + * (objectstack#28737). * * Each related list names the lookups of its rows to resolve (`expand`), as * the list's own read does, so the rows carry the names the list draws and no @@ -70,10 +73,12 @@ import { seedRecordApprovals, type ApprovalRequestLite } from '../hooks/useRecor const MAX_RELATED = 50; /** - * How many activity rows the page asks (objectstack#28470): the window the - * record feed always read (`$top: 200`). The 动态 panel draws the newest few, - * but its count and the 动态 tab are the whole merged stream the page holds, - * so the page asks the window the stream is drawn from, not the panel's peek. + * The activity window the record feed reads (`$top: 200`, objectstack#28470): + * what the 动态 tab draws in full, and what a page asks when it opens on a + * face that draws the whole stream. A page that opens on the 动态 panel asks + * only the rows the panel draws, and the stream's count comes with it + * (`timeline`, objectstack#28737); the 动态 tab reads this window when it + * opens. */ export const RECORD_PAGE_ACTIVITY_ROWS = 200; @@ -210,6 +215,8 @@ export function recordPageRequestOf(plan: { activity?: number; /** How many attachments, when the page draws them. */ attachments?: number; + /** The sources the page's 动态 stream merges, when it draws the stream's count (objectstack#28737). */ + timeline?: { discussion: boolean; activity: boolean; fieldChanges: boolean }; }): RecordPageRequest { return { ...(plan.expand.length > 0 ? { expand: plan.expand } : {}), @@ -227,6 +234,7 @@ export function recordPageRequestOf(plan: { ...(plan.discussion ? { discussion: true } : {}), ...(plan.activity !== undefined ? { activity: { top: plan.activity } } : {}), ...(plan.attachments !== undefined ? { attachments: { top: plan.attachments } } : {}), + ...(plan.timeline ? { timeline: plan.timeline } : {}), }; } @@ -290,21 +298,45 @@ export function recordPageComments(payload: Promise): /** * The payload's activity as the record feed's activity read answers it - * (`{ data }`): rejects with that read's refusal. + * (`{ data }`), with how many rows the record holds (`total`), so the feed + * knows whether it holds them all: rejects with that read's refusal. */ -export function recordPageActivity(payload: Promise): Promise<{ data: Record[] }> { +export function recordPageActivity( + payload: Promise, +): Promise<{ data: Record[]; total: number }> { return payload.then((page) => { const part = page?.activity; - if (!page || !part) return { data: [] }; + if (!page || !part) return { data: [], total: 0 }; if (isRefused(part)) { const thrown = streamRefusal(part.error, 'Activity refused'); if (thrown) throw thrown; - return { data: [] }; + return { data: [], total: 0 }; } - return { data: part.records ?? [] }; + const data = part.records ?? []; + return { data, total: typeof part.total === 'number' ? part.total : data.length }; }); } +/** + * The 动态 stream's count as the payload answers it (objectstack#28737): + * `total`, and how many of its entries are the comments the payload's + * discussion carried (`comments`) — the comment half the feed holds whole and + * keeps current through its own writes, so the page's count is + * `total - comments + the comments it holds`. `null` when the payload carries + * no count or a read behind it was refused: the feed's own refusal says so. + */ +export function recordPageTimeline( + payload: Promise, +): Promise<{ total: number; comments: number } | null> { + return payload.then((page) => { + const part = page?.timeline; + if (!page || !part || isRefused(part)) return null; + const discussion = page.discussion; + const comments = discussion && !isRefused(discussion) ? (discussion.comments ?? []).length : 0; + return { total: part.total, comments }; + }, () => null); +} + /** * Hand each part of the payload to the reader that asks it. Call it before any * reader's effect runs (a layout effect of the page), so each reader joins its diff --git a/ui/packages/plugin-detail/src/RecordPageColumns.tsx b/ui/packages/plugin-detail/src/RecordPageColumns.tsx index 0cb76435c2b..2dcef6acddd 100644 --- a/ui/packages/plugin-detail/src/RecordPageColumns.tsx +++ b/ui/packages/plugin-detail/src/RecordPageColumns.tsx @@ -23,7 +23,7 @@ import { RelatedCardStack } from './RelatedCardStack'; import { RecordTimelinePanel } from './RecordTimelinePanel'; import { RECORD_TAB_OVERVIEW, RecordTabRow, type RecordTab } from './RecordTabRow'; import { partitionRecordTabsByRecords } from './synth/recordTabRow'; -import { RecordTimelineCountContext } from './recordTimelineStream'; +import { RECORD_TIMELINE_PANEL_MAX_ENTRIES, RecordTimelineCountContext } from './recordTimelineStream'; import { RecordTimelineCountProbe } from './renderers/record-activity'; import { relatedCardId, @@ -405,7 +405,7 @@ export const RecordPageColumns: React.FC = ({ {hasTimeline && ( new Date(Date.UTC(2026, 9, 1, 0, minute)).toISOString(); +// What the page holds: a comment and the 动态 panel's five activity rows. +const held: FeedItem[] = [ + { id: 'c1', type: 'comment', actor: 'Ada', body: 'Called back', createdAt: at(1) }, + ...Array.from({ length: 5 }, (_, i): FeedItem => ({ + id: `a${i}`, + type: 'system', + actor: 'System', + body: `Step ${i}`, + createdAt: at(10 + i), + })), +]; +const SERVED = 341; + +function mount({ + total = SERVED as number | null, + items = held, + loading = false, + activityDenied = false, +}: { total?: number | null; items?: FeedItem[]; loading?: boolean; activityDenied?: boolean } = {}) { + return render( + + + + + + + + + , + ); +} + +const chipOf = (container: HTMLElement) => container.querySelector('[data-record-timeline-count]')?.textContent ?? null; + +describe('objectstack#28737 — the 动态 panel names the host\'s count of the whole stream', () => { + it('the count chip and 「查看全部 N 条」 name the served total, not the six rows held', async () => { + const { container } = mount(); + await waitFor(() => expect(chipOf(container)).toBe(String(SERVED))); + const viewAll = container.querySelector('[data-record-timeline-view-all]') as HTMLButtonElement; + expect(viewAll.textContent).toContain(String(SERVED)); + // The panel still draws the newest rows it holds. + expect(container.querySelectorAll('[data-record-timeline-peek-row]')).toHaveLength(5); + }); + + it('a served 0 is a count: the chip says 0', async () => { + const { container } = mount({ total: 0, items: [] }); + await waitFor(() => expect(chipOf(container)).toBe('0')); + }); + + it('names no count while the feed loads, or when a read was refused, whatever the host counted', () => { + expect(chipOf(mount({ loading: true }).container)).toBeNull(); + cleanup(); + expect(chipOf(mount({ activityDenied: true }).container)).toBeNull(); + }); + + it('CONTROL — without a host count, the chip counts the rows held', async () => { + const { container } = mount({ total: null }); + await waitFor(() => expect(chipOf(container)).toBe(String(held.length))); + }); +}); + +describe('objectstack#28737 — the 动态 tab and the tab row\'s probe report the host\'s count', () => { + it('the stream drawn in full reports the served total while 全部 is on', async () => { + const reported = vi.fn(); + render( + + + + + + + + + , + ); + await waitFor(() => expect(reported).toHaveBeenLastCalledWith(SERVED)); + }); + + it('the probe reports the served total, and the rows\' count without one', async () => { + const probe = (total: number | null) => { + const onCount = vi.fn(); + render( + + + + + , + ); + return onCount; + }; + const served = probe(SERVED); + await waitFor(() => expect(served).toHaveBeenLastCalledWith(SERVED)); + cleanup(); + const own = probe(null); + await waitFor(() => expect(own).toHaveBeenLastCalledWith(held.length)); + }); +}); + +describe('objectstack#28737 — the types the stream draws no entry for are one definition with the server\'s count', () => { + it('the activity reading maps exactly RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES to no entry', () => { + const undrawn = Object.keys(ACTIVITY_TYPE_TO_FEED_TYPE).filter((type) => ACTIVITY_TYPE_TO_FEED_TYPE[type] === undefined); + expect(undrawn.sort()).toEqual([...RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES].sort()); + }); + + it('an update steps aside for a field change by RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE, and nothing else does', () => { + const update = { id: 'u1', type: 'field_change', actor: 'Ada', body: '', createdAt: at(2), activityType: RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE } as FeedItem; + const created = { id: 'n1', type: 'field_change', actor: 'Ada', body: '', createdAt: at(0), activityType: 'created' } as FeedItem; + const changes = auditEntriesToFeedItems( + [{ id: 1, created_at: at(2), user_name: 'Ada', changes: [{ field: 'status', from: 'a', to: 'b' }] }], + { unknownUser: 'Unknown' }, + ); + expect(mergeTimelineStream([update, created], changes).map((item) => item.id)).toEqual(['audit:1', 'n1']); + expect(mergeTimelineStream([update, created], []).map((item) => item.id)).toEqual(['u1', 'n1']); + }); +}); + +/** The default page's 动态 block drawn in full (outside the panel). */ +function RecordTimelinePanelNodes() { + return ; +} diff --git a/ui/packages/plugin-detail/src/index.tsx b/ui/packages/plugin-detail/src/index.tsx index 1463a822825..ea60f038cb4 100644 --- a/ui/packages/plugin-detail/src/index.tsx +++ b/ui/packages/plugin-detail/src/index.tsx @@ -212,6 +212,7 @@ export { recordPageColumnOfTab, buildDefaultPageColumns, bodyTableIdsOf, + RECORD_PAGE_TIMELINE_PANEL, } from './synth/recordPageColumns'; // objectstack#27069 — the one reading of a related-list entry. export { relatedListEntryNames } from './synth/relatedListEntry'; @@ -236,6 +237,10 @@ export type { RelatedCountTarget, RelatedCountsSource } from './useRelatedCounts // the record's field changes over through this context. export { RecordFieldChangesContext, + // objectstack#28737 — the stream's size as the host counts it, and the + // panel's most entries (the host asks that many activity rows at open). + RecordTimelineTotalContext, + RECORD_TIMELINE_PANEL_MAX_ENTRIES, auditEntriesToFeedItems, mergeTimelineStream, filterTimelineStream, diff --git a/ui/packages/plugin-detail/src/recordTimelineStream.ts b/ui/packages/plugin-detail/src/recordTimelineStream.ts index 056d8a63ff3..71c77cf2a00 100644 --- a/ui/packages/plugin-detail/src/recordTimelineStream.ts +++ b/ui/packages/plugin-detail/src/recordTimelineStream.ts @@ -19,6 +19,7 @@ */ import * as React from 'react'; +import { RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE } from '@objectstack/spec/api'; import type { FeedItem } from '@object-ui/types'; /** @@ -46,6 +47,20 @@ export interface RecordTimelinePanel { export const RecordTimelinePanelContext = React.createContext(null); +/** The most entries the 动态 panel shows (its wide face); 3 when narrow. */ +export const RECORD_TIMELINE_PANEL_MAX_ENTRIES = 5; + +/** + * The whole 动态 stream's size as the host counts it (objectstack#28737): the + * record page payload's `timeline` count, kept current by the host across its + * own comment writes. The host then holds only the newest of the stream's + * activity, so the count its rows would give is not the stream's: the panel's + * count chip, its 「查看全部 N 条」, the 动态 tab's count and the tab row's + * probe all name this one instead. `null` (no host count, or not yet + * answered): each counts the stream it holds, as before. + */ +export const RecordTimelineTotalContext = React.createContext(null); + /** * Where the 动态 stream drawn in full — the 动态 tab — reports how many * entries it holds, for the tab's count chip on the record page's tab row @@ -120,7 +135,7 @@ export function mergeTimelineStream( ): FeedItem[] { const changes = fieldChanges ?? []; const base = (feed ?? []).filter( - (item) => !(changes.length > 0 && (item as { activityType?: string }).activityType === 'updated'), + (item) => !(changes.length > 0 && (item as { activityType?: string }).activityType === RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE), ); return [...base, ...changes] .map((item, index) => ({ item, index })) diff --git a/ui/packages/plugin-detail/src/renderers/record-activity.tsx b/ui/packages/plugin-detail/src/renderers/record-activity.tsx index 30c007df613..eee4455023f 100644 --- a/ui/packages/plugin-detail/src/renderers/record-activity.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-activity.tsx @@ -83,6 +83,7 @@ import { RecordFieldChangesContext, RecordTimelineCountContext, RecordTimelinePanelContext, + RecordTimelineTotalContext, filterTimelineStream, mergeTimelineStream, type TimelineStreamFilter, @@ -338,10 +339,14 @@ export const RecordActivityRenderer: React.FC = ({ // row: the whole stream's size, unknown while the feed loads or a read was // refused (a refused read is not "none"). The tab's full face reports it // while its 全部 chip is on; another chip's total is not the stream's. + // objectstack#28737 — the whole stream's size is the host's count where it + // has one (it holds only the newest activity then), else the rows' own. const countReporter = React.useContext(RecordTimelineCountContext); + const hostTotal = React.useContext(RecordTimelineTotalContext); + const wholeTotal = asStream && hostTotal !== null ? hostTotal : applied.total; const reportCount = asPeek ? timelinePanel?.onCount : asStream ? (countReporter ?? undefined) : undefined; const reportsWhole = asPeek || streamFilter === 'all'; - const streamCount = !loading && !activityDenied && !commentsDenied ? applied.total : null; + const streamCount = !loading && !activityDenied && !commentsDenied ? wholeTotal : null; React.useEffect(() => { if (reportsWhole) reportCount?.(streamCount); }, [reportCount, reportsWhole, streamCount]); @@ -377,7 +382,7 @@ export const RecordActivityRenderer: React.FC = ({ {...(asPeek ? { variant: 'peek' as const, - totalCount: applied.total, + totalCount: wholeTotal, onViewAll: timelinePanel?.onViewAll, } : asStream @@ -433,6 +438,7 @@ export function RecordTimelineCountProbe({ const node = firstActivityNode(nodes); const discussion = useDiscussionContext(); const fieldChanges = React.useContext(RecordFieldChangesContext); + const hostTotal = React.useContext(RecordTimelineTotalContext); const bag = (node?.properties ?? {}) as Record; const read = (key: string) => node?.[key] ?? bag[key]; const hostItems = Array.isArray(read('items')) ? (read('items') as FeedItem[]) : undefined; @@ -444,9 +450,11 @@ export function RecordTimelineCountProbe({ const unifiedTimeline = read('unifiedTimeline'); const count = React.useMemo(() => { if (!node || !sourceItems || loading || denied) return null; + // objectstack#28737 — the host's count of the whole stream, where it has one. + if (hostTotal !== null) return hostTotal; const stream = mergeTimelineStream(sourceItems, fieldChanges); return applyFeedConfig(stream, { types, showCompleted, unifiedTimeline }, Number.MAX_SAFE_INTEGER, BLOCK_NAME).items.length; - }, [node, sourceItems, loading, denied, fieldChanges, types, showCompleted, unifiedTimeline]); + }, [node, sourceItems, loading, denied, hostTotal, fieldChanges, types, showCompleted, unifiedTimeline]); React.useEffect(() => { onCount(count); }, [onCount, count]); diff --git a/ui/packages/plugin-detail/src/renderers/recordActivityFeed.ts b/ui/packages/plugin-detail/src/renderers/recordActivityFeed.ts index d2735e022af..5cf61e5b5ac 100644 --- a/ui/packages/plugin-detail/src/renderers/recordActivityFeed.ts +++ b/ui/packages/plugin-detail/src/renderers/recordActivityFeed.ts @@ -35,6 +35,7 @@ */ import { FeedFilterMode as FeedFilterModeEnum, FeedItemType as FeedItemTypeEnum } from '@objectstack/spec/data'; +import { RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES } from '@objectstack/spec/api'; import { classifyLoadError } from '@object-ui/react'; import type { FeedItem, FeedItemType } from '@object-ui/types'; import type { FeedFilterMode } from '../RecordActivityTimeline'; @@ -144,10 +145,10 @@ export const ACTIVITY_TYPE_TO_FEED_TYPE: Readonly [type, undefined])), }; /** diff --git a/ui/packages/types/src/data.ts b/ui/packages/types/src/data.ts index 7cc610e93b8..64d5fe76701 100644 --- a/ui/packages/types/src/data.ts +++ b/ui/packages/types/src/data.ts @@ -422,6 +422,12 @@ export interface RecordPagePayload { activity?: { total: number; records: Record[] } | { error: RecordPagePartRefusal }; /** The record's newest attachments and their count, or their read's refusal (objectstack#28470). */ attachments?: { total: number; records: Record[] } | { error: RecordPagePartRefusal }; + /** + * How many entries the record's 动态 stream holds over the sources the + * request named (objectstack#28737), or the refusal of a comment or + * activity read. + */ + timeline?: { total: number } | { error: RecordPagePartRefusal }; } /**