Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…ubscription, enhanced Activity/Chatter components) - Create src/data/feed.zod.ts with FeedItemType, FeedItemSchema, MentionSchema, ReactionSchema, FieldChangeEntrySchema, FeedActorSchema, FeedFilterMode - Create src/data/subscription.zod.ts with RecordSubscriptionSchema, SubscriptionEventType, NotificationChannel - Enhance RecordActivityProps with unifiedTimeline, filterMode, showCommentInput, enableMentions, enableReactions, enableThreading, showSubscriptionToggle - Implement RecordChatterProps replacing EmptyProps with sidebar/inline/drawer position, width, collapsible, embedded feed config - Update ComponentPropsMap to use RecordChatterProps - Add comprehensive tests (feed.test.ts, subscription.test.ts, enhanced component.test.ts) - Update ROADMAP.md Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds missing Feed/Chatter backing protocols to @objectstack/spec so existing feeds: true / activities: true capabilities and record:chatter UI component have a concrete, unified activity timeline model (Airtable/Salesforce-style), plus record-level subscription support.
Changes:
- Introduces
FeedItem/Mention/Reaction/FieldChangeEntryschemas and filtering enum insrc/data/feed.zod.ts. - Adds
RecordSubscriptionSchema+ channel/event enums insrc/data/subscription.zod.tsand exports both viasrc/data/index.ts. - Enhances
RecordActivityPropsand replacesrecord:chatterprops fromEmptyPropstoRecordChatterProps, with corresponding test coverage.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/spec/src/ui/component.zod.ts | Extends record activity configuration and defines RecordChatterProps; wires record:chatter into ComponentPropsMap. |
| packages/spec/src/ui/component.test.ts | Adds tests covering the new/expanded component prop schemas. |
| packages/spec/src/data/feed.zod.ts | Adds the unified feed/timeline data protocol schemas and enums. |
| packages/spec/src/data/feed.test.ts | Adds schema validation tests for feed protocol. |
| packages/spec/src/data/subscription.zod.ts | Adds record-level subscription protocol (events + channels + schema). |
| packages/spec/src/data/subscription.test.ts | Adds schema validation tests for subscription protocol. |
| packages/spec/src/data/index.ts | Re-exports the new feed/subscription protocols via the data domain entrypoint. |
| ROADMAP.md | Updates protocol deliverables list to include feed/timeline and record subscription. |
| export const ReactionSchema = z.object({ | ||
| emoji: z.string().describe('Emoji character or shortcode (e.g., "👍", ":thumbsup:")'), | ||
| userIds: z.array(z.string()).describe('Users who reacted'), | ||
| count: z.number().int().min(1).describe('Total reaction count'), | ||
| }); |
There was a problem hiding this comment.
ReactionSchema can become internally inconsistent because it requires both userIds and count but does not enforce that count matches userIds.length (and it also prevents cases where you only know the aggregate count). Consider either deriving count from userIds, making userIds optional with count required, or adding a refinement to keep them consistent.
| export const ReactionSchema = z.object({ | |
| emoji: z.string().describe('Emoji character or shortcode (e.g., "👍", ":thumbsup:")'), | |
| userIds: z.array(z.string()).describe('Users who reacted'), | |
| count: z.number().int().min(1).describe('Total reaction count'), | |
| }); | |
| export const ReactionSchema = z | |
| .object({ | |
| emoji: z.string().describe('Emoji character or shortcode (e.g., "👍", ":thumbsup:")'), | |
| userIds: z.array(z.string()).describe('Users who reacted'), | |
| count: z.number().int().min(1).describe('Total reaction count'), | |
| }) | |
| .refine( | |
| (reaction) => reaction.userIds.length === reaction.count, | |
| { | |
| message: 'Reaction count must match number of userIds', | |
| path: ['count'], | |
| }, | |
| ); |
| export const FeedItemSchema = z.object({ | ||
| /** Unique identifier */ | ||
| id: z.string().describe('Feed item ID'), | ||
|
|
||
| /** Feed item type */ | ||
| type: FeedItemType.describe('Activity type'), | ||
|
|
||
| /** Target record reference */ | ||
| object: z.string().describe('Object name (e.g., "account")'), | ||
| recordId: z.string().describe('Record ID this feed item belongs to'), | ||
|
|
||
| /** Actor (who performed the action) */ | ||
| actor: FeedActorSchema.describe('Who performed this action'), | ||
|
|
||
| /** Content (for comments/notes) */ | ||
| body: z.string().optional().describe('Rich text body (Markdown supported)'), | ||
|
|
||
| /** @Mentions */ | ||
| mentions: z.array(MentionSchema).optional().describe('Mentioned users/teams/records'), | ||
|
|
||
| /** Field changes (for field_change type) */ | ||
| changes: z.array(FieldChangeEntrySchema).optional().describe('Field-level changes'), | ||
|
|
||
| /** Reactions */ | ||
| reactions: z.array(ReactionSchema).optional().describe('Emoji reactions on this item'), | ||
|
|
||
| /** Reply threading */ | ||
| parentId: z.string().optional().describe('Parent feed item ID for threaded replies'), | ||
| replyCount: z.number().int().min(0).default(0).describe('Number of replies'), | ||
|
|
||
| /** Visibility */ | ||
| visibility: FeedVisibility.default('public') | ||
| .describe('Visibility: public (all users), internal (team only), private (author + mentioned)'), | ||
|
|
||
| /** Timestamps */ | ||
| createdAt: z.string().datetime().describe('Creation timestamp'), | ||
| updatedAt: z.string().datetime().optional().describe('Last update timestamp'), | ||
| editedAt: z.string().datetime().optional().describe('When comment was last edited'), | ||
| isEdited: z.boolean().default(false).describe('Whether comment has been edited'), | ||
| }); |
There was a problem hiding this comment.
FeedItemSchema allows invalid combinations for edit tracking (e.g., isEdited: true without editedAt, or editedAt present while isEdited is false). Add a refinement/discriminated state so the two fields stay consistent.
| export const FeedItemSchema = z.object({ | |
| /** Unique identifier */ | |
| id: z.string().describe('Feed item ID'), | |
| /** Feed item type */ | |
| type: FeedItemType.describe('Activity type'), | |
| /** Target record reference */ | |
| object: z.string().describe('Object name (e.g., "account")'), | |
| recordId: z.string().describe('Record ID this feed item belongs to'), | |
| /** Actor (who performed the action) */ | |
| actor: FeedActorSchema.describe('Who performed this action'), | |
| /** Content (for comments/notes) */ | |
| body: z.string().optional().describe('Rich text body (Markdown supported)'), | |
| /** @Mentions */ | |
| mentions: z.array(MentionSchema).optional().describe('Mentioned users/teams/records'), | |
| /** Field changes (for field_change type) */ | |
| changes: z.array(FieldChangeEntrySchema).optional().describe('Field-level changes'), | |
| /** Reactions */ | |
| reactions: z.array(ReactionSchema).optional().describe('Emoji reactions on this item'), | |
| /** Reply threading */ | |
| parentId: z.string().optional().describe('Parent feed item ID for threaded replies'), | |
| replyCount: z.number().int().min(0).default(0).describe('Number of replies'), | |
| /** Visibility */ | |
| visibility: FeedVisibility.default('public') | |
| .describe('Visibility: public (all users), internal (team only), private (author + mentioned)'), | |
| /** Timestamps */ | |
| createdAt: z.string().datetime().describe('Creation timestamp'), | |
| updatedAt: z.string().datetime().optional().describe('Last update timestamp'), | |
| editedAt: z.string().datetime().optional().describe('When comment was last edited'), | |
| isEdited: z.boolean().default(false).describe('Whether comment has been edited'), | |
| }); | |
| export const FeedItemSchema = z | |
| .object({ | |
| /** Unique identifier */ | |
| id: z.string().describe('Feed item ID'), | |
| /** Feed item type */ | |
| type: FeedItemType.describe('Activity type'), | |
| /** Target record reference */ | |
| object: z.string().describe('Object name (e.g., "account")'), | |
| recordId: z.string().describe('Record ID this feed item belongs to'), | |
| /** Actor (who performed the action) */ | |
| actor: FeedActorSchema.describe('Who performed this action'), | |
| /** Content (for comments/notes) */ | |
| body: z.string().optional().describe('Rich text body (Markdown supported)'), | |
| /** @Mentions */ | |
| mentions: z.array(MentionSchema).optional().describe('Mentioned users/teams/records'), | |
| /** Field changes (for field_change type) */ | |
| changes: z.array(FieldChangeEntrySchema).optional().describe('Field-level changes'), | |
| /** Reactions */ | |
| reactions: z.array(ReactionSchema).optional().describe('Emoji reactions on this item'), | |
| /** Reply threading */ | |
| parentId: z.string().optional().describe('Parent feed item ID for threaded replies'), | |
| replyCount: z.number().int().min(0).default(0).describe('Number of replies'), | |
| /** Visibility */ | |
| visibility: FeedVisibility.default('public') | |
| .describe('Visibility: public (all users), internal (team only), private (author + mentioned)'), | |
| /** Timestamps */ | |
| createdAt: z.string().datetime().describe('Creation timestamp'), | |
| updatedAt: z.string().datetime().optional().describe('Last update timestamp'), | |
| editedAt: z.string().datetime().optional().describe('When comment was last edited'), | |
| isEdited: z.boolean().default(false).describe('Whether comment has been edited'), | |
| }) | |
| .superRefine((data, ctx) => { | |
| if (data.isEdited && !data.editedAt) { | |
| ctx.addIssue({ | |
| code: z.ZodIssueCode.custom, | |
| path: ['editedAt'], | |
| message: 'editedAt is required when isEdited is true', | |
| }); | |
| } | |
| if (!data.isEdited && data.editedAt) { | |
| ctx.addIssue({ | |
| code: z.ZodIssueCode.custom, | |
| path: ['isEdited'], | |
| message: 'isEdited must be true when editedAt is set', | |
| }); | |
| } | |
| }); |
| export const FeedItemSchema = z.object({ | ||
| /** Unique identifier */ | ||
| id: z.string().describe('Feed item ID'), | ||
|
|
||
| /** Feed item type */ | ||
| type: FeedItemType.describe('Activity type'), | ||
|
|
||
| /** Target record reference */ | ||
| object: z.string().describe('Object name (e.g., "account")'), | ||
| recordId: z.string().describe('Record ID this feed item belongs to'), | ||
|
|
||
| /** Actor (who performed the action) */ | ||
| actor: FeedActorSchema.describe('Who performed this action'), | ||
|
|
||
| /** Content (for comments/notes) */ | ||
| body: z.string().optional().describe('Rich text body (Markdown supported)'), | ||
|
|
||
| /** @Mentions */ | ||
| mentions: z.array(MentionSchema).optional().describe('Mentioned users/teams/records'), | ||
|
|
||
| /** Field changes (for field_change type) */ | ||
| changes: z.array(FieldChangeEntrySchema).optional().describe('Field-level changes'), | ||
|
|
||
| /** Reactions */ | ||
| reactions: z.array(ReactionSchema).optional().describe('Emoji reactions on this item'), | ||
|
|
There was a problem hiding this comment.
FeedItemSchema is not type-discriminated, so it currently permits obviously invalid payloads (e.g., type: "comment" with no body, or type: "field_change" with no changes). Given the docstrings/examples, consider switching to z.discriminatedUnion('type', ...) or adding a superRefine that enforces required fields per type.
| export const NotificationChannel = z.enum([ | ||
| 'in_app', | ||
| 'email', | ||
| 'push', | ||
| 'slack', | ||
| ]); |
There was a problem hiding this comment.
The new NotificationChannel enum diverges from the existing System.NotificationChannelSchema (e.g. in_app vs in-app, and missing channels like sms, teams, webhook). This creates two similar-but-incompatible channel vocabularies. Consider reusing/importing NotificationChannelSchema or aligning the string literals to the existing system protocol.
| /** Events to subscribe to */ | ||
| events: z.array(SubscriptionEventType) | ||
| .default(['all']) | ||
| .describe('Event types to receive notifications for'), |
There was a problem hiding this comment.
RecordSubscriptionSchema.events defaults to ["all"], but the schema also allows mixing "all" with specific event types (e.g. ["all","comment"]), which is ambiguous. Consider constraining events so that either it is exactly ["all"] or it contains only specific event types (excluding "all").
| collapsible: z.boolean().default(true).describe('Whether the panel can be collapsed'), | ||
| /** Default collapsed state */ | ||
| defaultCollapsed: z.boolean().default(false).describe('Whether the panel starts collapsed'), |
There was a problem hiding this comment.
RecordChatterProps allows an inconsistent state where collapsible is false but defaultCollapsed can still be true. Consider a refinement so defaultCollapsed must be false when collapsible is disabled.
feeds: true/activities: truecapability switches existed on objects but had no backing data model.record:chatterwas mapped toEmptyProps. This PR fills those gaps with Airtable/Salesforce-style Feed, Mention, Reaction, and Subscription protocols.Data Protocol —
src/data/feed.zod.tsFeedItemType— 13 unified activity types:comment,field_change,task,event,email,call,note,file,record_create,record_delete,approval,sharing,systemFeedItemSchema— timeline entry with actor, body, mentions, field changes, reactions, threading (parentId/replyCount), visibility, edit trackingMentionSchema—user/team/recordtargets with character offset+length for rich text renderingFieldChangeEntrySchema— old/new values with display representationsReactionSchema— emoji reactions with user ID trackingFeedFilterMode—all,comments_only,changes_only,tasks_onlyData Protocol —
src/data/subscription.zod.tsRecordSubscriptionSchema— record-level notification subscription (bell icon pattern)SubscriptionEventType—comment/mention/field_change/task/approval/allNotificationChannel—in_app/email/push/slackUI Protocol —
component.zod.tsRecordActivityPropsenhanced with unified timeline support:typesnow accepts fullFeedItemTypeenum (was hardcoded to 5 activity types)filterMode,unifiedTimeline,showCommentInput,enableMentions,enableReactions,enableThreading,showSubscriptionToggleRecordChatterPropsreplacesEmptyProps:position:sidebar|inline|drawerwidth,collapsible,defaultCollapsedfeed: embeddedRecordActivityPropsfor full configuration delegationBackward Compatibility
Existing
types: ['task', 'event', 'email', 'call', 'note']usage (e.g., CRM example) remains valid — all are members of the expandedFeedItemTypeenum. Default values preserve prior behavior (limitchanged from 10→20 to match Airtable defaults).Tests
62 new tests across
feed.test.ts(35),subscription.test.ts(11),component.test.ts(16). Full suite: 5705 passing.Original prompt
This section details on the original issue you should resolve
<issue_title>协议增强:实现 Airtable 风格的 Comments & Activity Timeline(Feed/Chatter 协议标准)</issue_title>
<issue_description>### 背景
Airtable 等现代 SaaS 平台广泛采用了右侧 Activity Feed 面板,将字段变更、评论(支持@提及)、系统任务活动等融合为统一时间线,这为协同、审核、历史追踪带来极大便利。
当前 @objectstack/spec 协议已经有
feeds: true/activities: true/trackHistory: true等能力开关(Data Protocol)record:activity组件,但record:chatter未定义(空壳),也缺乏 Feed/Comment 数据结构目标
主要差距和需求
推荐实现步骤
src/data/feed.zod.ts,定义 FeedItemType、FeedItemSchema、MentionSchema、ReactionSchemacomponent.zod.ts,扩展RecordActivityProps/RecordChatterProps属性,并修正 ComponentPropsMapsrc/data/subscription.zod.ts(通知订阅能力,可选)相关参考
任务参考实现(可分多个 PR 完善)
test
🔍 一、截图分析:Airtable 的 Comments & Activity 功能拆解
从你提供的 Airtable 截图
可以看到:
REGION → Asia-Pacific,STATUS → Active🟢 二、当前协议已有的基础设施
A. Data Protocol — 能力开关(✅ 良好)
评估:
feeds: true和activities: true和trackHistory: true这三个开关已经存在,定义了启用的意图,但 没有对应��� Feed/Comment 数据模型协议。B. Field Protocol — 字段级变更追踪(✅ 存在)
评估: 字段级别的
trackFeedHistory可控制哪些字段的变更出现在 Feed 中(类似 Airtable 截图中只显示了 REGION, OPPORTUNITIES, STATUS 等关键字段变更)。C. UI Protocol — Activity 组件(🟡 骨架存在)
以及 ComponentPropsMap 中的注册:
D. Page Protocol — Lead Detail 示例用法(✅ 演示存在)