@object-ui/plugin-detail 3.0.3 → 3.1.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 (141) hide show
  1. package/.turbo/turbo-build.log +45 -8
  2. package/CHANGELOG.md +11 -0
  3. package/dist/AddressField-B1iVr404.js +96 -0
  4. package/dist/AutoNumberField-BxnFqllo.js +8 -0
  5. package/dist/AvatarField-Duw4xOLZ.js +82 -0
  6. package/dist/BooleanField-CZ4axVeq.js +37 -0
  7. package/dist/CodeField-BSz-mk2v.js +21 -0
  8. package/dist/ColorField-B522ad8m.js +42 -0
  9. package/dist/CurrencyField-Cwr3_pow.js +43 -0
  10. package/dist/DateField-DCo6dxud.js +21 -0
  11. package/dist/DateTimeField-BWfBuANO.js +28 -0
  12. package/dist/EmailField-CpwbdVCU.js +31 -0
  13. package/dist/FileField-DVAUAJ8e.js +133 -0
  14. package/dist/FormulaField-CJkkwIK8.js +9 -0
  15. package/dist/GeolocationField-DNCKitgo.js +123 -0
  16. package/dist/GridField-DSblZNfp.js +30 -0
  17. package/dist/ImageField-DBAlnMon.js +90 -0
  18. package/dist/LocationField-DsHsXA6R.js +31 -0
  19. package/dist/LookupField-CsT0QQz2.js +96 -0
  20. package/dist/MasterDetailField-Db8b7Gqs.js +108 -0
  21. package/dist/NumberField-0IGp7lcA.js +26 -0
  22. package/dist/ObjectField-BLApgJtS.js +48 -0
  23. package/dist/PasswordField-pHKyNlmo.js +38 -0
  24. package/dist/PercentField-CwgKmlIb.js +63 -0
  25. package/dist/PhoneField-lKtbYOdN.js +31 -0
  26. package/dist/QRCodeField-BTTasT3w.js +77 -0
  27. package/dist/RatingField-De2X-l44.js +47 -0
  28. package/dist/RichTextField-B5QnvUOr.js +38 -0
  29. package/dist/SelectField-C9AZRHWu.js +26 -0
  30. package/dist/SignatureField-BgcEmYzd.js +85 -0
  31. package/dist/SliderField-BzrttVOY.js +30 -0
  32. package/dist/SummaryField-ugYPYxjP.js +9 -0
  33. package/dist/TextAreaField-DSE_CaU6.js +39 -0
  34. package/dist/TextField-DFQ4T9PR.js +32 -0
  35. package/dist/TimeField-F0cfmsps.js +21 -0
  36. package/dist/UrlField-DLXrFIH-.js +33 -0
  37. package/dist/UserField-PXMmxJY9.js +49 -0
  38. package/dist/VectorField-CKg9jdGa.js +25 -0
  39. package/dist/index-qQ1C-yUR.js +59976 -0
  40. package/dist/index.js +32 -55026
  41. package/dist/index.umd.cjs +41 -30
  42. package/dist/plugin-detail.css +1 -1
  43. package/dist/src/ActivityTimeline.d.ts +20 -0
  44. package/dist/src/ActivityTimeline.d.ts.map +1 -0
  45. package/dist/src/CommentAttachment.d.ts +25 -0
  46. package/dist/src/CommentAttachment.d.ts.map +1 -0
  47. package/dist/src/CommentInput.d.ts +24 -0
  48. package/dist/src/CommentInput.d.ts.map +1 -0
  49. package/dist/src/DetailSection.d.ts +8 -0
  50. package/dist/src/DetailSection.d.ts.map +1 -1
  51. package/dist/src/DetailView.d.ts +4 -0
  52. package/dist/src/DetailView.d.ts.map +1 -1
  53. package/dist/src/DetailView.stories.d.ts +8 -0
  54. package/dist/src/DetailView.stories.d.ts.map +1 -1
  55. package/dist/src/DiffView.d.ts +24 -0
  56. package/dist/src/DiffView.d.ts.map +1 -0
  57. package/dist/src/FieldChangeItem.d.ts +21 -0
  58. package/dist/src/FieldChangeItem.d.ts.map +1 -0
  59. package/dist/src/HeaderHighlight.d.ts +18 -0
  60. package/dist/src/HeaderHighlight.d.ts.map +1 -0
  61. package/dist/src/InlineCreateRelated.d.ts +32 -0
  62. package/dist/src/InlineCreateRelated.d.ts.map +1 -0
  63. package/dist/src/MentionAutocomplete.d.ts +43 -0
  64. package/dist/src/MentionAutocomplete.d.ts.map +1 -0
  65. package/dist/src/PointInTimeRestore.d.ts +28 -0
  66. package/dist/src/PointInTimeRestore.d.ts.map +1 -0
  67. package/dist/src/ReactionPicker.d.ts +25 -0
  68. package/dist/src/ReactionPicker.d.ts.map +1 -0
  69. package/dist/src/RecordActivityTimeline.d.ts +49 -0
  70. package/dist/src/RecordActivityTimeline.d.ts.map +1 -0
  71. package/dist/src/RecordChatterPanel.d.ts +48 -0
  72. package/dist/src/RecordChatterPanel.d.ts.map +1 -0
  73. package/dist/src/RecordComments.d.ts +20 -0
  74. package/dist/src/RecordComments.d.ts.map +1 -0
  75. package/dist/src/RecordNavigationEnhanced.d.ts +18 -0
  76. package/dist/src/RecordNavigationEnhanced.d.ts.map +1 -0
  77. package/dist/src/RelatedList.d.ts +20 -0
  78. package/dist/src/RelatedList.d.ts.map +1 -1
  79. package/dist/src/RelationshipGraph.d.ts +23 -0
  80. package/dist/src/RelationshipGraph.d.ts.map +1 -0
  81. package/dist/src/RichTextCommentInput.d.ts +24 -0
  82. package/dist/src/RichTextCommentInput.d.ts.map +1 -0
  83. package/dist/src/SectionGroup.d.ts +21 -0
  84. package/dist/src/SectionGroup.d.ts.map +1 -0
  85. package/dist/src/SubscriptionToggle.d.ts +22 -0
  86. package/dist/src/SubscriptionToggle.d.ts.map +1 -0
  87. package/dist/src/ThreadedReplies.d.ts +26 -0
  88. package/dist/src/ThreadedReplies.d.ts.map +1 -0
  89. package/dist/src/autoLayout.d.ts +34 -0
  90. package/dist/src/autoLayout.d.ts.map +1 -0
  91. package/dist/src/index.d.ts +40 -0
  92. package/dist/src/index.d.ts.map +1 -1
  93. package/dist/src/useDetailTranslation.d.ts +34 -0
  94. package/dist/src/useDetailTranslation.d.ts.map +1 -0
  95. package/package.json +8 -7
  96. package/src/ActivityTimeline.tsx +184 -0
  97. package/src/CommentAttachment.tsx +192 -0
  98. package/src/CommentInput.tsx +81 -0
  99. package/src/DetailSection.tsx +81 -10
  100. package/src/DetailView.stories.tsx +76 -0
  101. package/src/DetailView.tsx +519 -66
  102. package/src/DiffView.tsx +231 -0
  103. package/src/FieldChangeItem.tsx +46 -0
  104. package/src/HeaderHighlight.tsx +67 -0
  105. package/src/InlineCreateRelated.tsx +291 -0
  106. package/src/MentionAutocomplete.tsx +123 -0
  107. package/src/PointInTimeRestore.tsx +261 -0
  108. package/src/ReactionPicker.tsx +106 -0
  109. package/src/RecordActivityTimeline.tsx +429 -0
  110. package/src/RecordChatterPanel.tsx +202 -0
  111. package/src/RecordComments.tsx +215 -0
  112. package/src/RecordNavigationEnhanced.tsx +211 -0
  113. package/src/RelatedList.tsx +314 -19
  114. package/src/RelationshipGraph.tsx +286 -0
  115. package/src/RichTextCommentInput.tsx +348 -0
  116. package/src/SectionGroup.tsx +101 -0
  117. package/src/SubscriptionToggle.tsx +60 -0
  118. package/src/ThreadedReplies.tsx +161 -0
  119. package/src/__tests__/ActivityTimeline.test.tsx +119 -0
  120. package/src/__tests__/ActivityTimelineFiltering.test.tsx +143 -0
  121. package/src/__tests__/CommentInput.test.tsx +57 -0
  122. package/src/__tests__/DetailSection.test.tsx +320 -0
  123. package/src/__tests__/DetailView.test.tsx +415 -1
  124. package/src/__tests__/FieldChangeItem.test.tsx +119 -0
  125. package/src/__tests__/HeaderHighlight.test.tsx +68 -0
  126. package/src/__tests__/MentionAutocomplete.test.tsx +97 -0
  127. package/src/__tests__/ReactionPicker.test.tsx +113 -0
  128. package/src/__tests__/RecordActivityTimeline.test.tsx +395 -0
  129. package/src/__tests__/RecordChatterPanel.test.tsx +227 -0
  130. package/src/__tests__/RecordComments.test.tsx +96 -0
  131. package/src/__tests__/RecordCommentsPinSearch.test.tsx +133 -0
  132. package/src/__tests__/RelatedList.test.tsx +160 -0
  133. package/src/__tests__/SectionGroup.test.tsx +101 -0
  134. package/src/__tests__/SubscriptionToggle.test.tsx +84 -0
  135. package/src/__tests__/ThreadedReplies.test.tsx +212 -0
  136. package/src/__tests__/autoLayout.test.ts +184 -0
  137. package/src/__tests__/phase12-features.test.tsx +583 -0
  138. package/src/__tests__/roadmap-features.test.tsx +478 -0
  139. package/src/autoLayout.ts +111 -0
  140. package/src/index.tsx +50 -0
  141. package/src/useDetailTranslation.ts +114 -0
