@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
package/src/DiffView.tsx DELETED
@@ -1,233 +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, Card, CardHeader, CardTitle, CardContent } from '@object-ui/components';
11
- import { Columns2, Rows3 } from 'lucide-react';
12
- import { useDetailTranslation } from './useDetailTranslation';
13
-
14
- export type DiffFieldType = 'string' | 'number' | 'boolean' | 'json' | 'date';
15
- export type DiffMode = 'unified' | 'side-by-side';
16
-
17
- export interface DiffLine {
18
- type: 'added' | 'removed' | 'unchanged';
19
- value: string;
20
- }
21
-
22
- export interface DiffViewProps {
23
- oldValue: any;
24
- newValue: any;
25
- fieldName: string;
26
- fieldType?: DiffFieldType;
27
- mode?: DiffMode;
28
- className?: string;
29
- }
30
-
31
- /** Convert a value to diffable string lines based on its type. */
32
- function valueToLines(value: any, fieldType: DiffFieldType): string[] {
33
- if (value == null) return ['(empty)'];
34
-
35
- switch (fieldType) {
36
- case 'json':
37
- try {
38
- const parsed = typeof value === 'string' ? JSON.parse(value) : value;
39
- return JSON.stringify(parsed, null, 2).split('\n');
40
- } catch {
41
- return String(value).split('\n');
42
- }
43
- case 'boolean':
44
- return [String(!!value)];
45
- case 'number':
46
- return [String(value)];
47
- case 'date':
48
- try {
49
- return [new Date(value).toLocaleString()];
50
- } catch {
51
- return [String(value)];
52
- }
53
- default:
54
- return String(value).split('\n');
55
- }
56
- }
57
-
58
- /** Compute a simple line-by-line diff between old and new lines. */
59
- function computeDiff(oldLines: string[], newLines: string[]): DiffLine[] {
60
- const result: DiffLine[] = [];
61
- const maxLen = Math.max(oldLines.length, newLines.length);
62
-
63
- for (let i = 0; i < maxLen; i++) {
64
- const oldLine = i < oldLines.length ? oldLines[i] : undefined;
65
- const newLine = i < newLines.length ? newLines[i] : undefined;
66
-
67
- if (oldLine === newLine) {
68
- result.push({ type: 'unchanged', value: oldLine! });
69
- } else {
70
- if (oldLine !== undefined) {
71
- result.push({ type: 'removed', value: oldLine });
72
- }
73
- if (newLine !== undefined) {
74
- result.push({ type: 'added', value: newLine });
75
- }
76
- }
77
- }
78
-
79
- return result;
80
- }
81
-
82
- const LINE_STYLES: Record<DiffLine['type'], string> = {
83
- added: 'bg-green-50 text-green-800 dark:bg-green-950/30 dark:text-green-300',
84
- removed: 'bg-red-50 text-red-800 dark:bg-red-950/30 dark:text-red-300',
85
- unchanged: 'text-muted-foreground',
86
- };
87
-
88
- const LINE_PREFIX: Record<DiffLine['type'], string> = {
89
- added: '+',
90
- removed: '-',
91
- unchanged: ' ',
92
- };
93
-
94
- export const DiffView: React.FC<DiffViewProps> = ({
95
- oldValue,
96
- newValue,
97
- fieldName,
98
- fieldType = 'string',
99
- mode: initialMode = 'unified',
100
- className,
101
- }) => {
102
- const { t } = useDetailTranslation();
103
- const [mode, setMode] = React.useState<DiffMode>(initialMode);
104
-
105
- const oldLines = React.useMemo(() => valueToLines(oldValue, fieldType), [oldValue, fieldType]);
106
- const newLines = React.useMemo(() => valueToLines(newValue, fieldType), [newValue, fieldType]);
107
- const diffLines = React.useMemo(() => computeDiff(oldLines, newLines), [oldLines, newLines]);
108
-
109
- const hasChanges = diffLines.some((l) => l.type !== 'unchanged');
110
-
111
- // Build side-by-side pairs
112
- const sideBySidePairs = React.useMemo(() => {
113
- if (mode !== 'side-by-side') return [];
114
-
115
- const pairs: { left: DiffLine | null; right: DiffLine | null }[] = [];
116
- let i = 0;
117
- while (i < diffLines.length) {
118
- const line = diffLines[i];
119
- if (line.type === 'unchanged') {
120
- pairs.push({ left: line, right: line });
121
- i++;
122
- } else if (line.type === 'removed') {
123
- // Check if next is 'added' to pair them
124
- const next = i + 1 < diffLines.length ? diffLines[i + 1] : null;
125
- if (next && next.type === 'added') {
126
- pairs.push({ left: line, right: next });
127
- i += 2;
128
- } else {
129
- pairs.push({ left: line, right: null });
130
- i++;
131
- }
132
- } else {
133
- pairs.push({ left: null, right: line });
134
- i++;
135
- }
136
- }
137
- return pairs;
138
- }, [mode, diffLines]);
139
-
140
- return (
141
- <Card className={cn('overflow-hidden', className)}>
142
- <CardHeader className="pb-2">
143
- <CardTitle className="flex items-center justify-between text-sm">
144
- <span className="font-medium">{fieldName}</span>
145
- <div className="flex items-center gap-1">
146
- <Button
147
- variant={mode === 'unified' ? 'secondary' : 'ghost'}
148
- size="icon"
149
- className="h-7 w-7"
150
- onClick={() => setMode('unified')}
151
- title={t('detail.unifiedDiff')}
152
- >
153
- <Rows3 className="h-3.5 w-3.5" />
154
- </Button>
155
- <Button
156
- variant={mode === 'side-by-side' ? 'secondary' : 'ghost'}
157
- size="icon"
158
- className="h-7 w-7"
159
- onClick={() => setMode('side-by-side')}
160
- title={t('detail.sideBySideDiff')}
161
- >
162
- <Columns2 className="h-3.5 w-3.5" />
163
- </Button>
164
- </div>
165
- </CardTitle>
166
- </CardHeader>
167
- <CardContent className="p-0">
168
- {!hasChanges ? (
169
- <p className="px-4 py-3 text-sm text-muted-foreground">{t('detail.noChanges')}</p>
170
- ) : mode === 'unified' ? (
171
- /* Unified diff view */
172
- <div className="font-mono text-xs overflow-x-auto">
173
- {diffLines.map((line, index) => (
174
- <div
175
- key={index}
176
- className={cn(
177
- 'px-4 py-0.5 whitespace-pre-wrap border-l-2',
178
- LINE_STYLES[line.type],
179
- line.type === 'added' && 'border-l-green-500',
180
- line.type === 'removed' && 'border-l-red-500',
181
- line.type === 'unchanged' && 'border-l-transparent',
182
- )}
183
- >
184
- <span className="select-none mr-2 inline-block w-3 text-center opacity-60">
185
- {LINE_PREFIX[line.type]}
186
- </span>
187
- {line.value}
188
- </div>
189
- ))}
190
- </div>
191
- ) : (
192
- /* Side-by-side diff view */
193
- <div className="overflow-x-auto">
194
- <div className="grid grid-cols-2 divide-x font-mono text-xs min-w-0">
195
- {/* Headers */}
196
- <div className="px-3 py-1.5 text-xs font-medium text-muted-foreground bg-muted/50">
197
- {t('detail.previousVersion')}
198
- </div>
199
- <div className="px-3 py-1.5 text-xs font-medium text-muted-foreground bg-muted/50">
200
- {t('detail.currentVersion')}
201
- </div>
202
- {/* Rows */}
203
- {sideBySidePairs.map((pair, index) => (
204
- <React.Fragment key={index}>
205
- <div
206
- className={cn(
207
- 'px-3 py-0.5 whitespace-pre-wrap min-h-[1.5em]',
208
- pair.left
209
- ? LINE_STYLES[pair.left.type]
210
- : 'bg-muted/20',
211
- )}
212
- >
213
- {pair.left?.value ?? ''}
214
- </div>
215
- <div
216
- className={cn(
217
- 'px-3 py-0.5 whitespace-pre-wrap min-h-[1.5em]',
218
- pair.right
219
- ? LINE_STYLES[pair.right.type]
220
- : 'bg-muted/20',
221
- )}
222
- >
223
- {pair.right?.value ?? ''}
224
- </div>
225
- </React.Fragment>
226
- ))}
227
- </div>
228
- </div>
229
- )}
230
- </CardContent>
231
- </Card>
232
- );
233
- };
@@ -1,46 +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 } 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
- };
@@ -1,88 +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, CardContent } from '@object-ui/components';
11
- import type { HighlightField } from '@object-ui/types';
12
- import { getCellRenderer } from '@object-ui/fields';
13
- import { useSafeFieldLabel } from '@object-ui/react';
14
-
15
- export interface HeaderHighlightProps {
16
- fields: HighlightField[];
17
- data?: any;
18
- className?: string;
19
- /** Object name for i18n field label resolution */
20
- objectName?: string;
21
- /** Object schema for field metadata enrichment */
22
- objectSchema?: any;
23
- }
24
-
25
- export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
26
- fields,
27
- data,
28
- className,
29
- objectName,
30
- objectSchema,
31
- }) => {
32
- const { fieldLabel } = useSafeFieldLabel();
33
- if (!fields.length || !data) return null;
34
-
35
- // Filter to only fields with values
36
- const visibleFields = fields.filter((f) => {
37
- const val = data?.[f.name];
38
- return val !== null && val !== undefined && val !== '';
39
- });
40
-
41
- if (visibleFields.length === 0) return null;
42
-
43
- return (
44
- <Card className={cn('bg-muted/30 border-dashed', className)}>
45
- <CardContent className="py-3 px-4">
46
- <div className={cn(
47
- 'grid gap-4',
48
- visibleFields.length === 1 ? 'grid-cols-1' :
49
- visibleFields.length === 2 ? 'grid-cols-2' :
50
- visibleFields.length === 3 ? 'grid-cols-3' :
51
- 'grid-cols-2 md:grid-cols-4'
52
- )}>
53
- {visibleFields.map((field) => {
54
- const value = data[field.name];
55
- // Enrich field metadata from objectSchema
56
- const objectDefField = objectSchema?.fields?.[field.name];
57
- const resolvedType = field.type || objectDefField?.type;
58
- const enrichedField = {
59
- name: field.name,
60
- label: field.label,
61
- type: resolvedType || 'text',
62
- ...(objectDefField?.options && { options: objectDefField.options }),
63
- ...(objectDefField?.currency && { currency: objectDefField.currency }),
64
- ...(objectDefField?.precision !== undefined && { precision: objectDefField.precision }),
65
- ...(objectDefField?.format && { format: objectDefField.format }),
66
- };
67
-
68
- // Use type-aware cell renderer — all renderers coerce values via
69
- // coerceToSafeValue() so even object/array data is safe (no error #310).
70
- const CellRenderer = getCellRenderer(resolvedType || 'text');
71
-
72
- return (
73
- <div key={field.name} className="flex flex-col gap-0.5">
74
- <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
75
- {field.icon && <span className="mr-1">{field.icon}</span>}
76
- {fieldLabel(objectName || '', field.name, field.label)}
77
- </span>
78
- <span className="text-sm font-semibold truncate">
79
- <CellRenderer value={value} field={enrichedField as any} />
80
- </span>
81
- </div>
82
- );
83
- })}
84
- </div>
85
- </CardContent>
86
- </Card>
87
- );
88
- };
@@ -1,291 +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 {
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
- };