@object-ui/plugin-detail 3.3.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +21 -1
  3. package/dist/AddressField-LgHnO2Lk.js +98 -0
  4. package/dist/AutoNumberField-xZCrU0eW.js +14 -0
  5. package/dist/{AvatarField-Xuieq0ZI.js → AvatarField-Dy2XGlPz.js} +16 -15
  6. package/dist/{BooleanField-DwfMKknK.js → BooleanField-C0Clfka5.js} +11 -10
  7. package/dist/CodeField-CHUa07B6.js +23 -0
  8. package/dist/ColorField-vxHqEhcS.js +38 -0
  9. package/dist/CurrencyField-DiWjYWDo.js +49 -0
  10. package/dist/DateField-DGaRPM4P.js +22 -0
  11. package/dist/DateTimeField-8QnpsI_h.js +30 -0
  12. package/dist/EmailField-CkVgMbpI.js +26 -0
  13. package/dist/FileField-5UPV7uek.js +149 -0
  14. package/dist/FormulaField-BUgt6-Pi.js +17 -0
  15. package/dist/GeolocationField-D9T_jgG6.js +118 -0
  16. package/dist/GridField-DE_HwiIN.js +49 -0
  17. package/dist/ImageField-Dswnqtzf.js +73 -0
  18. package/dist/LocationField-gjqbE6na.js +36 -0
  19. package/dist/LookupField-BcS3LRKc.js +901 -0
  20. package/dist/{MasterDetailField-B0HTmmD7.js → MasterDetailField-BF6_-X3A.js} +20 -19
  21. package/dist/NumberField-Dj2rYmrS.js +27 -0
  22. package/dist/ObjectField-BymIojwd.js +50 -0
  23. package/dist/{PasswordField-DVTimsc3.js → PasswordField-ED_Xgqz-.js} +8 -7
  24. package/dist/PercentField-D-JKOxKC.js +61 -0
  25. package/dist/PhoneField-DSCaGYq7.js +26 -0
  26. package/dist/QRCodeField-CtcOUapi.js +73 -0
  27. package/dist/{RatingField-rRi_P0N0.js → RatingField-BDnyQFWy.js} +10 -9
  28. package/dist/RichTextField-CH6LVZQA.js +33 -0
  29. package/dist/SelectField-DE4dpkMV.js +36 -0
  30. package/dist/{SignatureField-2CnhcWI0.js → SignatureField-B1wh3f5A.js} +18 -17
  31. package/dist/{SliderField-DEpMVXko.js → SliderField-zoTCKh9n.js} +2 -1
  32. package/dist/SummaryField-BeBVT6VN.js +22 -0
  33. package/dist/TextAreaField-rfUGrRxh.js +37 -0
  34. package/dist/TextField-C_yM7ATQ.js +30 -0
  35. package/dist/TimeField-BcQmBZi9.js +22 -0
  36. package/dist/UrlField-BakaF6NI.js +31 -0
  37. package/dist/UserField-zS7y3eKb.js +76 -0
  38. package/dist/VectorField-CTZ4myDM.js +34 -0
  39. package/dist/index.js +1912 -1728
  40. package/dist/index.umd.cjs +38 -47
  41. package/dist/packages/plugin-detail/src/DetailSection.d.ts.map +1 -1
  42. package/dist/packages/plugin-detail/src/DetailView.d.ts +24 -0
  43. package/dist/packages/plugin-detail/src/DetailView.d.ts.map +1 -1
  44. package/dist/packages/plugin-detail/src/RelatedList.d.ts +8 -0
  45. package/dist/packages/plugin-detail/src/RelatedList.d.ts.map +1 -1
  46. package/dist/packages/plugin-detail/src/useDetailTranslation.d.ts.map +1 -1
  47. package/dist/plugin-detail.css +1 -2
  48. package/dist/rolldown-runtime-DnwLefa7.js +23 -0
  49. package/dist/{src-C56Ly5uG.js → src-DyUKLvMN.js} +18271 -26636
  50. package/dist/{useFieldTranslation-CkxqyB82.js → useFieldTranslation-BRgjC1oq.js} +1 -1
  51. package/package.json +33 -11
  52. package/.turbo/turbo-build.log +0 -64
  53. package/dist/AddressField-CDLSeyNx.js +0 -93
  54. package/dist/AutoNumberField-CtE7suf5.js +0 -14
  55. package/dist/CodeField-CfwgRxx2.js +0 -22
  56. package/dist/ColorField-YKHA7dBD.js +0 -37
  57. package/dist/CurrencyField-tvS3fPAF.js +0 -51
  58. package/dist/DateField-BKqXpkOh.js +0 -21
  59. package/dist/DateTimeField-CR-nJCE7.js +0 -32
  60. package/dist/EmailField-CgvW1Qal.js +0 -28
  61. package/dist/FileField-BVAme2ML.js +0 -151
  62. package/dist/FormulaField-DamJ2VaG.js +0 -14
  63. package/dist/GeolocationField-C99z7ZBM.js +0 -113
  64. package/dist/GridField-C9JbpTx_.js +0 -51
  65. package/dist/ImageField-CDANtgVV.js +0 -75
  66. package/dist/LocationField-ZSyZ0O-h.js +0 -35
  67. package/dist/LookupField-B3hQJt95.js +0 -903
  68. package/dist/LookupField-D00z6gn_.js +0 -2
  69. package/dist/NumberField-DL2QAL7X.js +0 -26
  70. package/dist/ObjectField-JYvUnuRO.js +0 -52
  71. package/dist/PercentField-DjR6BSpw.js +0 -63
  72. package/dist/PhoneField-CX1JL-jp.js +0 -28
  73. package/dist/QRCodeField-CH_1pU6R.js +0 -72
  74. package/dist/RichTextField-CJqLWlrb.js +0 -32
  75. package/dist/SelectField-DGoDoRM_.js +0 -30
  76. package/dist/SelectField-XBVI50AD.js +0 -2
  77. package/dist/SummaryField-7ch9aqAu.js +0 -19
  78. package/dist/TextAreaField-Cmw1oXcw.js +0 -36
  79. package/dist/TextField-OTLa3p51.js +0 -29
  80. package/dist/TimeField-DKPoNWoR.js +0 -21
  81. package/dist/UrlField-CxbmzP9f.js +0 -33
  82. package/dist/UserField-ChvwUkMK.js +0 -78
  83. package/dist/VectorField-BVClL8Vw.js +0 -36
  84. package/src/ActivityTimeline.tsx +0 -184
  85. package/src/CommentAttachment.tsx +0 -194
  86. package/src/CommentInput.tsx +0 -81
  87. package/src/DetailSection.tsx +0 -340
  88. package/src/DetailTabs.tsx +0 -73
  89. package/src/DetailView.stories.tsx +0 -334
  90. package/src/DetailView.tsx +0 -823
  91. package/src/DiffView.tsx +0 -233
  92. package/src/FieldChangeItem.tsx +0 -46
  93. package/src/HeaderHighlight.tsx +0 -88
  94. package/src/InlineCreateRelated.tsx +0 -291
  95. package/src/MentionAutocomplete.tsx +0 -123
  96. package/src/PointInTimeRestore.tsx +0 -261
  97. package/src/ReactionPicker.tsx +0 -106
  98. package/src/RecordActivityTimeline.tsx +0 -433
  99. package/src/RecordChatterPanel.tsx +0 -209
  100. package/src/RecordComments.tsx +0 -217
  101. package/src/RecordNavigationEnhanced.tsx +0 -213
  102. package/src/RelatedList.tsx +0 -413
  103. package/src/RelationshipGraph.tsx +0 -286
  104. package/src/RichTextCommentInput.tsx +0 -350
  105. package/src/SectionGroup.tsx +0 -101
  106. package/src/SubscriptionToggle.tsx +0 -62
  107. package/src/ThreadedReplies.tsx +0 -163
  108. package/src/__tests__/ActivityTimeline.test.tsx +0 -119
  109. package/src/__tests__/ActivityTimelineFiltering.test.tsx +0 -143
  110. package/src/__tests__/CommentInput.test.tsx +0 -57
  111. package/src/__tests__/DetailSection.test.tsx +0 -490
  112. package/src/__tests__/DetailView.test.tsx +0 -694
  113. package/src/__tests__/FieldChangeItem.test.tsx +0 -119
  114. package/src/__tests__/HeaderHighlight.test.tsx +0 -213
  115. package/src/__tests__/MentionAutocomplete.test.tsx +0 -97
  116. package/src/__tests__/ReactionPicker.test.tsx +0 -113
  117. package/src/__tests__/RecordActivityTimeline.test.tsx +0 -395
  118. package/src/__tests__/RecordChatterPanel.test.tsx +0 -265
  119. package/src/__tests__/RecordComments.test.tsx +0 -96
  120. package/src/__tests__/RecordCommentsPinSearch.test.tsx +0 -133
  121. package/src/__tests__/RelatedList.test.tsx +0 -160
  122. package/src/__tests__/SectionGroup.test.tsx +0 -101
  123. package/src/__tests__/SubscriptionToggle.test.tsx +0 -84
  124. package/src/__tests__/ThreadedReplies.test.tsx +0 -212
  125. package/src/__tests__/autoLayout.test.ts +0 -228
  126. package/src/__tests__/phase12-features.test.tsx +0 -583
  127. package/src/__tests__/roadmap-features.test.tsx +0 -478
  128. package/src/autoLayout.ts +0 -128
  129. package/src/index.tsx +0 -149
  130. package/src/useDetailTranslation.ts +0 -183
  131. package/tsconfig.json +0 -18
  132. package/vite.config.ts +0 -57
  133. package/vitest.config.ts +0 -13
  134. package/vitest.setup.ts +0 -1
