From c19131b9ac38e969c7d9c543774230f1681c7298 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 07:47:22 +0000 Subject: [PATCH 1/2] Initial plan From ef4a444f01ccd883a949b30a467f403a7d67e949 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 07:54:37 +0000 Subject: [PATCH 2/2] feat: implement Feed/Chatter protocol (FeedItem, Mention, Reaction, Subscription, 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> --- ROADMAP.md | 4 +- packages/spec/src/data/feed.test.ts | 337 ++++++++++++++++++++ packages/spec/src/data/feed.zod.ts | 166 ++++++++++ packages/spec/src/data/index.ts | 6 + packages/spec/src/data/subscription.test.ts | 103 ++++++ packages/spec/src/data/subscription.zod.ts | 60 ++++ packages/spec/src/ui/component.test.ts | 158 +++++++++ packages/spec/src/ui/component.zod.ts | 41 ++- 8 files changed, 870 insertions(+), 5 deletions(-) create mode 100644 packages/spec/src/data/feed.test.ts create mode 100644 packages/spec/src/data/feed.zod.ts create mode 100644 packages/spec/src/data/subscription.test.ts create mode 100644 packages/spec/src/data/subscription.zod.ts diff --git a/ROADMAP.md b/ROADMAP.md index 9f49df0622..8b1f560b91 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -146,9 +146,9 @@ The following renames are planned for packages that implement core service contr ### Deliverables — All Completed -- [x] **Data Protocol** — Object, Field (35+ types), Query, Filter, Validation, Hook, Datasource, Dataset, Analytics, Document, Storage Name Mapping (`tableName`/`columnName`) +- [x] **Data Protocol** — Object, Field (35+ types), Query, Filter, Validation, Hook, Datasource, Dataset, Analytics, Document, Storage Name Mapping (`tableName`/`columnName`), Feed & Activity Timeline (FeedItem, Comment, Mention, Reaction, FieldChange), Record Subscription (notification channels) - [x] **Driver Specifications** — Memory, PostgreSQL, MongoDB driver schemas + SQL/NoSQL abstractions -- [x] **UI Protocol** — View (List/Form/Kanban/Calendar/Gantt), App, Dashboard, Report, Action, Page (16 types), Chart, Widget, Theme, Animation, DnD, Touch, Keyboard, Responsive, Offline, Notification, i18n, Content Elements +- [x] **UI Protocol** — View (List/Form/Kanban/Calendar/Gantt), App, Dashboard, Report, Action, Page (16 types), Chart, Widget, Theme, Animation, DnD, Touch, Keyboard, Responsive, Offline, Notification, i18n, Content Elements, Enhanced Activity Timeline (`RecordActivityProps` unified timeline, `RecordChatterProps` sidebar/drawer) - [x] **System Protocol** — Manifest, Auth Config, Cache, Logging, Metrics, Tracing, Audit, Encryption, Masking, Migration, Tenant, Translation, Search Engine, HTTP Server, Worker, Job, Object Storage, Notification, Message Queue, Registry Config, Collaboration, Compliance, Change Management, Disaster Recovery, License, Security Context, Core Services, SystemObjectName/SystemFieldName Constants, StorageNameMapping Utilities - [x] **Automation Protocol** — Flow (autolaunched/screen/schedule), Workflow, State Machine, Trigger Registry, Approval, ETL, Sync, Webhook - [x] **AI Protocol** — Agent, Agent Action, Conversation, Cost, MCP, Model Registry, NLQ, Orchestration, Predictive, RAG Pipeline, Runtime Ops, Feedback Loop, DevOps Agent, Plugin Development diff --git a/packages/spec/src/data/feed.test.ts b/packages/spec/src/data/feed.test.ts new file mode 100644 index 0000000000..934ef97be8 --- /dev/null +++ b/packages/spec/src/data/feed.test.ts @@ -0,0 +1,337 @@ +import { describe, it, expect } from 'vitest'; +import { + FeedItemType, + MentionSchema, + FieldChangeEntrySchema, + ReactionSchema, + FeedActorSchema, + FeedVisibility, + FeedItemSchema, + FeedFilterMode, + type FeedItem, + type Mention, + type FieldChangeEntry, + type Reaction, + type FeedActor, +} from './feed.zod'; + +describe('FeedItemType', () => { + it('should accept all valid feed item types', () => { + const types = [ + 'comment', 'field_change', 'task', 'event', 'email', 'call', + 'note', 'file', 'record_create', 'record_delete', 'approval', + 'sharing', 'system', + ]; + types.forEach(type => { + expect(() => FeedItemType.parse(type)).not.toThrow(); + }); + }); + + it('should reject invalid types', () => { + expect(() => FeedItemType.parse('unknown')).toThrow(); + expect(() => FeedItemType.parse('')).toThrow(); + }); +}); + +describe('MentionSchema', () => { + it('should accept a valid user mention', () => { + const mention: Mention = { + type: 'user', + id: 'user_123', + name: 'Jane Doe', + offset: 17, + length: 9, + }; + const result = MentionSchema.parse(mention); + expect(result.type).toBe('user'); + expect(result.id).toBe('user_123'); + expect(result.name).toBe('Jane Doe'); + expect(result.offset).toBe(17); + expect(result.length).toBe(9); + }); + + it('should accept team and record mention types', () => { + expect(() => MentionSchema.parse({ type: 'team', id: 'team_1', name: 'Engineering', offset: 0, length: 12 })).not.toThrow(); + expect(() => MentionSchema.parse({ type: 'record', id: 'rec_1', name: 'Acme Corp', offset: 5, length: 9 })).not.toThrow(); + }); + + it('should reject invalid mention type', () => { + expect(() => MentionSchema.parse({ type: 'group', id: '1', name: 'X', offset: 0, length: 1 })).toThrow(); + }); + + it('should reject negative offset', () => { + expect(() => MentionSchema.parse({ type: 'user', id: '1', name: 'X', offset: -1, length: 1 })).toThrow(); + }); + + it('should reject zero length', () => { + expect(() => MentionSchema.parse({ type: 'user', id: '1', name: 'X', offset: 0, length: 0 })).toThrow(); + }); + + it('should reject missing required fields', () => { + expect(() => MentionSchema.parse({})).toThrow(); + expect(() => MentionSchema.parse({ type: 'user' })).toThrow(); + }); +}); + +describe('FieldChangeEntrySchema', () => { + it('should accept minimal field change', () => { + const result = FieldChangeEntrySchema.parse({ field: 'status' }); + expect(result.field).toBe('status'); + expect(result.oldValue).toBeUndefined(); + expect(result.newValue).toBeUndefined(); + }); + + it('should accept full field change with display values', () => { + const change: FieldChangeEntry = { + field: 'region', + fieldLabel: 'Region', + oldValue: null, + newValue: 'asia_pacific', + oldDisplayValue: '', + newDisplayValue: 'Asia-Pacific', + }; + const result = FieldChangeEntrySchema.parse(change); + expect(result.fieldLabel).toBe('Region'); + expect(result.newDisplayValue).toBe('Asia-Pacific'); + }); + + it('should reject without field name', () => { + expect(() => FieldChangeEntrySchema.parse({})).toThrow(); + }); +}); + +describe('ReactionSchema', () => { + it('should accept valid reaction', () => { + const reaction: Reaction = { + emoji: '👍', + userIds: ['user_1', 'user_2'], + count: 2, + }; + const result = ReactionSchema.parse(reaction); + expect(result.emoji).toBe('👍'); + expect(result.userIds).toHaveLength(2); + expect(result.count).toBe(2); + }); + + it('should reject count less than 1', () => { + expect(() => ReactionSchema.parse({ emoji: '👍', userIds: [], count: 0 })).toThrow(); + }); + + it('should reject missing required fields', () => { + expect(() => ReactionSchema.parse({})).toThrow(); + expect(() => ReactionSchema.parse({ emoji: '👍' })).toThrow(); + }); +}); + +describe('FeedActorSchema', () => { + it('should accept user actor', () => { + const actor: FeedActor = { + type: 'user', + id: 'user_456', + name: 'John Smith', + }; + const result = FeedActorSchema.parse(actor); + expect(result.type).toBe('user'); + expect(result.name).toBe('John Smith'); + }); + + it('should accept system actor with source', () => { + const result = FeedActorSchema.parse({ + type: 'system', + id: 'sys_001', + source: 'Omni', + }); + expect(result.type).toBe('system'); + expect(result.source).toBe('Omni'); + }); + + it('should accept all actor types', () => { + const types = ['user', 'system', 'service', 'automation']; + types.forEach(type => { + expect(() => FeedActorSchema.parse({ type, id: 'test_1' })).not.toThrow(); + }); + }); + + it('should accept actor with avatarUrl', () => { + const result = FeedActorSchema.parse({ + type: 'user', + id: 'user_1', + avatarUrl: 'https://example.com/avatar.png', + }); + expect(result.avatarUrl).toBe('https://example.com/avatar.png'); + }); + + it('should reject invalid actor type', () => { + expect(() => FeedActorSchema.parse({ type: 'bot', id: '1' })).toThrow(); + }); +}); + +describe('FeedVisibility', () => { + it('should accept valid visibility levels', () => { + ['public', 'internal', 'private'].forEach(v => { + expect(() => FeedVisibility.parse(v)).not.toThrow(); + }); + }); + + it('should reject invalid visibility', () => { + expect(() => FeedVisibility.parse('secret')).toThrow(); + }); +}); + +describe('FeedFilterMode', () => { + it('should accept valid filter modes', () => { + ['all', 'comments_only', 'changes_only', 'tasks_only'].forEach(mode => { + expect(() => FeedFilterMode.parse(mode)).not.toThrow(); + }); + }); + + it('should reject invalid filter mode', () => { + expect(() => FeedFilterMode.parse('custom')).toThrow(); + }); +}); + +describe('FeedItemSchema', () => { + const minimalComment: FeedItem = { + id: 'feed_001', + type: 'comment', + object: 'account', + recordId: 'rec_123', + actor: { type: 'user', id: 'user_456', name: 'John Smith' }, + body: 'Great progress on this deal!', + createdAt: '2026-01-15T10:30:00Z', + }; + + it('should accept a minimal comment feed item', () => { + const result = FeedItemSchema.parse(minimalComment); + expect(result.id).toBe('feed_001'); + expect(result.type).toBe('comment'); + expect(result.object).toBe('account'); + expect(result.recordId).toBe('rec_123'); + expect(result.body).toBe('Great progress on this deal!'); + expect(result.replyCount).toBe(0); + expect(result.visibility).toBe('public'); + expect(result.isEdited).toBe(false); + }); + + it('should accept a comment with mentions', () => { + const result = FeedItemSchema.parse({ + ...minimalComment, + mentions: [ + { type: 'user', id: 'user_789', name: 'Jane Doe', offset: 17, length: 9 }, + ], + }); + expect(result.mentions).toHaveLength(1); + expect(result.mentions![0].name).toBe('Jane Doe'); + }); + + it('should accept a field_change feed item with changes', () => { + const fieldChange: FeedItem = { + id: 'feed_002', + type: 'field_change', + object: 'account', + recordId: 'rec_123', + actor: { type: 'user', id: 'user_456', name: 'John Smith' }, + changes: [ + { field: 'status', oldDisplayValue: 'New', newDisplayValue: 'Active' }, + { field: 'region', oldDisplayValue: '', newDisplayValue: 'Asia-Pacific' }, + ], + createdAt: '2026-01-15T10:25:00Z', + }; + const result = FeedItemSchema.parse(fieldChange); + expect(result.type).toBe('field_change'); + expect(result.changes).toHaveLength(2); + expect(result.changes![0].field).toBe('status'); + }); + + it('should accept a threaded reply', () => { + const result = FeedItemSchema.parse({ + ...minimalComment, + id: 'feed_003', + parentId: 'feed_001', + }); + expect(result.parentId).toBe('feed_001'); + }); + + it('should accept edited comment', () => { + const result = FeedItemSchema.parse({ + ...minimalComment, + isEdited: true, + editedAt: '2026-01-15T11:00:00Z', + }); + expect(result.isEdited).toBe(true); + expect(result.editedAt).toBe('2026-01-15T11:00:00Z'); + }); + + it('should accept reactions on a feed item', () => { + const result = FeedItemSchema.parse({ + ...minimalComment, + reactions: [ + { emoji: '👍', userIds: ['user_789'], count: 1 }, + { emoji: '❤️', userIds: ['user_101', 'user_102'], count: 2 }, + ], + }); + expect(result.reactions).toHaveLength(2); + }); + + it('should accept internal visibility', () => { + const result = FeedItemSchema.parse({ + ...minimalComment, + visibility: 'internal', + }); + expect(result.visibility).toBe('internal'); + }); + + it('should accept system actor with source', () => { + const result = FeedItemSchema.parse({ + ...minimalComment, + actor: { type: 'system', id: 'sys_001', source: 'API' }, + }); + expect(result.actor.type).toBe('system'); + expect(result.actor.source).toBe('API'); + }); + + it('should accept all feed item types', () => { + const types = [ + 'comment', 'field_change', 'task', 'event', 'email', 'call', + 'note', 'file', 'record_create', 'record_delete', 'approval', + 'sharing', 'system', + ]; + types.forEach(type => { + expect(() => FeedItemSchema.parse({ + id: `feed_${type}`, + type, + object: 'account', + recordId: 'rec_1', + actor: { type: 'user', id: 'user_1' }, + createdAt: '2026-01-15T10:00:00Z', + })).not.toThrow(); + }); + }); + + it('should apply default values', () => { + const result = FeedItemSchema.parse({ + id: 'feed_def', + type: 'note', + object: 'lead', + recordId: 'rec_1', + actor: { type: 'user', id: 'user_1' }, + createdAt: '2026-01-15T10:00:00Z', + }); + expect(result.replyCount).toBe(0); + expect(result.visibility).toBe('public'); + expect(result.isEdited).toBe(false); + }); + + it('should reject without required fields', () => { + expect(() => FeedItemSchema.parse({})).toThrow(); + expect(() => FeedItemSchema.parse({ id: 'x' })).toThrow(); + expect(() => FeedItemSchema.parse({ id: 'x', type: 'comment' })).toThrow(); + }); + + it('should reject invalid datetime format', () => { + expect(() => FeedItemSchema.parse({ + ...minimalComment, + createdAt: 'not-a-date', + })).toThrow(); + }); +}); diff --git a/packages/spec/src/data/feed.zod.ts b/packages/spec/src/data/feed.zod.ts new file mode 100644 index 0000000000..aabe55ead9 --- /dev/null +++ b/packages/spec/src/data/feed.zod.ts @@ -0,0 +1,166 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; + +/** + * Feed Item Type + * Unified activity types for the record timeline. + * Covers comments, field changes, tasks, events, and system activities. + */ +export const FeedItemType = z.enum([ + 'comment', + 'field_change', + 'task', + 'event', + 'email', + 'call', + 'note', + 'file', + 'record_create', + 'record_delete', + 'approval', + 'sharing', + 'system', +]); +export type FeedItemType = z.infer; + +/** + * Mention Schema + * Represents an @mention within comment body text. + */ +export const MentionSchema = z.object({ + type: z.enum(['user', 'team', 'record']).describe('Mention target type'), + id: z.string().describe('Target ID'), + name: z.string().describe('Display name for rendering'), + offset: z.number().int().min(0).describe('Character offset in body text'), + length: z.number().int().min(1).describe('Length of mention token in body text'), +}); +export type Mention = z.infer; + +/** + * Field Change Entry Schema + * Represents a single field-level change within a field_change feed item. + */ +export const FieldChangeEntrySchema = z.object({ + field: z.string().describe('Field machine name'), + fieldLabel: z.string().optional().describe('Field display label'), + oldValue: z.unknown().optional().describe('Previous value'), + newValue: z.unknown().optional().describe('New value'), + oldDisplayValue: z.string().optional().describe('Human-readable old value'), + newDisplayValue: z.string().optional().describe('Human-readable new value'), +}); +export type FieldChangeEntry = z.infer; + +/** + * Reaction Schema + * Represents an emoji reaction on a feed item. + */ +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 type Reaction = z.infer; + +/** + * Feed Actor Schema + * Represents the actor who performed the action. + */ +export const FeedActorSchema = z.object({ + type: z.enum(['user', 'system', 'service', 'automation']).describe('Actor type'), + id: z.string().describe('Actor ID'), + name: z.string().optional().describe('Actor display name'), + avatarUrl: z.string().url().optional().describe('Actor avatar URL'), + source: z.string().optional().describe('Source application (e.g., "Omni", "API", "Studio")'), +}); +export type FeedActor = z.infer; + +/** + * Feed Item Visibility + */ +export const FeedVisibility = z.enum(['public', 'internal', 'private']); +export type FeedVisibility = z.infer; + +/** + * Feed Item Schema + * A single entry in the unified activity timeline. + * + * @example Comment + * { + * id: 'feed_001', + * type: 'comment', + * object: 'account', + * recordId: 'rec_123', + * body: 'Great progress! @jane.doe can you follow up?', + * mentions: [{ type: 'user', id: 'user_123', name: 'Jane Doe', offset: 17, length: 9 }], + * actor: { type: 'user', id: 'user_456', name: 'John Smith' }, + * createdAt: '2026-01-15T10:30:00Z', + * } + * + * @example Field Change + * { + * id: 'feed_002', + * type: 'field_change', + * object: 'account', + * recordId: 'rec_123', + * changes: [ + * { field: 'status', oldDisplayValue: 'New', newDisplayValue: 'Active' }, + * { field: 'region', oldDisplayValue: '', newDisplayValue: 'Asia-Pacific' }, + * ], + * actor: { type: 'user', id: 'user_456', name: 'John Smith' }, + * createdAt: '2026-01-15T10:25:00Z', + * } + */ +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 type FeedItem = z.infer; + +/** + * Feed Filter Mode + * Controls which feed item types to display in the timeline. + */ +export const FeedFilterMode = z.enum([ + 'all', + 'comments_only', + 'changes_only', + 'tasks_only', +]); +export type FeedFilterMode = z.infer; diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index f033f7125b..ca4f771db4 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -24,3 +24,9 @@ export * from './datasource.zod'; // Analytics Protocol (Semantic Layer) export * from './analytics.zod'; +// Feed & Activity Protocol +export * from './feed.zod'; + +// Subscription Protocol +export * from './subscription.zod'; + diff --git a/packages/spec/src/data/subscription.test.ts b/packages/spec/src/data/subscription.test.ts new file mode 100644 index 0000000000..05f0dfc2e2 --- /dev/null +++ b/packages/spec/src/data/subscription.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; +import { + SubscriptionEventType, + NotificationChannel, + RecordSubscriptionSchema, + type RecordSubscription, +} from './subscription.zod'; + +describe('SubscriptionEventType', () => { + it('should accept all valid event types', () => { + const types = ['comment', 'mention', 'field_change', 'task', 'approval', 'all']; + types.forEach(type => { + expect(() => SubscriptionEventType.parse(type)).not.toThrow(); + }); + }); + + it('should reject invalid event type', () => { + expect(() => SubscriptionEventType.parse('unknown')).toThrow(); + expect(() => SubscriptionEventType.parse('')).toThrow(); + }); +}); + +describe('NotificationChannel', () => { + it('should accept all valid channels', () => { + const channels = ['in_app', 'email', 'push', 'slack']; + channels.forEach(channel => { + expect(() => NotificationChannel.parse(channel)).not.toThrow(); + }); + }); + + it('should reject invalid channel', () => { + expect(() => NotificationChannel.parse('sms')).toThrow(); + }); +}); + +describe('RecordSubscriptionSchema', () => { + const minimalSubscription: RecordSubscription = { + object: 'account', + recordId: 'rec_123', + userId: 'user_456', + createdAt: '2026-01-15T10:00:00Z', + }; + + it('should accept minimal subscription with defaults', () => { + const result = RecordSubscriptionSchema.parse(minimalSubscription); + expect(result.object).toBe('account'); + expect(result.recordId).toBe('rec_123'); + expect(result.userId).toBe('user_456'); + expect(result.events).toEqual(['all']); + expect(result.channels).toEqual(['in_app']); + expect(result.active).toBe(true); + }); + + it('should accept full subscription', () => { + const full: RecordSubscription = { + object: 'opportunity', + recordId: 'rec_789', + userId: 'user_101', + events: ['comment', 'field_change'], + channels: ['in_app', 'email'], + active: true, + createdAt: '2026-01-15T10:00:00Z', + }; + const result = RecordSubscriptionSchema.parse(full); + expect(result.events).toEqual(['comment', 'field_change']); + expect(result.channels).toEqual(['in_app', 'email']); + }); + + it('should accept inactive subscription', () => { + const result = RecordSubscriptionSchema.parse({ + ...minimalSubscription, + active: false, + }); + expect(result.active).toBe(false); + }); + + it('should reject without required fields', () => { + expect(() => RecordSubscriptionSchema.parse({})).toThrow(); + expect(() => RecordSubscriptionSchema.parse({ object: 'account' })).toThrow(); + expect(() => RecordSubscriptionSchema.parse({ object: 'account', recordId: 'rec_1' })).toThrow(); + }); + + it('should reject invalid datetime format', () => { + expect(() => RecordSubscriptionSchema.parse({ + ...minimalSubscription, + createdAt: 'not-a-date', + })).toThrow(); + }); + + it('should reject invalid event types in array', () => { + expect(() => RecordSubscriptionSchema.parse({ + ...minimalSubscription, + events: ['invalid_event'], + })).toThrow(); + }); + + it('should reject invalid notification channels', () => { + expect(() => RecordSubscriptionSchema.parse({ + ...minimalSubscription, + channels: ['sms'], + })).toThrow(); + }); +}); diff --git a/packages/spec/src/data/subscription.zod.ts b/packages/spec/src/data/subscription.zod.ts new file mode 100644 index 0000000000..6903d9fda0 --- /dev/null +++ b/packages/spec/src/data/subscription.zod.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; + +/** + * Subscription Event Type + * Event types that can be subscribed to for record-level notifications. + */ +export const SubscriptionEventType = z.enum([ + 'comment', + 'mention', + 'field_change', + 'task', + 'approval', + 'all', +]); +export type SubscriptionEventType = z.infer; + +/** + * Notification Channel + * Delivery channels for record subscription notifications. + */ +export const NotificationChannel = z.enum([ + 'in_app', + 'email', + 'push', + 'slack', +]); +export type NotificationChannel = z.infer; + +/** + * Record Subscription Schema + * Defines a user's subscription to record-level notifications. + * Enables Airtable-style bell icon for record change notifications. + */ +export const RecordSubscriptionSchema = z.object({ + /** Target */ + object: z.string().describe('Object name'), + recordId: z.string().describe('Record ID'), + + /** Subscriber */ + userId: z.string().describe('Subscribing user ID'), + + /** Events to subscribe to */ + events: z.array(SubscriptionEventType) + .default(['all']) + .describe('Event types to receive notifications for'), + + /** Notification channels */ + channels: z.array(NotificationChannel) + .default(['in_app']) + .describe('Notification delivery channels'), + + /** Active */ + active: z.boolean().default(true).describe('Whether the subscription is active'), + + /** Timestamps */ + createdAt: z.string().datetime().describe('Subscription creation timestamp'), +}); +export type RecordSubscription = z.infer; diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 18c0851f15..2a8a197193 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -6,6 +6,8 @@ import { RecordDetailsProps, RecordRelatedListProps, RecordHighlightsProps, + RecordActivityProps, + RecordChatterProps, ComponentPropsMap, ElementTextPropsSchema, ElementNumberPropsSchema, @@ -600,3 +602,159 @@ describe('ComponentPropsMap interactive elements', () => { expect(result.object).toBe('account'); }); }); + +// --------------------------------------------------------------------------- +// Enhanced RecordActivityProps (Unified Timeline) +// --------------------------------------------------------------------------- +describe('RecordActivityProps (enhanced)', () => { + it('should accept empty with defaults', () => { + const result = RecordActivityProps.parse({}); + expect(result.filterMode).toBe('all'); + expect(result.showFilterToggle).toBe(true); + expect(result.limit).toBe(20); + expect(result.showCompleted).toBe(false); + expect(result.unifiedTimeline).toBe(true); + expect(result.showCommentInput).toBe(true); + expect(result.enableMentions).toBe(true); + expect(result.enableReactions).toBe(false); + expect(result.enableThreading).toBe(false); + expect(result.showSubscriptionToggle).toBe(true); + }); + + it('should accept unified feed item types including comment and field_change', () => { + const result = RecordActivityProps.parse({ + types: ['comment', 'field_change', 'task', 'email'], + }); + expect(result.types).toEqual(['comment', 'field_change', 'task', 'email']); + }); + + it('should accept custom filter mode', () => { + const result = RecordActivityProps.parse({ filterMode: 'comments_only' }); + expect(result.filterMode).toBe('comments_only'); + }); + + it('should accept all filter modes', () => { + const modes = ['all', 'comments_only', 'changes_only', 'tasks_only'] as const; + modes.forEach(mode => { + expect(() => RecordActivityProps.parse({ filterMode: mode })).not.toThrow(); + }); + }); + + it('should accept full configuration', () => { + const result = RecordActivityProps.parse({ + types: ['comment', 'field_change'], + filterMode: 'all', + showFilterToggle: true, + limit: 50, + showCompleted: true, + unifiedTimeline: true, + showCommentInput: true, + enableMentions: true, + enableReactions: true, + enableThreading: true, + showSubscriptionToggle: false, + }); + expect(result.enableReactions).toBe(true); + expect(result.enableThreading).toBe(true); + expect(result.showSubscriptionToggle).toBe(false); + expect(result.limit).toBe(50); + }); + + it('should reject invalid feed item type', () => { + expect(() => RecordActivityProps.parse({ types: ['invalid_type'] })).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// RecordChatterProps (replaces EmptyProps) +// --------------------------------------------------------------------------- +describe('RecordChatterProps', () => { + it('should accept empty with defaults', () => { + const result = RecordChatterProps.parse({}); + expect(result.position).toBe('sidebar'); + expect(result.collapsible).toBe(true); + expect(result.defaultCollapsed).toBe(false); + expect(result.width).toBeUndefined(); + expect(result.feed).toBeUndefined(); + }); + + it('should accept sidebar position with width', () => { + const result = RecordChatterProps.parse({ + position: 'sidebar', + width: '350px', + }); + expect(result.position).toBe('sidebar'); + expect(result.width).toBe('350px'); + }); + + it('should accept numeric width', () => { + const result = RecordChatterProps.parse({ width: 400 }); + expect(result.width).toBe(400); + }); + + it('should accept all position modes', () => { + const positions = ['sidebar', 'inline', 'drawer'] as const; + positions.forEach(position => { + expect(() => RecordChatterProps.parse({ position })).not.toThrow(); + }); + }); + + it('should accept collapsed state', () => { + const result = RecordChatterProps.parse({ + collapsible: true, + defaultCollapsed: true, + }); + expect(result.defaultCollapsed).toBe(true); + }); + + it('should accept embedded feed configuration', () => { + const result = RecordChatterProps.parse({ + position: 'sidebar', + width: '30%', + feed: { + types: ['comment', 'field_change'], + filterMode: 'all', + limit: 30, + enableMentions: true, + enableReactions: true, + }, + }); + expect(result.feed).toBeDefined(); + expect(result.feed!.types).toEqual(['comment', 'field_change']); + expect(result.feed!.limit).toBe(30); + expect(result.feed!.enableReactions).toBe(true); + }); + + it('should reject invalid position', () => { + expect(() => RecordChatterProps.parse({ position: 'modal' })).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// ComponentPropsMap — record:chatter is no longer empty +// --------------------------------------------------------------------------- +describe('ComponentPropsMap record:chatter', () => { + it('should parse record:chatter with defaults', () => { + const result = ComponentPropsMap['record:chatter'].parse({}); + expect(result.position).toBe('sidebar'); + expect(result.collapsible).toBe(true); + }); + + it('should parse record:chatter with feed config', () => { + const result = ComponentPropsMap['record:chatter'].parse({ + position: 'drawer', + feed: { filterMode: 'comments_only' }, + }); + expect(result.position).toBe('drawer'); + expect(result.feed!.filterMode).toBe('comments_only'); + }); + + it('should parse record:activity with unified types', () => { + const result = ComponentPropsMap['record:activity'].parse({ + types: ['comment', 'field_change', 'task'], + unifiedTimeline: true, + }); + expect(result.types).toEqual(['comment', 'field_change', 'task']); + expect(result.unifiedTimeline).toBe(true); + }); +}); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 2abf71c62a..f7b6b44e2a 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; +import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; /** * Empty Properties Schema @@ -91,9 +92,43 @@ export const RecordHighlightsProps = z.object({ }); export const RecordActivityProps = z.object({ - types: z.array(z.enum(['task', 'event', 'email', 'call', 'note'])).optional().describe('Activity types to display'), - limit: z.number().int().positive().default(10).describe('Number of activities to show'), + /** Activity types to display (unified enum including comment, field_change, etc.) */ + types: z.array(FeedItemType).optional().describe('Feed item types to show (default: all)'), + /** Default filter mode (Airtable-style dropdown) */ + filterMode: FeedFilterMode.default('all').describe('Default activity filter'), + /** Allow user to switch filter modes */ + showFilterToggle: z.boolean().default(true).describe('Show filter dropdown in panel header'), + /** Pagination */ + limit: z.number().int().positive().default(20).describe('Number of items to load per page'), + /** Show completed activities */ showCompleted: z.boolean().default(false).describe('Include completed activities'), + /** Merge field_change + comment in a unified timeline */ + unifiedTimeline: z.boolean().default(true).describe('Mix field changes and comments in one timeline (Airtable style)'), + /** Show the comment input box at the bottom */ + showCommentInput: z.boolean().default(true).describe('Show "Leave a comment" input at the bottom'), + /** Enable @mentions in comments */ + enableMentions: z.boolean().default(true).describe('Enable @mentions in comments'), + /** Enable emoji reactions */ + enableReactions: z.boolean().default(false).describe('Enable emoji reactions on feed items'), + /** Enable threaded replies */ + enableThreading: z.boolean().default(false).describe('Enable threaded replies on comments'), + /** Show notification subscription toggle (bell icon) */ + showSubscriptionToggle: z.boolean().default(true).describe('Show bell icon for record-level notification subscription'), + /** ARIA accessibility */ + aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), +}); + +export const RecordChatterProps = z.object({ + /** Panel position */ + position: z.enum(['sidebar', 'inline', 'drawer']).default('sidebar').describe('Where to render the chatter panel'), + /** Panel width (for sidebar/drawer) */ + width: z.union([z.string(), z.number()]).optional().describe('Panel width (e.g., "350px", "30%")'), + /** Collapsible */ + 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'), + /** Feed configuration (delegates to RecordActivityProps) */ + feed: RecordActivityProps.optional().describe('Embedded activity feed configuration'), /** ARIA accessibility */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), }); @@ -241,7 +276,7 @@ export const ComponentPropsMap = { 'record:related_list': RecordRelatedListProps, 'record:highlights': RecordHighlightsProps, 'record:activity': RecordActivityProps, - 'record:chatter': EmptyProps, + 'record:chatter': RecordChatterProps, 'record:path': RecordPathProps, // Navigation