@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.
- package/.turbo/turbo-build.log +45 -8
- package/CHANGELOG.md +11 -0
- package/dist/AddressField-B1iVr404.js +96 -0
- package/dist/AutoNumberField-BxnFqllo.js +8 -0
- package/dist/AvatarField-Duw4xOLZ.js +82 -0
- package/dist/BooleanField-CZ4axVeq.js +37 -0
- package/dist/CodeField-BSz-mk2v.js +21 -0
- package/dist/ColorField-B522ad8m.js +42 -0
- package/dist/CurrencyField-Cwr3_pow.js +43 -0
- package/dist/DateField-DCo6dxud.js +21 -0
- package/dist/DateTimeField-BWfBuANO.js +28 -0
- package/dist/EmailField-CpwbdVCU.js +31 -0
- package/dist/FileField-DVAUAJ8e.js +133 -0
- package/dist/FormulaField-CJkkwIK8.js +9 -0
- package/dist/GeolocationField-DNCKitgo.js +123 -0
- package/dist/GridField-DSblZNfp.js +30 -0
- package/dist/ImageField-DBAlnMon.js +90 -0
- package/dist/LocationField-DsHsXA6R.js +31 -0
- package/dist/LookupField-CsT0QQz2.js +96 -0
- package/dist/MasterDetailField-Db8b7Gqs.js +108 -0
- package/dist/NumberField-0IGp7lcA.js +26 -0
- package/dist/ObjectField-BLApgJtS.js +48 -0
- package/dist/PasswordField-pHKyNlmo.js +38 -0
- package/dist/PercentField-CwgKmlIb.js +63 -0
- package/dist/PhoneField-lKtbYOdN.js +31 -0
- package/dist/QRCodeField-BTTasT3w.js +77 -0
- package/dist/RatingField-De2X-l44.js +47 -0
- package/dist/RichTextField-B5QnvUOr.js +38 -0
- package/dist/SelectField-C9AZRHWu.js +26 -0
- package/dist/SignatureField-BgcEmYzd.js +85 -0
- package/dist/SliderField-BzrttVOY.js +30 -0
- package/dist/SummaryField-ugYPYxjP.js +9 -0
- package/dist/TextAreaField-DSE_CaU6.js +39 -0
- package/dist/TextField-DFQ4T9PR.js +32 -0
- package/dist/TimeField-F0cfmsps.js +21 -0
- package/dist/UrlField-DLXrFIH-.js +33 -0
- package/dist/UserField-PXMmxJY9.js +49 -0
- package/dist/VectorField-CKg9jdGa.js +25 -0
- package/dist/index-qQ1C-yUR.js +59976 -0
- package/dist/index.js +32 -55026
- package/dist/index.umd.cjs +41 -30
- package/dist/plugin-detail.css +1 -1
- package/dist/src/ActivityTimeline.d.ts +20 -0
- package/dist/src/ActivityTimeline.d.ts.map +1 -0
- package/dist/src/CommentAttachment.d.ts +25 -0
- package/dist/src/CommentAttachment.d.ts.map +1 -0
- package/dist/src/CommentInput.d.ts +24 -0
- package/dist/src/CommentInput.d.ts.map +1 -0
- package/dist/src/DetailSection.d.ts +8 -0
- package/dist/src/DetailSection.d.ts.map +1 -1
- package/dist/src/DetailView.d.ts +4 -0
- package/dist/src/DetailView.d.ts.map +1 -1
- package/dist/src/DetailView.stories.d.ts +8 -0
- package/dist/src/DetailView.stories.d.ts.map +1 -1
- package/dist/src/DiffView.d.ts +24 -0
- package/dist/src/DiffView.d.ts.map +1 -0
- package/dist/src/FieldChangeItem.d.ts +21 -0
- package/dist/src/FieldChangeItem.d.ts.map +1 -0
- package/dist/src/HeaderHighlight.d.ts +18 -0
- package/dist/src/HeaderHighlight.d.ts.map +1 -0
- package/dist/src/InlineCreateRelated.d.ts +32 -0
- package/dist/src/InlineCreateRelated.d.ts.map +1 -0
- package/dist/src/MentionAutocomplete.d.ts +43 -0
- package/dist/src/MentionAutocomplete.d.ts.map +1 -0
- package/dist/src/PointInTimeRestore.d.ts +28 -0
- package/dist/src/PointInTimeRestore.d.ts.map +1 -0
- package/dist/src/ReactionPicker.d.ts +25 -0
- package/dist/src/ReactionPicker.d.ts.map +1 -0
- package/dist/src/RecordActivityTimeline.d.ts +49 -0
- package/dist/src/RecordActivityTimeline.d.ts.map +1 -0
- package/dist/src/RecordChatterPanel.d.ts +48 -0
- package/dist/src/RecordChatterPanel.d.ts.map +1 -0
- package/dist/src/RecordComments.d.ts +20 -0
- package/dist/src/RecordComments.d.ts.map +1 -0
- package/dist/src/RecordNavigationEnhanced.d.ts +18 -0
- package/dist/src/RecordNavigationEnhanced.d.ts.map +1 -0
- package/dist/src/RelatedList.d.ts +20 -0
- package/dist/src/RelatedList.d.ts.map +1 -1
- package/dist/src/RelationshipGraph.d.ts +23 -0
- package/dist/src/RelationshipGraph.d.ts.map +1 -0
- package/dist/src/RichTextCommentInput.d.ts +24 -0
- package/dist/src/RichTextCommentInput.d.ts.map +1 -0
- package/dist/src/SectionGroup.d.ts +21 -0
- package/dist/src/SectionGroup.d.ts.map +1 -0
- package/dist/src/SubscriptionToggle.d.ts +22 -0
- package/dist/src/SubscriptionToggle.d.ts.map +1 -0
- package/dist/src/ThreadedReplies.d.ts +26 -0
- package/dist/src/ThreadedReplies.d.ts.map +1 -0
- package/dist/src/autoLayout.d.ts +34 -0
- package/dist/src/autoLayout.d.ts.map +1 -0
- package/dist/src/index.d.ts +40 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/useDetailTranslation.d.ts +34 -0
- package/dist/src/useDetailTranslation.d.ts.map +1 -0
- package/package.json +8 -7
- package/src/ActivityTimeline.tsx +184 -0
- package/src/CommentAttachment.tsx +192 -0
- package/src/CommentInput.tsx +81 -0
- package/src/DetailSection.tsx +81 -10
- package/src/DetailView.stories.tsx +76 -0
- package/src/DetailView.tsx +519 -66
- package/src/DiffView.tsx +231 -0
- package/src/FieldChangeItem.tsx +46 -0
- package/src/HeaderHighlight.tsx +67 -0
- package/src/InlineCreateRelated.tsx +291 -0
- package/src/MentionAutocomplete.tsx +123 -0
- package/src/PointInTimeRestore.tsx +261 -0
- package/src/ReactionPicker.tsx +106 -0
- package/src/RecordActivityTimeline.tsx +429 -0
- package/src/RecordChatterPanel.tsx +202 -0
- package/src/RecordComments.tsx +215 -0
- package/src/RecordNavigationEnhanced.tsx +211 -0
- package/src/RelatedList.tsx +314 -19
- package/src/RelationshipGraph.tsx +286 -0
- package/src/RichTextCommentInput.tsx +348 -0
- package/src/SectionGroup.tsx +101 -0
- package/src/SubscriptionToggle.tsx +60 -0
- package/src/ThreadedReplies.tsx +161 -0
- package/src/__tests__/ActivityTimeline.test.tsx +119 -0
- package/src/__tests__/ActivityTimelineFiltering.test.tsx +143 -0
- package/src/__tests__/CommentInput.test.tsx +57 -0
- package/src/__tests__/DetailSection.test.tsx +320 -0
- package/src/__tests__/DetailView.test.tsx +415 -1
- package/src/__tests__/FieldChangeItem.test.tsx +119 -0
- package/src/__tests__/HeaderHighlight.test.tsx +68 -0
- package/src/__tests__/MentionAutocomplete.test.tsx +97 -0
- package/src/__tests__/ReactionPicker.test.tsx +113 -0
- package/src/__tests__/RecordActivityTimeline.test.tsx +395 -0
- package/src/__tests__/RecordChatterPanel.test.tsx +227 -0
- package/src/__tests__/RecordComments.test.tsx +96 -0
- package/src/__tests__/RecordCommentsPinSearch.test.tsx +133 -0
- package/src/__tests__/RelatedList.test.tsx +160 -0
- package/src/__tests__/SectionGroup.test.tsx +101 -0
- package/src/__tests__/SubscriptionToggle.test.tsx +84 -0
- package/src/__tests__/ThreadedReplies.test.tsx +212 -0
- package/src/__tests__/autoLayout.test.ts +184 -0
- package/src/__tests__/phase12-features.test.tsx +583 -0
- package/src/__tests__/roadmap-features.test.tsx +478 -0
- package/src/autoLayout.ts +111 -0
- package/src/index.tsx +50 -0
- package/src/useDetailTranslation.ts +114 -0
package/src/DiffView.tsx
ADDED
|
@@ -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
|
+
};
|