@@ -1,433 +0,0 @@
1
- /**
2
- * ObjectUI
3
- * Copyright (c) 2024-present ObjectStack Inc.
4
- *
5
- * This source code is licensed under the MIT license found in the
6
- * LICENSE file in the root directory of this source tree.
7
- */
8
-
9
- import * as React from 'react';
10
- import { cn, Card, CardHeader, CardTitle, CardContent, Button } from '@object-ui/components';
11
- import {
12
- Activity,
13
- Edit,
14
- PlusCircle,
15
- Trash2,
16
- MessageSquare,
17
- ArrowRightLeft,
18
- Calendar,
19
- CheckSquare,
20
- Zap,
21
- Mail,
22
- Phone,
23
- ChevronDown,
24
- Loader2,
25
- } from 'lucide-react';
26
- import type { FeedItem, FeedItemType, RecordActivityComponentProps, RecordSubscription } from '@object-ui/types';
27
- import { FieldChangeItem } from './FieldChangeItem';
28
- import { ReactionPicker } from './ReactionPicker';
29
- import { ThreadedReplies } from './ThreadedReplies';
30
- import { SubscriptionToggle } from './SubscriptionToggle';
31
- import { useDetailTranslation } from './useDetailTranslation';
32
-
33
- export type FeedFilterMode = 'all' | 'comments_only' | 'changes_only' | 'tasks_only';
34
-
35
- export interface RecordActivityTimelineProps {
36
- /** Feed items to display */
37
- items: FeedItem[];
38
- /** Activity configuration from RecordActivityComponentProps */
39
- config?: RecordActivityComponentProps;
40
- /** Filter mode for the timeline */
41
- filterMode?: FeedFilterMode;
42
- /** Called when filter mode changes */
43
- onFilterChange?: (mode: FeedFilterMode) => void;
44
- /** Whether there are more items to load */
45
- hasMore?: boolean;
46
- /** Called when user wants to load more items */
47
- onLoadMore?: () => void | Promise<void>;
48
- /** Loading state */
49
- loading?: boolean;
50
- /** Called when a comment is submitted */
51
- onAddComment?: (text: string) => void | Promise<void>;
52
- /** Called when a reply is submitted */
53
- onAddReply?: (parentId: string | number, text: string) => void | Promise<void>;
54
- /** Called when user toggles a reaction */
55
- onToggleReaction?: (itemId: string | number, emoji: string) => void | Promise<void>;
56
- /** Subscription state */
57
- subscription?: RecordSubscription;
58
- /** Called when user toggles subscription */
59
- onToggleSubscription?: (subscribed: boolean) => void | Promise<void>;
60
- /** When true, collapse to only the comment input when there are no items */
61
- collapseWhenEmpty?: boolean;
62
- className?: string;
63
- }
64
-
65
- const FEED_TYPE_ICONS: Record<FeedItemType, React.ElementType> = {
66
- comment: MessageSquare,
67
- field_change: Edit,
68
- task: CheckSquare,
69
- event: Calendar,
70
- system: Zap,
71
- email: Mail,
72
- call: Phone,
73
- };
74
-
75
- const FEED_TYPE_COLORS: Record<FeedItemType, string> = {
76
- comment: 'bg-purple-100 text-purple-600',
77
- field_change: 'bg-blue-100 text-blue-600',
78
- task: 'bg-green-100 text-green-600',
79
- event: 'bg-amber-100 text-amber-600',
80
- system: 'bg-gray-100 text-gray-600',
81
- email: 'bg-indigo-100 text-indigo-600',
82
- call: 'bg-teal-100 text-teal-600',
83
- };
84
-
85
- function getFilterOptions(t: (key: string) => string): { value: FeedFilterMode; label: string }[] {
86
- return [
87
- { value: 'all', label: t('detail.allActivity') },
88
- { value: 'comments_only', label: t('detail.commentsOnly') },
89
- { value: 'changes_only', label: t('detail.fieldChangesFilter') },
90
- { value: 'tasks_only', label: t('detail.tasksOnly') },
91
- ];
92
- }
93
-
94
- function formatTimestamp(timestamp: string): string {
95
- try {
96
- const date = new Date(timestamp);
97
- const now = new Date();
98
- const diffMs = now.getTime() - date.getTime();
99
- const diffMins = Math.floor(diffMs / 60000);
100
-
101
- if (diffMins < 1) return 'just now';
102
- if (diffMins < 60) return `${diffMins}m ago`;
103
- const diffHours = Math.floor(diffMins / 60);
104
- if (diffHours < 24) return `${diffHours}h ago`;
105
- const diffDays = Math.floor(diffHours / 24);
106
- if (diffDays < 7) return `${diffDays}d ago`;
107
- return date.toLocaleDateString();
108
- } catch {
109
- return timestamp;
110
- }
111
- }
112
-
113
- function filterItems(items: FeedItem[], mode: FeedFilterMode): FeedItem[] {
114
- switch (mode) {
115
- case 'comments_only':
116
- return items.filter((i) => i.type === 'comment');
117
- case 'changes_only':
118
- return items.filter((i) => i.type === 'field_change');
119
- case 'tasks_only':
120
- return items.filter((i) => i.type === 'task');
121
- default:
122
- return items;
123
- }
124
- }
125
-
126
- /**
127
- * RecordActivityTimeline — Unified timeline renderer for Airtable-style activity feeds.
128
- *
129
- * Renders different feed item types (comment, field_change, task, event, system, etc.)
130
- * in a unified timeline. Supports filtering, pagination, reactions, and threading.
131
- *
132
- * Aligned with @objectstack/spec RecordActivityProps.
133
- */
134
- export const RecordActivityTimeline: React.FC<RecordActivityTimelineProps> = ({
135
- items,
136
- config,
137
- filterMode: controlledFilter,
138
- onFilterChange,
139
- hasMore = false,
140
- onLoadMore,
141
- loading: _loading = false,
142
- onAddComment,
143
- onAddReply,
144
- onToggleReaction,
145
- subscription,
146
- onToggleSubscription,
147
- collapseWhenEmpty = false,
148
- className,
149
- }) => {
150
- const { t } = useDetailTranslation();
151
- const [internalFilter, setInternalFilter] = React.useState<FeedFilterMode>('all');
152
- const [commentText, setCommentText] = React.useState('');
153
- const [isSubmitting, setIsSubmitting] = React.useState(false);
154
- const [isLoadingMore, setIsLoadingMore] = React.useState(false);
155
-
156
- const activeFilter = controlledFilter ?? internalFilter;
157
- const showFilter = config?.showFilterToggle !== false;
158
- const showCommentInput = config?.showCommentInput !== false && !!onAddComment;
159
- const enableReactions = config?.enableReactions ?? false;
160
- const enableThreading = config?.enableThreading ?? false;
161
- const showSubscription = config?.showSubscriptionToggle ?? false;
162
-
163
- const filtered = React.useMemo(
164
- () => filterItems(items, activeFilter),
165
- [items, activeFilter],
166
- );
167
-
168
- // Group replies by parentId
169
- const rootItems = React.useMemo(() => {
170
- if (!enableThreading) return filtered;
171
- return filtered.filter((i) => !i.parentId);
172
- }, [filtered, enableThreading]);
173
-
174
- const repliesByParent = React.useMemo(() => {
175
- if (!enableThreading) return new Map<string | number, FeedItem[]>();
176
- const map = new Map<string | number, FeedItem[]>();
177
- for (const item of filtered) {
178
- if (item.parentId) {
179
- const existing = map.get(item.parentId) ?? [];
180
- existing.push(item);
181
- map.set(item.parentId, existing);
182
- }
183
- }
184
- return map;
185
- }, [filtered, enableThreading]);
186
-
187
- const handleFilterChange = React.useCallback(
188
- (mode: FeedFilterMode) => {
189
- if (onFilterChange) {
190
- onFilterChange(mode);
191
- } else {
192
- setInternalFilter(mode);
193
- }
194
- },
195
- [onFilterChange],
196
- );
197
-
198
- const handleAddComment = React.useCallback(async () => {
199
- const text = commentText.trim();
200
- if (!text || !onAddComment) return;
201
- setIsSubmitting(true);
202
- try {
203
- await onAddComment(text);
204
- setCommentText('');
205
- } finally {
206
- setIsSubmitting(false);
207
- }
208
- }, [commentText, onAddComment]);
209
-
210
- const handleKeyDown = React.useCallback(
211
- (e: React.KeyboardEvent) => {
212
- if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
213
- e.preventDefault();
214
- handleAddComment();
215
- }
216
- },
217
- [handleAddComment],
218
- );
219
-
220
- const handleLoadMore = React.useCallback(async () => {
221
- if (!onLoadMore) return;
222
- setIsLoadingMore(true);
223
- try {
224
- await onLoadMore();
225
- } finally {
226
- setIsLoadingMore(false);
227
- }
228
- }, [onLoadMore]);
229
-
230
- return (
231
- <Card className={cn('', className)}>
232
- <CardHeader>
233
- <div className="flex items-center justify-between">
234
- <CardTitle className="flex items-center gap-2 text-base">
235
- <Activity className="h-4 w-4" />
236
- {t('detail.activity')}
237
- <span className="text-sm font-normal text-muted-foreground">
238
- ({filtered.length})
239
- </span>
240
- </CardTitle>
241
- <div className="flex items-center gap-1">
242
- {showSubscription && subscription && (
243
- <SubscriptionToggle
244
- subscription={subscription}
245
- onToggle={onToggleSubscription}
246
- />
247
- )}
248
- </div>
249
- </div>
250
- </CardHeader>
251
- <CardContent className="space-y-4">
252
- {/* Filter dropdown */}
253
- {showFilter && (
254
- <div className="flex items-center gap-2">
255
- <select
256
- className="rounded-md border border-input bg-background px-2.5 py-1.5 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
257
- value={activeFilter}
258
- onChange={(e) => handleFilterChange(e.target.value as FeedFilterMode)}
259
- aria-label={t('detail.filterActivity')}
260
- >
261
- {getFilterOptions(t).map((opt) => (
262
- <option key={opt.value} value={opt.value}>
263
- {opt.label}
264
- </option>
265
- ))}
266
- </select>
267
- </div>
268
- )}
269
-
270
- {/* Comment Input */}
271
- {showCommentInput && (
272
- <div className="flex gap-2">
273
- <textarea
274
- className="flex-1 min-h-[60px] rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none"
275
- placeholder={t('detail.leaveCommentPlaceholder')}
276
- value={commentText}
277
- onChange={(e) => setCommentText(e.target.value)}
278
- onKeyDown={handleKeyDown}
279
- disabled={isSubmitting}
280
- />
281
- <Button
282
- size="icon"
283
- variant="default"
284
- onClick={handleAddComment}
285
- disabled={!commentText.trim() || isSubmitting}
286
- className="shrink-0 self-end"
287
- aria-label={t('detail.submitComment')}
288
- >
289
- <MessageSquare className="h-4 w-4" />
290
- </Button>
291
- </div>
292
- )}
293
-
294
- {/* Timeline */}
295
- {filtered.length === 0 ? (
296
- collapseWhenEmpty ? null : (
297
- <p className="text-sm text-muted-foreground text-center py-4">
298
- {t('detail.noActivity')}
299
- </p>
300
- )
301
- ) : (
302
- <div className="relative">
303
- {/* Timeline line */}
304
- <div className="absolute left-4 top-2 bottom-2 w-px bg-border" />
305
-
306
- <div className="space-y-4">
307
- {rootItems.map((item) => {
308
- const Icon = FEED_TYPE_ICONS[item.type] || Zap;
309
- const colorClass =
310
- FEED_TYPE_COLORS[item.type] || 'bg-gray-100 text-gray-600';
311
- const replies = repliesByParent.get(item.id) ?? [];
312
-
313
- return (
314
- <div key={item.id}>
315
- <div className="flex gap-3 relative">
316
- {/* Icon */}
317
- <div
318
- className={cn(
319
- 'shrink-0 h-8 w-8 rounded-full flex items-center justify-center z-10',
320
- colorClass,
321
- )}
322
- >
323
- {item.actorAvatarUrl ? (
324
- <img
325
- src={item.actorAvatarUrl}
326
- alt={item.actor}
327
- className="h-8 w-8 rounded-full object-cover"
328
- />
329
- ) : (
330
- <Icon className="h-3.5 w-3.5" />
331
- )}
332
- </div>
333
- {/* Content */}
334
- <div className="flex-1 min-w-0 pt-1">
335
- <div className="flex items-center gap-2 mb-0.5">
336
- <span className="text-sm font-medium">{item.actor}</span>
337
- {item.source && (
338
- <span className="text-xs text-muted-foreground">
339
- {t('detail.via', { source: item.source })}
340
- </span>
341
- )}
342
- <span className="text-xs text-muted-foreground">
343
- {formatTimestamp(item.createdAt)}
344
- </span>
345
- {item.edited && (
346
- <span className="text-xs text-muted-foreground italic">{t('detail.edited')}</span>
347
- )}
348
- {item.pinned && (
349
- <span className="text-xs text-amber-600">📌 {t('detail.pinned')}</span>
350
- )}
351
- </div>
352
-
353
- {/* Body text */}
354
- {item.body && (
355
- <p className="text-sm whitespace-pre-wrap break-words text-muted-foreground">
356
- {item.body}
357
- </p>
358
- )}
359
-
360
- {/* Field changes */}
361
- {item.type === 'field_change' && item.fieldChanges && (
362
- <div className="space-y-1 mt-1">
363
- {item.fieldChanges.map((change, idx) => (
364
- <FieldChangeItem key={idx} change={change} />
365
- ))}
366
- </div>
367
- )}
368
-
369
- {/* Reactions */}
370
- {enableReactions && item.reactions && item.reactions.length > 0 && (
371
- <div className="mt-1.5">
372
- <ReactionPicker
373
- reactions={item.reactions}
374
- onToggleReaction={
375
- onToggleReaction
376
- ? (emoji) => onToggleReaction(item.id, emoji)
377
- : undefined
378
- }
379
- />
380
- </div>
381
- )}
382
-
383
- {/* Add reaction button (even if no reactions yet) */}
384
- {enableReactions && (!item.reactions || item.reactions.length === 0) && onToggleReaction && (
385
- <div className="mt-1.5">
386
- <ReactionPicker
387
- reactions={[]}
388
- onToggleReaction={(emoji) => onToggleReaction(item.id, emoji)}
389
- />
390
- </div>
391
- )}
392
- </div>
393
- </div>
394
-
395
- {/* Threading */}
396
- {enableThreading && (item.replyCount ?? 0) > 0 && (
397
- <ThreadedReplies
398
- parentItem={item}
399
- replies={replies}
400
- onAddReply={onAddReply}
401
- showReplyInput={!!onAddReply}
402
- />
403
- )}
404
- </div>
405
- );
406
- })}
407
- </div>
408
- </div>
409
- )}
410
-
411
- {/* Load More */}
412
- {hasMore && (
413
- <div className="text-center pt-2">
414
- <Button
415
- variant="ghost"
416
- size="sm"
417
- onClick={handleLoadMore}
418
- disabled={isLoadingMore}
419
- aria-label={t('detail.loadMore')}
420
- >
421
- {isLoadingMore ? (
422
- <Loader2 className="h-4 w-4 animate-spin mr-1" />
423
- ) : (
424
- <ChevronDown className="h-4 w-4 mr-1" />
425
- )}
426
- {t('detail.loadMore')}
427
- </Button>
428
- </div>
429
- )}
430
- </CardContent>
431
- </Card>
432
- );
433
- };
@@ -1,209 +0,0 @@
1
- /**
2
- * ObjectUI
3
- * Copyright (c) 2024-present ObjectStack Inc.
4
- *
5
- * This source code is licensed under the MIT license found in the
6
- * LICENSE file in the root directory of this source tree.
7
- */
8
-
9
- import * as React from 'react';
10
- import { cn, Button } from '@object-ui/components';
11
- import { MessageSquare, PanelRightOpen, PanelRightClose, X } from 'lucide-react';
12
- import type { RecordChatterComponentProps, FeedItem, RecordSubscription } from '@object-ui/types';
13
- import { RecordActivityTimeline } from './RecordActivityTimeline';
14
- import type { FeedFilterMode, RecordActivityTimelineProps } from './RecordActivityTimeline';
15
- import { useDetailTranslation } from './useDetailTranslation';
16
-
17
- export interface RecordChatterPanelProps {
18
- /** Chatter panel configuration from RecordChatterComponentProps */
19
- config?: RecordChatterComponentProps;
20
- /** Feed items to display in the embedded timeline */
21
- items: FeedItem[];
22
- /** Whether there are more items to load */
23
- hasMore?: boolean;
24
- /** Called when user wants to load more */
25
- onLoadMore?: () => void | Promise<void>;
26
- /** Loading state */
27
- loading?: boolean;
28
- /** Called when a comment is submitted */
29
- onAddComment?: (text: string) => void | Promise<void>;
30
- /** Called when a reply is submitted */
31
- onAddReply?: (parentId: string | number, text: string) => void | Promise<void>;
32
- /** Called when user toggles a reaction */
33
- onToggleReaction?: (itemId: string | number, emoji: string) => void | Promise<void>;
34
- /** Subscription state */
35
- subscription?: RecordSubscription;
36
- /** Called when user toggles subscription */
37
- onToggleSubscription?: (subscribed: boolean) => void | Promise<void>;
38
- /** Filter mode */
39
- filterMode?: FeedFilterMode;
40
- /** Called when filter changes */
41
- onFilterChange?: (mode: FeedFilterMode) => void;
42
- /** When true, auto-collapse panel when there are no feed items */
43
- collapseWhenEmpty?: boolean;
44
- className?: string;
45
- }
46
-
47
- /**
48
- * RecordChatterPanel — Side/inline/drawer panel for record discussions.
49
- *
50
- * Consumes RecordChatterComponentProps from the spec protocol.
51
- * Supports three positions: bottom (inline), right (sidebar), left (sidebar).
52
- * Can be collapsible/expandable.
53
- *
54
- * Embeds RecordActivityTimeline as the feed sub-component.
55
- */
56
- export const RecordChatterPanel: React.FC<RecordChatterPanelProps> = ({
57
- config,
58
- items,
59
- hasMore,
60
- onLoadMore,
61
- loading,
62
- onAddComment,
63
- onAddReply,
64
- onToggleReaction,
65
- subscription,
66
- onToggleSubscription,
67
- filterMode,
68
- onFilterChange,
69
- collapseWhenEmpty = false,
70
- className,
71
- }) => {
72
- const position = config?.position ?? 'right';
73
- const width = config?.width ?? '360px';
74
- const collapsible = config?.collapsible ?? true;
75
- const defaultCollapsed = (collapseWhenEmpty && items.length === 0) || (config?.defaultCollapsed ?? false);
76
-
77
- const { t } = useDetailTranslation();
78
- const [collapsed, setCollapsed] = React.useState(defaultCollapsed);
79
-
80
- const isSidebar = position === 'right' || position === 'left';
81
- const isInline = position === 'bottom';
82
-
83
- // Sidebar mode
84
- if (isSidebar) {
85
- if (collapsed && collapsible) {
86
- return (
87
- <div
88
- className={cn(
89
- 'flex items-start pt-4',
90
- position === 'right' ? 'border-l' : 'border-r',
91
- className,
92
- )}
93
- >
94
- <Button
95
- variant="ghost"
96
- size="icon"
97
- className="h-8 w-8 mx-1"
98
- onClick={() => setCollapsed(false)}
99
- aria-label={t('detail.openDiscussion')}
100
- >
101
- <PanelRightOpen className="h-4 w-4" />
102
- </Button>
103
- </div>
104
- );
105
- }
106
-
107
- return (
108
- <div
109
- className={cn(
110
- 'flex flex-col overflow-hidden',
111
- position === 'right' ? 'border-l' : 'border-r',
112
- className,
113
- )}
114
- style={{ width, minWidth: width }}
115
- >
116
- {/* Header */}
117
- <div className="flex items-center justify-between px-4 py-3 border-b">
118
- <div className="flex items-center gap-2">
119
- <MessageSquare className="h-4 w-4" />
120
- <span className="text-sm font-medium">{t('detail.discussion')}</span>
121
- </div>
122
- {collapsible && (
123
- <Button
124
- variant="ghost"
125
- size="icon"
126
- className="h-7 w-7"
127
- onClick={() => setCollapsed(true)}
128
- aria-label={t('detail.closeDiscussion')}
129
- >
130
- <X className="h-3.5 w-3.5" />
131
- </Button>
132
- )}
133
- </div>
134
-
135
- {/* Embedded Timeline */}
136
- <div className="flex-1 overflow-y-auto">
137
- <RecordActivityTimeline
138
- items={items}
139
- config={config?.feed}
140
- hasMore={hasMore}
141
- onLoadMore={onLoadMore}
142
- loading={loading}
143
- onAddComment={onAddComment}
144
- onAddReply={onAddReply}
145
- onToggleReaction={onToggleReaction}
146
- subscription={subscription}
147
- onToggleSubscription={onToggleSubscription}
148
- filterMode={filterMode}
149
- onFilterChange={onFilterChange}
150
- collapseWhenEmpty={collapseWhenEmpty}
151
- className="border-0 shadow-none"
152
- />
153
- </div>
154
- </div>
155
- );
156
- }
157
-
158
- // Inline / bottom mode
159
- return (
160
- <div className={cn('', className)}>
161
- {collapsible && collapsed ? (
162
- <Button
163
- variant="ghost"
164
- className="w-full justify-start gap-2 text-muted-foreground"
165
- onClick={() => setCollapsed(false)}
166
- aria-label={t('detail.showDiscussion', { count: items.length })}
167
- >
168
- <MessageSquare className="h-4 w-4" />
169
- <span>{t('detail.showDiscussion', { count: items.length })}</span>
170
- </Button>
171
- ) : (
172
- <div>
173
- {collapsible && (
174
- <div className="flex items-center justify-between mb-2">
175
- <div className="flex items-center gap-2 text-sm font-medium">
176
- <MessageSquare className="h-4 w-4" />
177
- {t('detail.discussion')}
178
- </div>
179
- <Button
180
- variant="ghost"
181
- size="icon"
182
- className="h-7 w-7"
183
- onClick={() => setCollapsed(true)}
184
- aria-label={t('detail.hideDiscussion')}
185
- >
186
- <PanelRightClose className="h-3.5 w-3.5" />
187
- </Button>
188
- </div>
189
- )}
190
- <RecordActivityTimeline
191
- items={items}
192
- config={config?.feed}
193
- hasMore={hasMore}
194
- onLoadMore={onLoadMore}
195
- loading={loading}
196
- onAddComment={onAddComment}
197
- onAddReply={onAddReply}
198
- onToggleReaction={onToggleReaction}
199
- subscription={subscription}
200
- onToggleSubscription={onToggleSubscription}
201
- filterMode={filterMode}
202
- onFilterChange={onFilterChange}
203
- collapseWhenEmpty={collapseWhenEmpty}
204
- />
205
- </div>
206
- )}
207
- </div>
208
- );
209
- };