diff --git a/.changeset/21279-record-activity-host-feed-guidance.md b/.changeset/21279-record-activity-host-feed-guidance.md new file mode 100644 index 00000000000..7f048f767c7 --- /dev/null +++ b/.changeset/21279-record-activity-host-feed-guidance.md @@ -0,0 +1,12 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): `record:activity`'s props row names `items` / `loading` as the host's feed slot when it refuses them + +Clause-②: no + +`ComponentPropsMap['record:activity']` (`RecordActivityProps`) refused an authored `properties.items` or `properties.loading` with only the generic "Unrecognized key(s) on this `record:activity`" line. Both are keys the objectui `record:activity` renderer reads, as a feed a host that composes the block in code already owns, so an author copying a TSX composition into a JSON page met no reason for the refusal. + +- The refusal now says who reads each key on each mount the row reaches. On a standalone `record:activity`, `items` is the host's data channel and `loading` the host's fetch state for that feed. On a `record:chatter` / `record:discussion` `feed`, which is the same object, nothing reads either. The remedy is the same on both: omit them. The block then presents the record page's discussion feed, and a standalone `record:activity` with no discussion context fetches the record's own `sys_activity` rows. This is the same `guidance` shape `record:history`'s row already uses for `entries` / `loading`. +- The accept set does not change. Both keys stay refused, through the row and through `record:chatter` / `record:discussion`'s `feed`, which is the same object. Only the message text changes; `record:history` is unchanged. diff --git a/packages/spec/src/ui/component-record-blocks.test.ts b/packages/spec/src/ui/component-record-blocks.test.ts index d1eafce555c..1de5765b4f3 100644 --- a/packages/spec/src/ui/component-record-blocks.test.ts +++ b/packages/spec/src/ui/component-record-blocks.test.ts @@ -280,6 +280,78 @@ describe('ComponentPropsMap["record:history"] (#8744)', () => { }); }); +describe('ComponentPropsMap["record:activity"] — the host feed slot is named, not declared', () => { + // `record:history`'s `entries` / `loading` pair, on the activity block: the + // standalone renderer takes `items` and `loading` as a host-supplied feed. + // Both keys stay REFUSED (the accept set is unchanged); only the refusal + // names them. The row is also `record:chatter` / `record:discussion`'s + // `feed`, where nothing reads either key, so each message states both mounts. + const row = ComponentPropsMap['record:activity']; + const refusalOf = (bag: Record) => { + const result = row.safeParse(bag); + expect(result.success).toBe(false); + const issues = result.error!.issues; + expect(issues).toHaveLength(1); + return issues[0]!; + }; + + it('refuses `items` and `loading` at the bag, for every value — the key, not a value domain', () => { + for (const [key, values] of [ + ['items', [[{ id: 'a1', type: 'comment' }], []]], + ['loading', [true, false]], + ] as const) { + for (const value of values) { + const issue = refusalOf({ [key]: value }); + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.path).toEqual([]); + expect((issue as { keys?: string[] }).keys).toEqual([key]); + } + } + }); + + it('names `items` as the standalone block\'s host data channel, with the omit remedy', () => { + const { message } = refusalOf({ items: [{ id: 'a1', type: 'comment' }] }); + expect(message).toContain('`items` is not authorable surface. On a standalone `record:activity` it is the HOST\'s data channel'); + expect(message).toContain('Omit it'); + // The self-fetch fallback is scoped to the standalone block — the chatter mount has none. + expect(message).toContain('a standalone `record:activity` with no discussion context self-fetches the record\'s own `sys_activity` rows'); + }); + + it('names `loading` as the standalone block\'s host fetch state, with the omit remedy', () => { + const { message } = refusalOf({ loading: true }); + expect(message).toContain('`loading` is not authorable surface. On a standalone `record:activity` it is the host\'s fetch state for its `items` feed'); + expect(message).toContain('Omit it with `items`'); + }); + + it('CONTROL: an unrelated unknown key keeps the generic refusal, with no host-channel line', () => { + const issue = refusalOf({ inventedFeedKey: [] }); + expect((issue as { keys?: string[] }).keys).toEqual(['inventedFeedKey']); + expect(issue.message).toContain('Unrecognized key(s) on this `record:activity`'); + expect(issue.message).not.toContain('data channel'); + expect(issue.message).not.toContain('fetch state'); + }); + + it('the `record:chatter` / `record:discussion` `feed` (the same object) refuses both keys and says nothing reads them there', () => { + // At the `.objectui-sha` pin the chatter renderer reads its rows and its + // loading flag off the discussion context and never reads `feed.items` / + // `feed.loading`, so the mount-true clause is the one asserted here. + for (const type of ['record:chatter', 'record:discussion'] as const) { + for (const key of ['items', 'loading'] as const) { + const result = ComponentPropsMap[type].safeParse({ feed: { [key]: key === 'items' ? [] : true } }); + expect(result.success).toBe(false); + const issues = result.error!.issues; + expect(issues).toHaveLength(1); + const issue = issues[0]!; + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.path).toEqual(['feed']); + expect((issue as { keys?: string[] }).keys).toEqual([key]); + expect(issue.message).toContain('On a `record:chatter` / `record:discussion` `feed` nothing reads it.'); + expect(issue.message).toContain('Omit it'); + } + } + }); +}); + describe('the `record:discussion` / `record:chatter` pair (#8744)', () => { it('judges both names with one accept face', () => { const authored = { position: 'bottom', collapsible: false } as const; diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 03fda207923..81692a690c7 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -1610,6 +1610,40 @@ export const RecordActivityProps = strictObject({ surface: 'this `record:activity`', history: PROPS_HISTORY, guidanceSets: COMPONENT_LEVEL_GUIDANCE, + /** + * The host feed slot, NAMED rather than declared — `RecordHistoryProps`' + * `entries` / `loading` pair, on this block. Measured at the `.objectui-sha` + * pin `89cad75d5570` (objectui `plugin-detail/src/renderers/ + * record-activity.tsx:161-166`, `:265-266`): the renderer takes `items` and + * `loading` off the node or out of this bag as a feed a host composing the + * block in code already owns, and the host flag wins over every other + * loading source. Neither is authorable: an authored feed is a snapshot that + * never updates, and an authored `loading: true` pins the loading state on. + * So both stay UNDECLARED — the accept set is unchanged — and this table only + * makes the refusal name them instead of the generic unrecognized-key line. + * + * The same object is `record:chatter` / `record:discussion`'s `feed`, so the + * entries answer there too. That renderer reads its rows and its loading + * flag off the host's discussion context, reads neither `feed.items` nor + * `feed.loading`, and never self-fetches (`renderers/record-chatter.tsx`, + * same pin). `guidance` is one flat table keyed by key name — it cannot be + * scoped to a mount — so each entry says who reads the key on EACH mount, + * and every clause is true wherever the refusal is raised. + */ + guidance: { + items: '`items` is not authorable surface. On a standalone `record:activity` it is the HOST\'s ' + + 'data channel: a host composing that block in code passes the feed it already owns, and the ' + + 'renderer presents it in place of its own sources; hand-authored items would ship a static ' + + 'snapshot of the feed that never updates. On a `record:chatter` / `record:discussion` `feed` ' + + 'nothing reads it. Omit it: the block presents the record page\'s discussion feed, and a ' + + 'standalone `record:activity` with no discussion context self-fetches the record\'s own ' + + '`sys_activity` rows.', + loading: '`loading` is not authorable surface. On a standalone `record:activity` it is the host\'s ' + + 'fetch state for its `items` feed and wins over the block\'s other loading sources, so ' + + 'authored `true` pins the loading state on forever. On a `record:chatter` / ' + + '`record:discussion` `feed` nothing reads it. Omit it with `items`: each block takes its ' + + 'loading state from its own source.', + }, }, { /** * Feed/activity kinds to show — an OPEN vocabulary (commit 1a6a19c31, executing the