Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/21279-record-activity-host-feed-guidance.md
Original file line number Diff line number Diff line change
@@ -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.
72 changes: 72 additions & 0 deletions packages/spec/src/ui/component-record-blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => {
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;
Expand Down
34 changes: 34 additions & 0 deletions packages/spec/src/ui/component.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading