Skip to content

Commit 8fe674f

Browse files
CopilotCopilot
andcommitted
feat: add L2 features - demo activities, notification filters, conflict resolution, conditional triggers
- Add DEMO_ACTIVITIES data to AppHeader and pass to ActivityFeed - Add notification preference filter toggles in ActivityFeed sheet - Wire useConflictResolution into ObjectView reconnection flow - Add structured conditional trigger fields to AutomationBuilder Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1a8f3e7 commit 8fe674f

4 files changed

Lines changed: 116 additions & 8 deletions

File tree

apps/console/src/components/ActivityFeed.tsx

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@
1010
import { useState } from 'react';
1111
import {
1212
Button,
13+
Badge,
1314
Sheet,
1415
SheetContent,
1516
SheetHeader,
1617
SheetTitle,
1718
SheetTrigger,
1819
} from '@object-ui/components';
19-
import { Bell, Plus, Pencil, Trash2, MessageSquare } from 'lucide-react';
20+
import { Bell, Plus, Pencil, Trash2, MessageSquare, Filter } from 'lucide-react';
2021

2122
export interface ActivityItem {
2223
id: string;
@@ -59,6 +60,19 @@ function formatRelativeTime(iso: string): string {
5960

6061
export function ActivityFeed({ activities = [], className }: ActivityFeedProps) {
6162
const [open, setOpen] = useState(false);
63+
const [showFilters, setShowFilters] = useState(false);
64+
const [notificationPreferences, setNotificationPreferences] = useState<Record<ActivityItem['type'], boolean>>({
65+
create: true,
66+
update: true,
67+
delete: true,
68+
comment: true,
69+
});
70+
71+
const togglePreference = (type: ActivityItem['type']) => {
72+
setNotificationPreferences(prev => ({ ...prev, [type]: !prev[type] }));
73+
};
74+
75+
const filteredActivities = activities.filter(a => notificationPreferences[a.type]);
6276

6377
return (
6478
<Sheet open={open} onOpenChange={setOpen}>
@@ -80,17 +94,48 @@ export function ActivityFeed({ activities = [], className }: ActivityFeedProps)
8094

8195
<SheetContent side="right" className="w-80 sm:w-96">
8296
<SheetHeader>
83-
<SheetTitle>Recent Activity</SheetTitle>
97+
<SheetTitle className="flex items-center justify-between">
98+
Recent Activity
99+
<Button
100+
variant={showFilters ? 'secondary' : 'ghost'}
101+
size="sm"
102+
className="h-7 px-2"
103+
onClick={() => setShowFilters(!showFilters)}
104+
>
105+
<Filter className="h-3.5 w-3.5 mr-1" />
106+
Filter
107+
</Button>
108+
</SheetTitle>
84109
</SheetHeader>
85110

86-
{activities.length === 0 ? (
111+
{showFilters && (
112+
<div className="flex flex-wrap gap-1.5 mt-3 px-1">
113+
{(Object.keys(typeConfig) as ActivityItem['type'][]).map(type => {
114+
const { icon: Icon, color } = typeConfig[type];
115+
const active = notificationPreferences[type];
116+
return (
117+
<Badge
118+
key={type}
119+
variant={active ? 'default' : 'outline'}
120+
className="cursor-pointer select-none gap-1 capitalize"
121+
onClick={() => togglePreference(type)}
122+
>
123+
<Icon className={`h-3 w-3 ${active ? '' : color}`} />
124+
{type}
125+
</Badge>
126+
);
127+
})}
128+
</div>
129+
)}
130+
131+
{filteredActivities.length === 0 ? (
87132
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
88133
<Bell className="h-8 w-8 opacity-40" />
89134
<p className="text-sm">No recent activity</p>
90135
</div>
91136
) : (
92137
<ul className="mt-4 space-y-1 overflow-y-auto max-h-[calc(100vh-8rem)]">
93-
{activities.map((item) => {
138+
{filteredActivities.map((item) => {
94139
const { icon: Icon, color } = typeConfig[item.type];
95140
return (
96141
<li

apps/console/src/components/AppHeader.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { PresenceAvatars, type PresenceUser } from '@object-ui/collaboration';
3131
import { ModeToggle } from './mode-toggle';
3232
import { LocaleSwitcher } from './LocaleSwitcher';
3333
import { ConnectionStatus } from './ConnectionStatus';
34-
import { ActivityFeed } from './ActivityFeed';
34+
import { ActivityFeed, type ActivityItem } from './ActivityFeed';
3535
import type { ConnectionState } from '../dataSource';
3636

3737
/** Convert a slug like "crm_dashboard" or "audit-log" to "Crm Dashboard" / "Audit Log" */
@@ -48,6 +48,15 @@ const MOCK_PRESENCE_USERS: PresenceUser[] = [
4848
{ userId: 'u3', userName: 'Carol Li', color: '#e74c3c', status: 'active', lastActivity: new Date().toISOString() },
4949
];
5050

51+
// Demo activity items for local/mock mode
52+
const DEMO_ACTIVITIES: ActivityItem[] = [
53+
{ id: 'a1', type: 'create', objectName: 'Contact', recordId: 'c-101', user: 'Alice Chen', description: 'Created new contact "Acme Corp"', timestamp: new Date(Date.now() - 2 * 60 * 1000).toISOString() },
54+
{ id: 'a2', type: 'update', objectName: 'Deal', recordId: 'd-42', user: 'Bob Smith', description: 'Updated deal stage to "Negotiation"', timestamp: new Date(Date.now() - 15 * 60 * 1000).toISOString() },
55+
{ id: 'a3', type: 'comment', objectName: 'Task', recordId: 't-88', user: 'Carol Li', description: 'Commented on task "Q4 Review"', timestamp: new Date(Date.now() - 45 * 60 * 1000).toISOString() },
56+
{ id: 'a4', type: 'delete', objectName: 'Lead', recordId: 'l-7', user: 'Alice Chen', description: 'Deleted duplicate lead "Test Lead"', timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString() },
57+
{ id: 'a5', type: 'update', objectName: 'Contact', recordId: 'c-55', user: 'Bob Smith', description: 'Updated email for "Jane Doe"', timestamp: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString() },
58+
];
59+
5160
export function AppHeader({ appName, objects, connectionState, presenceUsers }: { appName: string, objects: any[], connectionState?: ConnectionState, presenceUsers?: PresenceUser[] }) {
5261
const location = useLocation();
5362
const params = useParams();
@@ -218,7 +227,7 @@ export function AppHeader({ appName, objects, connectionState, presenceUsers }:
218227

219228
{/* Activity Feed */}
220229
<div className="hidden sm:flex shrink-0 relative">
221-
<ActivityFeed />
230+
<ActivityFeed activities={DEMO_ACTIVITIES} />
222231
</div>
223232

224233
{/* Help */}

apps/console/src/components/ObjectView.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataI
2626
import { useObjectActions } from '../hooks/useObjectActions';
2727
import { useObjectTranslation } from '@object-ui/i18n';
2828
import { usePermissions } from '@object-ui/permissions';
29-
import { useRealtimeSubscription } from '@object-ui/collaboration';
29+
import { useRealtimeSubscription, useConflictResolution } from '@object-ui/collaboration';
3030

3131
/** Map view types to Lucide icons (Airtable-style) */
3232
const VIEW_TYPE_ICONS: Record<string, ComponentType<{ className?: string }>> = {
@@ -122,11 +122,19 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
122122
channel: `object:${objectDef.name}`,
123123
});
124124

125+
// Conflict resolution: detect and queue conflicts on reconnection
126+
const conflictUserId = objectDef.name ? `user-${objectDef.name}` : 'current-user';
127+
const { hasConflicts, resolveAllConflicts } = useConflictResolution(conflictUserId);
128+
125129
useEffect(() => {
126130
if (realtimeMessage) {
131+
// On reconnection data change, auto-resolve with server-wins strategy
132+
if (hasConflicts) {
133+
resolveAllConflicts('remote');
134+
}
127135
setRefreshKey(k => k + 1);
128136
}
129-
}, [realtimeMessage]);
137+
}, [realtimeMessage, hasConflicts, resolveAllConflicts]);
130138

131139
// Drawer Logic
132140
const drawerRecordId = searchParams.get('recordId');

packages/plugin-workflow/src/AutomationBuilder.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ export interface TriggerConfig {
2020
fieldName?: string;
2121
schedule?: string;
2222
condition?: string;
23+
conditionField?: string;
24+
conditionOperator?: 'equals' | 'not_equals' | 'contains' | 'greater_than' | 'less_than';
25+
conditionValue?: string;
2326
}
2427

2528
export interface ActionConfig {
@@ -62,6 +65,14 @@ const ACTION_ICONS: Record<ActionConfig['type'], React.ReactNode> = {
6265
webhook: <Globe className="h-4 w-4" />, notification: <Bell className="h-4 w-4" />,
6366
};
6467

68+
const CONDITION_OPERATORS: Record<NonNullable<TriggerConfig['conditionOperator']>, string> = {
69+
equals: 'Equals',
70+
not_equals: 'Not Equals',
71+
contains: 'Contains',
72+
greater_than: 'Greater Than',
73+
less_than: 'Less Than',
74+
};
75+
6576
const defaultAutomation = (): AutomationDefinition => ({
6677
id: `auto-${Date.now()}`, name: '', description: '', enabled: true,
6778
trigger: { type: 'record_created' }, actions: [], createdAt: new Date().toISOString(),
@@ -190,6 +201,41 @@ export const AutomationBuilder: React.FC<AutomationBuilderProps> = ({
190201
<Label className="text-xs">Condition (optional)</Label>
191202
<Input value={automation.trigger.condition ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateTrigger({ condition: e.target.value })} placeholder='e.g. ${data.status === "active"}' />
192203
</div>
204+
<Separator />
205+
<div className="space-y-3">
206+
<Label className="text-xs font-medium">Conditional Trigger (optional)</Label>
207+
<p className="text-xs text-muted-foreground">Run only when a field matches a specific value.</p>
208+
<div className="grid grid-cols-3 gap-2">
209+
<div className="space-y-1">
210+
<Label className="text-xs">Field</Label>
211+
{selectedObjectFields ? (
212+
<Select value={automation.trigger.conditionField ?? ''} onValueChange={(v) => updateTrigger({ conditionField: v })}>
213+
<SelectTrigger><SelectValue placeholder="Field" /></SelectTrigger>
214+
<SelectContent>
215+
{Object.keys(selectedObjectFields).map(f => <SelectItem key={f} value={f}>{f}</SelectItem>)}
216+
</SelectContent>
217+
</Select>
218+
) : (
219+
<Input value={automation.trigger.conditionField ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateTrigger({ conditionField: e.target.value })} placeholder="e.g. status" />
220+
)}
221+
</div>
222+
<div className="space-y-1">
223+
<Label className="text-xs">Operator</Label>
224+
<Select value={automation.trigger.conditionOperator ?? 'equals'} onValueChange={(v) => updateTrigger({ conditionOperator: v as TriggerConfig['conditionOperator'] })}>
225+
<SelectTrigger><SelectValue placeholder="Operator" /></SelectTrigger>
226+
<SelectContent>
227+
{(Object.keys(CONDITION_OPERATORS) as NonNullable<TriggerConfig['conditionOperator']>[]).map(op => (
228+
<SelectItem key={op} value={op}>{CONDITION_OPERATORS[op]}</SelectItem>
229+
))}
230+
</SelectContent>
231+
</Select>
232+
</div>
233+
<div className="space-y-1">
234+
<Label className="text-xs">Value</Label>
235+
<Input value={automation.trigger.conditionValue ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateTrigger({ conditionValue: e.target.value })} placeholder="e.g. urgent" />
236+
</div>
237+
</div>
238+
</div>
193239
</CardContent>
194240
</Card>
195241

0 commit comments

Comments
 (0)