@@ -0,0 +1,231 @@
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, Card, CardHeader, CardTitle, CardContent } from '@object-ui/components';
11
+ import { Columns2, Rows3 } from 'lucide-react';
12
+
13
+ export type DiffFieldType = 'string' | 'number' | 'boolean' | 'json' | 'date';
14
+ export type DiffMode = 'unified' | 'side-by-side';
15
+
16
+ export interface DiffLine {
17
+ type: 'added' | 'removed' | 'unchanged';
18
+ value: string;
19
+ }
20
+
21
+ export interface DiffViewProps {
22
+ oldValue: any;
23
+ newValue: any;
24
+ fieldName: string;
25
+ fieldType?: DiffFieldType;
26
+ mode?: DiffMode;
27
+ className?: string;
28
+ }
29
+
30
+ /** Convert a value to diffable string lines based on its type. */
31
+ function valueToLines(value: any, fieldType: DiffFieldType): string[] {
32
+ if (value == null) return ['(empty)'];
33
+
34
+ switch (fieldType) {
35
+ case 'json':
36
+ try {
37
+ const parsed = typeof value === 'string' ? JSON.parse(value) : value;
38
+ return JSON.stringify(parsed, null, 2).split('\n');
39
+ } catch {
40
+ return String(value).split('\n');
41
+ }
42
+ case 'boolean':
43
+ return [String(!!value)];
44
+ case 'number':
45
+ return [String(value)];
46
+ case 'date':
47
+ try {
48
+ return [new Date(value).toLocaleString()];
49
+ } catch {
50
+ return [String(value)];
51
+ }
52
+ default:
53
+ return String(value).split('\n');
54
+ }
55
+ }
56
+
57
+ /** Compute a simple line-by-line diff between old and new lines. */
58
+ function computeDiff(oldLines: string[], newLines: string[]): DiffLine[] {
59
+ const result: DiffLine[] = [];
60
+ const maxLen = Math.max(oldLines.length, newLines.length);
61
+
62
+ for (let i = 0; i < maxLen; i++) {
63
+ const oldLine = i < oldLines.length ? oldLines[i] : undefined;
64
+ const newLine = i < newLines.length ? newLines[i] : undefined;
65
+
66
+ if (oldLine === newLine) {
67
+ result.push({ type: 'unchanged', value: oldLine! });
68
+ } else {
69
+ if (oldLine !== undefined) {
70
+ result.push({ type: 'removed', value: oldLine });
71
+ }
72
+ if (newLine !== undefined) {
73
+ result.push({ type: 'added', value: newLine });
74
+ }
75
+ }
76
+ }
77
+
78
+ return result;
79
+ }
80
+
81
+ const LINE_STYLES: Record<DiffLine['type'], string> = {
82
+ added: 'bg-green-50 text-green-800 dark:bg-green-950/30 dark:text-green-300',
83
+ removed: 'bg-red-50 text-red-800 dark:bg-red-950/30 dark:text-red-300',
84
+ unchanged: 'text-muted-foreground',
85
+ };
86
+
87
+ const LINE_PREFIX: Record<DiffLine['type'], string> = {
88
+ added: '+',
89
+ removed: '-',
90
+ unchanged: ' ',
91
+ };
92
+
93
+ export const DiffView: React.FC<DiffViewProps> = ({
94
+ oldValue,
95
+ newValue,
96
+ fieldName,
97
+ fieldType = 'string',
98
+ mode: initialMode = 'unified',
99
+ className,
100
+ }) => {
101
+ const [mode, setMode] = React.useState<DiffMode>(initialMode);
102
+
103
+ const oldLines = React.useMemo(() => valueToLines(oldValue, fieldType), [oldValue, fieldType]);
104
+ const newLines = React.useMemo(() => valueToLines(newValue, fieldType), [newValue, fieldType]);
105
+ const diffLines = React.useMemo(() => computeDiff(oldLines, newLines), [oldLines, newLines]);
106
+
107
+ const hasChanges = diffLines.some((l) => l.type !== 'unchanged');
108
+
109
+ // Build side-by-side pairs
110
+ const sideBySidePairs = React.useMemo(() => {
111
+ if (mode !== 'side-by-side') return [];
112
+
113
+ const pairs: { left: DiffLine | null; right: DiffLine | null }[] = [];
114
+ let i = 0;
115
+ while (i < diffLines.length) {
116
+ const line = diffLines[i];
117
+ if (line.type === 'unchanged') {
118
+ pairs.push({ left: line, right: line });
119
+ i++;
120
+ } else if (line.type === 'removed') {
121
+ // Check if next is 'added' to pair them
122
+ const next = i + 1 < diffLines.length ? diffLines[i + 1] : null;
123
+ if (next && next.type === 'added') {
124
+ pairs.push({ left: line, right: next });
125
+ i += 2;
126
+ } else {
127
+ pairs.push({ left: line, right: null });
128
+ i++;
129
+ }
130
+ } else {
131
+ pairs.push({ left: null, right: line });
132
+ i++;
133
+ }
134
+ }
135
+ return pairs;
136
+ }, [mode, diffLines]);
137
+
138
+ return (
139
+ <Card className={cn('overflow-hidden', className)}>
140
+ <CardHeader className="pb-2">
141
+ <CardTitle className="flex items-center justify-between text-sm">
142
+ <span className="font-medium">{fieldName}</span>
143
+ <div className="flex items-center gap-1">
144
+ <Button
145
+ variant={mode === 'unified' ? 'secondary' : 'ghost'}
146
+ size="icon"
147
+ className="h-7 w-7"
148
+ onClick={() => setMode('unified')}
149
+ title="Unified diff"
150
+ >
151
+ <Rows3 className="h-3.5 w-3.5" />
152
+ </Button>
153
+ <Button
154
+ variant={mode === 'side-by-side' ? 'secondary' : 'ghost'}
155
+ size="icon"
156
+ className="h-7 w-7"
157
+ onClick={() => setMode('side-by-side')}
158
+ title="Side-by-side diff"
159
+ >
160
+ <Columns2 className="h-3.5 w-3.5" />
161
+ </Button>
162
+ </div>
163
+ </CardTitle>
164
+ </CardHeader>
165
+ <CardContent className="p-0">
166
+ {!hasChanges ? (
167
+ <p className="px-4 py-3 text-sm text-muted-foreground">No changes</p>
168
+ ) : mode === 'unified' ? (
169
+ /* Unified diff view */
170
+ <div className="font-mono text-xs overflow-x-auto">
171
+ {diffLines.map((line, index) => (
172
+ <div
173
+ key={index}
174
+ className={cn(
175
+ 'px-4 py-0.5 whitespace-pre-wrap border-l-2',
176
+ LINE_STYLES[line.type],
177
+ line.type === 'added' && 'border-l-green-500',
178
+ line.type === 'removed' && 'border-l-red-500',
179
+ line.type === 'unchanged' && 'border-l-transparent',
180
+ )}
181
+ >
182
+ <span className="select-none mr-2 inline-block w-3 text-center opacity-60">
183
+ {LINE_PREFIX[line.type]}
184
+ </span>
185
+ {line.value}
186
+ </div>
187
+ ))}
188
+ </div>
189
+ ) : (
190
+ /* Side-by-side diff view */
191
+ <div className="overflow-x-auto">
192
+ <div className="grid grid-cols-2 divide-x font-mono text-xs min-w-0">
193
+ {/* Headers */}
194
+ <div className="px-3 py-1.5 text-xs font-medium text-muted-foreground bg-muted/50">
195
+ Previous
196
+ </div>
197
+ <div className="px-3 py-1.5 text-xs font-medium text-muted-foreground bg-muted/50">
198
+ Current
199
+ </div>
200
+ {/* Rows */}
201
+ {sideBySidePairs.map((pair, index) => (
202
+ <React.Fragment key={index}>
203
+ <div
204
+ className={cn(
205
+ 'px-3 py-0.5 whitespace-pre-wrap min-h-[1.5em]',
206
+ pair.left
207
+ ? LINE_STYLES[pair.left.type]
208
+ : 'bg-muted/20',
209
+ )}
210
+ >
211
+ {pair.left?.value ?? ''}
212
+ </div>
213
+ <div
214
+ className={cn(
215
+ 'px-3 py-0.5 whitespace-pre-wrap min-h-[1.5em]',
216
+ pair.right
217
+ ? LINE_STYLES[pair.right.type]
218
+ : 'bg-muted/20',
219
+ )}
220
+ >
221
+ {pair.right?.value ?? ''}
222
+ </div>
223
+ </React.Fragment>
224
+ ))}
225
+ </div>
226
+ </div>
227
+ )}
228
+ </CardContent>
229
+ </Card>
230
+ );
231
+ };
@@ -0,0 +1,46 @@
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 } from '@object-ui/components';
11
+ import { ArrowRight } from 'lucide-react';
12
+ import type { FieldChangeEntry } from '@object-ui/types';
13
+
14
+ export interface FieldChangeItemProps {
15
+ /** The field change entry to render */
16
+ change: FieldChangeEntry;
17
+ className?: string;
18
+ }
19
+
20
+ /**
21
+ * FieldChangeItem — Renders a single field change entry.
22
+ * Shows: field label → old value → new value with human-readable display values.
23
+ * Aligned with @objectstack/spec FieldChangeEntrySchema.
24
+ */
25
+ export const FieldChangeItem: React.FC<FieldChangeItemProps> = ({
26
+ change,
27
+ className,
28
+ }) => {
29
+ const label =
30
+ change.fieldLabel ??
31
+ change.field.charAt(0).toUpperCase() + change.field.slice(1).replace(/_/g, ' ');
32
+
33
+ const oldDisplay =
34
+ change.oldDisplayValue ?? (change.oldValue != null ? String(change.oldValue) : '(empty)');
35
+ const newDisplay =
36
+ change.newDisplayValue ?? (change.newValue != null ? String(change.newValue) : '(empty)');
37
+
38
+ return (
39
+ <div className={cn('flex items-center gap-1.5 text-sm flex-wrap', className)}>
40
+ <span className="font-medium text-foreground">{label}</span>
41
+ <span className="text-muted-foreground line-through">{oldDisplay}</span>
42
+ <ArrowRight className="h-3 w-3 text-muted-foreground shrink-0" />
43
+ <span className="text-foreground">{newDisplay}</span>
44
+ </div>
45
+ );
46
+ };
@@ -0,0 +1,67 @@
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, CardContent } from '@object-ui/components';
11
+ import type { HighlightField } from '@object-ui/types';
12
+ import { useSafeFieldLabel } from '@object-ui/react';
13
+
14
+ export interface HeaderHighlightProps {
15
+ fields: HighlightField[];
16
+ data?: any;
17
+ className?: string;
18
+ /** Object name for i18n field label resolution */
19
+ objectName?: string;
20
+ }
21
+
22
+ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
23
+ fields,
24
+ data,
25
+ className,
26
+ objectName,
27
+ }) => {
28
+ const { fieldLabel } = useSafeFieldLabel();
29
+ if (!fields.length || !data) return null;
30
+
31
+ // Filter to only fields with values
32
+ const visibleFields = fields.filter((f) => {
33
+ const val = data?.[f.name];
34
+ return val !== null && val !== undefined && val !== '';
35
+ });
36
+
37
+ if (visibleFields.length === 0) return null;
38
+
39
+ return (
40
+ <Card className={cn('bg-muted/30 border-dashed', className)}>
41
+ <CardContent className="py-3 px-4">
42
+ <div className={cn(
43
+ 'grid gap-4',
44
+ visibleFields.length === 1 ? 'grid-cols-1' :
45
+ visibleFields.length === 2 ? 'grid-cols-2' :
46
+ visibleFields.length === 3 ? 'grid-cols-3' :
47
+ 'grid-cols-2 md:grid-cols-4'
48
+ )}>
49
+ {visibleFields.map((field) => {
50
+ const value = data[field.name];
51
+ return (
52
+ <div key={field.name} className="flex flex-col gap-0.5">
53
+ <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
54
+ {field.icon && <span className="mr-1">{field.icon}</span>}
55
+ {fieldLabel(objectName || '', field.name, field.label)}
56
+ </span>
57
+ <span className="text-sm font-semibold truncate">
58
+ {String(value)}
59
+ </span>
60
+ </div>
61
+ );
62
+ })}
63
+ </div>
64
+ </CardContent>
65
+ </Card>
66
+ );
67
+ };
@@ -0,0 +1,291 @@
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 {
11
+ cn,
12
+ Button,
13
+ Card,
14
+ CardHeader,
15
+ CardTitle,
16
+ CardContent,
17
+ Input,
18
+ Tabs,
19
+ TabsList,
20
+ TabsTrigger,
21
+ TabsContent,
22
+ } from '@object-ui/components';
23
+ import { Plus, Link, Search, X, Loader2 } from 'lucide-react';
24
+
25
+ export interface RelatedFieldDefinition {
26
+ name: string;
27
+ label: string;
28
+ type: 'string' | 'number' | 'boolean' | 'date';
29
+ required?: boolean;
30
+ placeholder?: string;
31
+ }
32
+
33
+ export interface RelatedRecordOption {
34
+ id: string;
35
+ label: string;
36
+ description?: string;
37
+ }
38
+
39
+ export interface InlineCreateRelatedProps {
40
+ objectName: string;
41
+ relationshipField: string;
42
+ fields: RelatedFieldDefinition[];
43
+ onCreateRecord?: (values: Record<string, any>) => void | Promise<void>;
44
+ onLinkRecord?: (recordId: string) => void | Promise<void>;
45
+ onSearch?: (query: string) => Promise<RelatedRecordOption[]>;
46
+ existingRecords?: RelatedRecordOption[];
47
+ className?: string;
48
+ }
49
+
50
+ export const InlineCreateRelated: React.FC<InlineCreateRelatedProps> = ({
51
+ objectName,
52
+ relationshipField,
53
+ fields,
54
+ onCreateRecord,
55
+ onLinkRecord,
56
+ onSearch,
57
+ existingRecords = [],
58
+ className,
59
+ }) => {
60
+ const [isOpen, setIsOpen] = React.useState(false);
61
+ const [activeTab, setActiveTab] = React.useState<string>('create');
62
+ const [formValues, setFormValues] = React.useState<Record<string, any>>({});
63
+ const [searchQuery, setSearchQuery] = React.useState('');
64
+ const [searchResults, setSearchResults] = React.useState<RelatedRecordOption[]>(existingRecords);
65
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
66
+ const [isSearching, setIsSearching] = React.useState(false);
67
+
68
+ const filteredResults = React.useMemo(() => {
69
+ if (!searchQuery.trim()) return searchResults;
70
+ const query = searchQuery.toLowerCase();
71
+ return searchResults.filter(
72
+ (r) =>
73
+ r.label.toLowerCase().includes(query) ||
74
+ r.description?.toLowerCase().includes(query),
75
+ );
76
+ }, [searchQuery, searchResults]);
77
+
78
+ const handleSearchChange = React.useCallback(
79
+ async (value: string) => {
80
+ setSearchQuery(value);
81
+ if (onSearch && value.trim().length >= 2) {
82
+ setIsSearching(true);
83
+ try {
84
+ const results = await onSearch(value);
85
+ setSearchResults(results);
86
+ } finally {
87
+ setIsSearching(false);
88
+ }
89
+ }
90
+ },
91
+ [onSearch],
92
+ );
93
+
94
+ const handleFieldChange = React.useCallback(
95
+ (fieldName: string, value: string) => {
96
+ setFormValues((prev) => ({ ...prev, [fieldName]: value }));
97
+ },
98
+ [],
99
+ );
100
+
101
+ const handleCreate = React.useCallback(async () => {
102
+ if (!onCreateRecord) return;
103
+ setIsSubmitting(true);
104
+ try {
105
+ await onCreateRecord({ ...formValues, [relationshipField]: true });
106
+ setFormValues({});
107
+ setIsOpen(false);
108
+ } finally {
109
+ setIsSubmitting(false);
110
+ }
111
+ }, [onCreateRecord, formValues, relationshipField]);
112
+
113
+ const handleLink = React.useCallback(
114
+ async (recordId: string) => {
115
+ if (!onLinkRecord) return;
116
+ setIsSubmitting(true);
117
+ try {
118
+ await onLinkRecord(recordId);
119
+ setSearchQuery('');
120
+ setIsOpen(false);
121
+ } finally {
122
+ setIsSubmitting(false);
123
+ }
124
+ },
125
+ [onLinkRecord],
126
+ );
127
+
128
+ const isCreateValid = React.useMemo(() => {
129
+ return fields
130
+ .filter((f) => f.required)
131
+ .every((f) => formValues[f.name]?.toString().trim());
132
+ }, [fields, formValues]);
133
+
134
+ if (!isOpen) {
135
+ return (
136
+ <div className={cn('flex gap-2', className)}>
137
+ {onCreateRecord && (
138
+ <Button
139
+ variant="outline"
140
+ size="sm"
141
+ onClick={() => {
142
+ setActiveTab('create');
143
+ setIsOpen(true);
144
+ }}
145
+ className="gap-1.5"
146
+ >
147
+ <Plus className="h-3.5 w-3.5" />
148
+ New {objectName}
149
+ </Button>
150
+ )}
151
+ {onLinkRecord && (
152
+ <Button
153
+ variant="outline"
154
+ size="sm"
155
+ onClick={() => {
156
+ setActiveTab('link');
157
+ setIsOpen(true);
158
+ }}
159
+ className="gap-1.5"
160
+ >
161
+ <Link className="h-3.5 w-3.5" />
162
+ Link Existing
163
+ </Button>
164
+ )}
165
+ </div>
166
+ );
167
+ }
168
+
169
+ return (
170
+ <Card className={cn('', className)}>
171
+ <CardHeader className="pb-3">
172
+ <CardTitle className="flex items-center justify-between text-sm">
173
+ <span>
174
+ {activeTab === 'create' ? 'Create' : 'Link'} {objectName}
175
+ </span>
176
+ <Button
177
+ variant="ghost"
178
+ size="icon"
179
+ className="h-6 w-6"
180
+ onClick={() => setIsOpen(false)}
181
+ >
182
+ <X className="h-3.5 w-3.5" />
183
+ </Button>
184
+ </CardTitle>
185
+ </CardHeader>
186
+ <CardContent>
187
+ <Tabs value={activeTab} onValueChange={setActiveTab}>
188
+ <TabsList className="mb-3 w-full">
189
+ {onCreateRecord && (
190
+ <TabsTrigger value="create" className="flex-1 gap-1.5">
191
+ <Plus className="h-3.5 w-3.5" />
192
+ Create New
193
+ </TabsTrigger>
194
+ )}
195
+ {onLinkRecord && (
196
+ <TabsTrigger value="link" className="flex-1 gap-1.5">
197
+ <Link className="h-3.5 w-3.5" />
198
+ Link Existing
199
+ </TabsTrigger>
200
+ )}
201
+ </TabsList>
202
+
203
+ {/* Create New Tab */}
204
+ {onCreateRecord && (
205
+ <TabsContent value="create" className="space-y-3 mt-0">
206
+ {fields.map((field) => (
207
+ <div key={field.name}>
208
+ <label className="text-xs font-medium text-muted-foreground mb-1 block">
209
+ {field.label}
210
+ {field.required && (
211
+ <span className="text-destructive ml-0.5">*</span>
212
+ )}
213
+ </label>
214
+ <Input
215
+ type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
216
+ placeholder={field.placeholder || `Enter ${field.label.toLowerCase()}`}
217
+ value={formValues[field.name] || ''}
218
+ onChange={(e) => handleFieldChange(field.name, e.target.value)}
219
+ className="h-8 text-sm"
220
+ />
221
+ </div>
222
+ ))}
223
+ <div className="flex justify-end gap-2 pt-1">
224
+ <Button
225
+ variant="ghost"
226
+ size="sm"
227
+ onClick={() => setIsOpen(false)}
228
+ >
229
+ Cancel
230
+ </Button>
231
+ <Button
232
+ size="sm"
233
+ onClick={handleCreate}
234
+ disabled={!isCreateValid || isSubmitting}
235
+ className="gap-1.5"
236
+ >
237
+ {isSubmitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
238
+ Create
239
+ </Button>
240
+ </div>
241
+ </TabsContent>
242
+ )}
243
+
244
+ {/* Link Existing Tab */}
245
+ {onLinkRecord && (
246
+ <TabsContent value="link" className="space-y-3 mt-0">
247
+ <div className="relative">
248
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
249
+ <Input
250
+ placeholder={`Search ${objectName}…`}
251
+ value={searchQuery}
252
+ onChange={(e) => handleSearchChange(e.target.value)}
253
+ className="h-8 text-sm pl-8"
254
+ />
255
+ </div>
256
+ <div className="max-h-48 overflow-y-auto space-y-1">
257
+ {isSearching ? (
258
+ <div className="flex items-center justify-center py-4 text-sm text-muted-foreground">
259
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
260
+ Searching…
261
+ </div>
262
+ ) : filteredResults.length === 0 ? (
263
+ <p className="text-sm text-muted-foreground text-center py-4">
264
+ {searchQuery ? 'No records found' : 'Type to search records'}
265
+ </p>
266
+ ) : (
267
+ filteredResults.map((record) => (
268
+ <button
269
+ key={record.id}
270
+ type="button"
271
+ className="w-full text-left px-3 py-2 rounded-md hover:bg-accent text-sm transition-colors"
272
+ onClick={() => handleLink(record.id)}
273
+ disabled={isSubmitting}
274
+ >
275
+ <span className="font-medium">{record.label}</span>
276
+ {record.description && (
277
+ <span className="block text-xs text-muted-foreground mt-0.5">
278
+ {record.description}
279
+ </span>
280
+ )}
281
+ </button>
282
+ ))
283
+ )}
284
+ </div>
285
+ </TabsContent>
286
+ )}
287
+ </Tabs>
288
+ </CardContent>
289
+ </Card>
290
+ );
291
+ };