@object-ui/plugin-detail 3.0.2 → 3.1.0
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 +9 -0
- package/dist/AddressField-C07oUOY6.js +96 -0
- package/dist/AutoNumberField-BxnFqllo.js +8 -0
- package/dist/AvatarField-VThNABzo.js +82 -0
- package/dist/BooleanField-CGHKBzAi.js +37 -0
- package/dist/CodeField-Co_muhRR.js +21 -0
- package/dist/ColorField-DLid_tFz.js +42 -0
- package/dist/CurrencyField-Bw-LqANM.js +43 -0
- package/dist/DateField-BNHAzMB2.js +21 -0
- package/dist/DateTimeField-DjAyn_DQ.js +28 -0
- package/dist/EmailField-xoNcSppb.js +31 -0
- package/dist/FileField-DbNJwjU2.js +133 -0
- package/dist/FormulaField-CJkkwIK8.js +9 -0
- package/dist/GeolocationField-C1AnS6VV.js +123 -0
- package/dist/GridField-DATAHIKf.js +30 -0
- package/dist/ImageField-CEKJpyJp.js +90 -0
- package/dist/LocationField-jDWXjlpx.js +31 -0
- package/dist/LookupField-DQ08L9UQ.js +96 -0
- package/dist/MasterDetailField-Dbk529Ea.js +108 -0
- package/dist/NumberField-BVroN9aV.js +26 -0
- package/dist/ObjectField-CT3l_IHW.js +48 -0
- package/dist/PasswordField-DweVLEE0.js +38 -0
- package/dist/PercentField-ZpWUK97K.js +63 -0
- package/dist/PhoneField-mw-9fqZ_.js +31 -0
- package/dist/QRCodeField-Cbb9ck59.js +77 -0
- package/dist/RatingField-CSqgLS6t.js +47 -0
- package/dist/RichTextField-BpfBOd99.js +38 -0
- package/dist/SelectField-B9Ei-5jl.js +26 -0
- package/dist/SignatureField-DgGpHnQ8.js +85 -0
- package/dist/SliderField-C6HvOHd8.js +30 -0
- package/dist/SummaryField-ugYPYxjP.js +9 -0
- package/dist/TextAreaField-BK3RgzY3.js +39 -0
- package/dist/TextField-Bvzx3atT.js +32 -0
- package/dist/TimeField-Cuz9-Uai.js +21 -0
- package/dist/UrlField-B6XHTV73.js +33 -0
- package/dist/UserField-ooTul2d6.js +49 -0
- package/dist/VectorField-CKg9jdGa.js +25 -0
- package/dist/index-CnlyRfY_.js +59461 -0
- package/dist/index.js +30 -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 +6 -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/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 +4 -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/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 +36 -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 +74 -9
- package/src/DetailView.stories.tsx +76 -0
- package/src/DetailView.tsx +270 -27
- package/src/DiffView.tsx +231 -0
- package/src/FieldChangeItem.tsx +46 -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 +37 -8
- package/src/RelationshipGraph.tsx +286 -0
- package/src/RichTextCommentInput.tsx +348 -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__/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 +66 -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/autoLayout.ts +111 -0
- package/src/index.tsx +46 -0
- package/src/useDetailTranslation.ts +103 -0
|
@@ -0,0 +1,215 @@
|
|
|
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 { MessageSquare, Send, Pin, Search, X } from 'lucide-react';
|
|
12
|
+
import type { CommentEntry } from '@object-ui/types';
|
|
13
|
+
|
|
14
|
+
export interface RecordCommentsProps {
|
|
15
|
+
comments: CommentEntry[];
|
|
16
|
+
onAddComment?: (text: string) => void | Promise<void>;
|
|
17
|
+
/** Callback to toggle pin/star on a comment */
|
|
18
|
+
onTogglePin?: (commentId: string | number) => void;
|
|
19
|
+
/** Enable search input for filtering comments */
|
|
20
|
+
searchable?: boolean;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Format a timestamp string into a human-readable relative time or date string.
|
|
26
|
+
*/
|
|
27
|
+
function formatTimestamp(timestamp: string): string {
|
|
28
|
+
try {
|
|
29
|
+
const date = new Date(timestamp);
|
|
30
|
+
const now = new Date();
|
|
31
|
+
const diffMs = now.getTime() - date.getTime();
|
|
32
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
33
|
+
|
|
34
|
+
if (diffMins < 1) return 'just now';
|
|
35
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
36
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
37
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
38
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
39
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
40
|
+
return date.toLocaleDateString();
|
|
41
|
+
} catch {
|
|
42
|
+
return timestamp;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const RecordComments: React.FC<RecordCommentsProps> = ({
|
|
47
|
+
comments,
|
|
48
|
+
onAddComment,
|
|
49
|
+
onTogglePin,
|
|
50
|
+
searchable = false,
|
|
51
|
+
className,
|
|
52
|
+
}) => {
|
|
53
|
+
const [newComment, setNewComment] = React.useState('');
|
|
54
|
+
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
|
55
|
+
const [searchQuery, setSearchQuery] = React.useState('');
|
|
56
|
+
|
|
57
|
+
const handleSubmit = React.useCallback(async () => {
|
|
58
|
+
const text = newComment.trim();
|
|
59
|
+
if (!text || !onAddComment) return;
|
|
60
|
+
|
|
61
|
+
setIsSubmitting(true);
|
|
62
|
+
try {
|
|
63
|
+
await onAddComment(text);
|
|
64
|
+
setNewComment('');
|
|
65
|
+
} finally {
|
|
66
|
+
setIsSubmitting(false);
|
|
67
|
+
}
|
|
68
|
+
}, [newComment, onAddComment]);
|
|
69
|
+
|
|
70
|
+
const handleKeyDown = React.useCallback(
|
|
71
|
+
(e: React.KeyboardEvent) => {
|
|
72
|
+
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
handleSubmit();
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
[handleSubmit],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
/** Sort pinned comments first, then by date */
|
|
81
|
+
const sortedComments = React.useMemo(() => {
|
|
82
|
+
const filtered = searchQuery.trim()
|
|
83
|
+
? comments.filter(c => {
|
|
84
|
+
const q = searchQuery.trim().toLowerCase();
|
|
85
|
+
return c.text.toLowerCase().includes(q) || c.author.toLowerCase().includes(q);
|
|
86
|
+
})
|
|
87
|
+
: comments;
|
|
88
|
+
|
|
89
|
+
return [...filtered].sort((a, b) => {
|
|
90
|
+
if (a.pinned && !b.pinned) return -1;
|
|
91
|
+
if (!a.pinned && b.pinned) return 1;
|
|
92
|
+
return 0;
|
|
93
|
+
});
|
|
94
|
+
}, [comments, searchQuery]);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<Card className={cn('', className)}>
|
|
98
|
+
<CardHeader>
|
|
99
|
+
<CardTitle className="flex items-center gap-2 text-base">
|
|
100
|
+
<MessageSquare className="h-4 w-4" />
|
|
101
|
+
Comments
|
|
102
|
+
<span className="text-sm font-normal text-muted-foreground">
|
|
103
|
+
({comments.length})
|
|
104
|
+
</span>
|
|
105
|
+
</CardTitle>
|
|
106
|
+
</CardHeader>
|
|
107
|
+
<CardContent className="space-y-4">
|
|
108
|
+
{/* Search Input */}
|
|
109
|
+
{searchable && (
|
|
110
|
+
<div className="flex items-center gap-2">
|
|
111
|
+
<div className="relative flex-1">
|
|
112
|
+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
|
113
|
+
<input
|
|
114
|
+
className="w-full rounded-md border border-input bg-background pl-8 pr-8 py-1.5 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
115
|
+
placeholder="Search comments…"
|
|
116
|
+
value={searchQuery}
|
|
117
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
118
|
+
aria-label="Search comments"
|
|
119
|
+
/>
|
|
120
|
+
{searchQuery && (
|
|
121
|
+
<button
|
|
122
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
123
|
+
onClick={() => setSearchQuery('')}
|
|
124
|
+
aria-label="Clear search"
|
|
125
|
+
type="button"
|
|
126
|
+
>
|
|
127
|
+
<X className="h-3.5 w-3.5" />
|
|
128
|
+
</button>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{/* Comment Input */}
|
|
135
|
+
{onAddComment && (
|
|
136
|
+
<div className="flex gap-2">
|
|
137
|
+
<textarea
|
|
138
|
+
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"
|
|
139
|
+
placeholder="Add a comment… (Ctrl+Enter to submit)"
|
|
140
|
+
value={newComment}
|
|
141
|
+
onChange={(e) => setNewComment(e.target.value)}
|
|
142
|
+
onKeyDown={handleKeyDown}
|
|
143
|
+
disabled={isSubmitting}
|
|
144
|
+
/>
|
|
145
|
+
<Button
|
|
146
|
+
size="icon"
|
|
147
|
+
variant="default"
|
|
148
|
+
onClick={handleSubmit}
|
|
149
|
+
disabled={!newComment.trim() || isSubmitting}
|
|
150
|
+
className="shrink-0 self-end"
|
|
151
|
+
>
|
|
152
|
+
<Send className="h-4 w-4" />
|
|
153
|
+
</Button>
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
|
|
157
|
+
{/* Comment List */}
|
|
158
|
+
{sortedComments.length === 0 ? (
|
|
159
|
+
<p className="text-sm text-muted-foreground text-center py-4">
|
|
160
|
+
{searchQuery.trim() ? 'No matching comments' : 'No comments yet'}
|
|
161
|
+
</p>
|
|
162
|
+
) : (
|
|
163
|
+
<div className="space-y-3">
|
|
164
|
+
{sortedComments.map((comment) => (
|
|
165
|
+
<div key={comment.id} className={cn('flex gap-3', comment.pinned && 'bg-muted/40 rounded-md p-2 -mx-2')}>
|
|
166
|
+
{/* Avatar */}
|
|
167
|
+
<div className="shrink-0">
|
|
168
|
+
{comment.avatarUrl ? (
|
|
169
|
+
<img
|
|
170
|
+
src={comment.avatarUrl}
|
|
171
|
+
alt={comment.author}
|
|
172
|
+
className="h-8 w-8 rounded-full object-cover"
|
|
173
|
+
/>
|
|
174
|
+
) : (
|
|
175
|
+
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center text-xs font-medium text-muted-foreground">
|
|
176
|
+
{comment.author.charAt(0).toUpperCase()}
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
{/* Content */}
|
|
181
|
+
<div className="flex-1 min-w-0">
|
|
182
|
+
<div className="flex items-center gap-2 mb-0.5">
|
|
183
|
+
<span className="text-sm font-medium truncate">{comment.author}</span>
|
|
184
|
+
<span className="text-xs text-muted-foreground">
|
|
185
|
+
{formatTimestamp(comment.createdAt)}
|
|
186
|
+
</span>
|
|
187
|
+
{comment.pinned && (
|
|
188
|
+
<span className="text-xs text-amber-600 flex items-center gap-0.5">
|
|
189
|
+
<Pin className="h-3 w-3" />
|
|
190
|
+
Pinned
|
|
191
|
+
</span>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
<p className="text-sm whitespace-pre-wrap break-words">{comment.text}</p>
|
|
195
|
+
{/* Pin action */}
|
|
196
|
+
{onTogglePin && (
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
className="mt-1 text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
|
|
200
|
+
onClick={() => onTogglePin(comment.id)}
|
|
201
|
+
aria-label={comment.pinned ? 'Unpin comment' : 'Pin comment'}
|
|
202
|
+
>
|
|
203
|
+
<Pin className="h-3 w-3" />
|
|
204
|
+
{comment.pinned ? 'Unpin' : 'Pin'}
|
|
205
|
+
</button>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
))}
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
</CardContent>
|
|
213
|
+
</Card>
|
|
214
|
+
);
|
|
215
|
+
};
|
|
@@ -0,0 +1,211 @@
|
|
|
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, Input } from '@object-ui/components';
|
|
11
|
+
import {
|
|
12
|
+
ChevronsLeft,
|
|
13
|
+
ChevronsRight,
|
|
14
|
+
ChevronLeft,
|
|
15
|
+
ChevronRight,
|
|
16
|
+
Search,
|
|
17
|
+
} from 'lucide-react';
|
|
18
|
+
|
|
19
|
+
export interface RecordNavigationEnhancedProps {
|
|
20
|
+
currentIndex: number;
|
|
21
|
+
totalRecords: number;
|
|
22
|
+
recordIds: string[];
|
|
23
|
+
onNavigate: (recordId: string) => void;
|
|
24
|
+
onSearch?: (query: string) => void;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const RecordNavigationEnhanced: React.FC<RecordNavigationEnhancedProps> = ({
|
|
29
|
+
currentIndex,
|
|
30
|
+
totalRecords,
|
|
31
|
+
recordIds,
|
|
32
|
+
onNavigate,
|
|
33
|
+
onSearch,
|
|
34
|
+
className,
|
|
35
|
+
}) => {
|
|
36
|
+
const [searchQuery, setSearchQuery] = React.useState('');
|
|
37
|
+
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
|
|
38
|
+
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
|
39
|
+
|
|
40
|
+
const canGoFirst = currentIndex > 0;
|
|
41
|
+
const canGoPrev = currentIndex > 0;
|
|
42
|
+
const canGoNext = currentIndex < totalRecords - 1;
|
|
43
|
+
const canGoLast = currentIndex < totalRecords - 1;
|
|
44
|
+
|
|
45
|
+
const handleFirst = React.useCallback(() => {
|
|
46
|
+
if (canGoFirst) onNavigate(recordIds[0]);
|
|
47
|
+
}, [canGoFirst, onNavigate, recordIds]);
|
|
48
|
+
|
|
49
|
+
const handlePrev = React.useCallback(() => {
|
|
50
|
+
if (canGoPrev) onNavigate(recordIds[currentIndex - 1]);
|
|
51
|
+
}, [canGoPrev, onNavigate, recordIds, currentIndex]);
|
|
52
|
+
|
|
53
|
+
const handleNext = React.useCallback(() => {
|
|
54
|
+
if (canGoNext) onNavigate(recordIds[currentIndex + 1]);
|
|
55
|
+
}, [canGoNext, onNavigate, recordIds, currentIndex]);
|
|
56
|
+
|
|
57
|
+
const handleLast = React.useCallback(() => {
|
|
58
|
+
if (canGoLast) onNavigate(recordIds[recordIds.length - 1]);
|
|
59
|
+
}, [canGoLast, onNavigate, recordIds]);
|
|
60
|
+
|
|
61
|
+
const handleSearchChange = React.useCallback(
|
|
62
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
63
|
+
const value = e.target.value;
|
|
64
|
+
setSearchQuery(value);
|
|
65
|
+
onSearch?.(value);
|
|
66
|
+
},
|
|
67
|
+
[onSearch],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const handleToggleSearch = React.useCallback(() => {
|
|
71
|
+
setIsSearchOpen((prev) => {
|
|
72
|
+
if (!prev) {
|
|
73
|
+
requestAnimationFrame(() => searchInputRef.current?.focus());
|
|
74
|
+
} else {
|
|
75
|
+
setSearchQuery('');
|
|
76
|
+
onSearch?.('');
|
|
77
|
+
}
|
|
78
|
+
return !prev;
|
|
79
|
+
});
|
|
80
|
+
}, [onSearch]);
|
|
81
|
+
|
|
82
|
+
// Keyboard shortcuts
|
|
83
|
+
React.useEffect(() => {
|
|
84
|
+
const handler = (e: KeyboardEvent) => {
|
|
85
|
+
const tag = (e.target as HTMLElement)?.tagName;
|
|
86
|
+
const isEditable =
|
|
87
|
+
tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable;
|
|
88
|
+
|
|
89
|
+
// Allow search input to capture normal keys
|
|
90
|
+
if (isEditable && e.target !== searchInputRef.current) return;
|
|
91
|
+
// In search input, only handle navigation keys
|
|
92
|
+
if (e.target === searchInputRef.current) {
|
|
93
|
+
if (e.key === 'Escape') {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
setIsSearchOpen(false);
|
|
96
|
+
setSearchQuery('');
|
|
97
|
+
onSearch?.('');
|
|
98
|
+
(e.target as HTMLElement).blur();
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
switch (e.key) {
|
|
104
|
+
case 'Home':
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
if (canGoFirst) onNavigate(recordIds[0]);
|
|
107
|
+
break;
|
|
108
|
+
case 'End':
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
if (canGoLast) onNavigate(recordIds[recordIds.length - 1]);
|
|
111
|
+
break;
|
|
112
|
+
case 'ArrowLeft':
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
if (canGoPrev) onNavigate(recordIds[currentIndex - 1]);
|
|
115
|
+
break;
|
|
116
|
+
case 'ArrowRight':
|
|
117
|
+
e.preventDefault();
|
|
118
|
+
if (canGoNext) onNavigate(recordIds[currentIndex + 1]);
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
document.addEventListener('keydown', handler);
|
|
124
|
+
return () => document.removeEventListener('keydown', handler);
|
|
125
|
+
}, [currentIndex, recordIds, canGoFirst, canGoPrev, canGoNext, canGoLast, onNavigate, onSearch]);
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div className={cn('flex items-center gap-1.5', className)}>
|
|
129
|
+
{/* First */}
|
|
130
|
+
<Button
|
|
131
|
+
variant="outline"
|
|
132
|
+
size="icon"
|
|
133
|
+
className="h-8 w-8"
|
|
134
|
+
disabled={!canGoFirst}
|
|
135
|
+
onClick={handleFirst}
|
|
136
|
+
title="First record (Home)"
|
|
137
|
+
>
|
|
138
|
+
<ChevronsLeft className="h-4 w-4" />
|
|
139
|
+
</Button>
|
|
140
|
+
|
|
141
|
+
{/* Prev */}
|
|
142
|
+
<Button
|
|
143
|
+
variant="outline"
|
|
144
|
+
size="icon"
|
|
145
|
+
className="h-8 w-8"
|
|
146
|
+
disabled={!canGoPrev}
|
|
147
|
+
onClick={handlePrev}
|
|
148
|
+
title="Previous record (←)"
|
|
149
|
+
>
|
|
150
|
+
<ChevronLeft className="h-4 w-4" />
|
|
151
|
+
</Button>
|
|
152
|
+
|
|
153
|
+
{/* Position indicator */}
|
|
154
|
+
<span className="text-xs text-muted-foreground whitespace-nowrap px-1.5 tabular-nums">
|
|
155
|
+
{totalRecords > 0 ? `${currentIndex + 1} of ${totalRecords}` : 'No records'}
|
|
156
|
+
</span>
|
|
157
|
+
|
|
158
|
+
{/* Next */}
|
|
159
|
+
<Button
|
|
160
|
+
variant="outline"
|
|
161
|
+
size="icon"
|
|
162
|
+
className="h-8 w-8"
|
|
163
|
+
disabled={!canGoNext}
|
|
164
|
+
onClick={handleNext}
|
|
165
|
+
title="Next record (→)"
|
|
166
|
+
>
|
|
167
|
+
<ChevronRight className="h-4 w-4" />
|
|
168
|
+
</Button>
|
|
169
|
+
|
|
170
|
+
{/* Last */}
|
|
171
|
+
<Button
|
|
172
|
+
variant="outline"
|
|
173
|
+
size="icon"
|
|
174
|
+
className="h-8 w-8"
|
|
175
|
+
disabled={!canGoLast}
|
|
176
|
+
onClick={handleLast}
|
|
177
|
+
title="Last record (End)"
|
|
178
|
+
>
|
|
179
|
+
<ChevronsRight className="h-4 w-4" />
|
|
180
|
+
</Button>
|
|
181
|
+
|
|
182
|
+
{/* Search toggle */}
|
|
183
|
+
{onSearch && (
|
|
184
|
+
<>
|
|
185
|
+
<Button
|
|
186
|
+
variant={isSearchOpen ? 'secondary' : 'ghost'}
|
|
187
|
+
size="icon"
|
|
188
|
+
className="h-8 w-8"
|
|
189
|
+
onClick={handleToggleSearch}
|
|
190
|
+
title="Search while navigating"
|
|
191
|
+
>
|
|
192
|
+
<Search className="h-4 w-4" />
|
|
193
|
+
</Button>
|
|
194
|
+
|
|
195
|
+
{isSearchOpen && (
|
|
196
|
+
<div className="relative">
|
|
197
|
+
<Input
|
|
198
|
+
ref={searchInputRef}
|
|
199
|
+
type="text"
|
|
200
|
+
placeholder="Search records…"
|
|
201
|
+
value={searchQuery}
|
|
202
|
+
onChange={handleSearchChange}
|
|
203
|
+
className="h-8 w-48 text-sm"
|
|
204
|
+
/>
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
</>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
};
|
package/src/RelatedList.tsx
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import * as React from 'react';
|
|
10
|
-
import { Card, CardHeader, CardTitle, CardContent } from '@object-ui/components';
|
|
10
|
+
import { Card, CardHeader, CardTitle, CardContent, Button } from '@object-ui/components';
|
|
11
11
|
import { SchemaRenderer } from '@object-ui/react';
|
|
12
|
+
import { Plus, ExternalLink } from 'lucide-react';
|
|
12
13
|
import type { DataSource } from '@object-ui/types';
|
|
14
|
+
import { useDetailTranslation } from './useDetailTranslation';
|
|
13
15
|
|
|
14
16
|
export interface RelatedListProps {
|
|
15
17
|
title: string;
|
|
@@ -20,6 +22,10 @@ export interface RelatedListProps {
|
|
|
20
22
|
columns?: any[];
|
|
21
23
|
className?: string;
|
|
22
24
|
dataSource?: DataSource;
|
|
25
|
+
/** Callback when "New" button is clicked */
|
|
26
|
+
onNew?: () => void;
|
|
27
|
+
/** Callback when "View All" button is clicked */
|
|
28
|
+
onViewAll?: () => void;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
export const RelatedList: React.FC<RelatedListProps> = ({
|
|
@@ -31,14 +37,17 @@ export const RelatedList: React.FC<RelatedListProps> = ({
|
|
|
31
37
|
columns,
|
|
32
38
|
className,
|
|
33
39
|
dataSource,
|
|
40
|
+
onNew,
|
|
41
|
+
onViewAll,
|
|
34
42
|
}) => {
|
|
35
43
|
const [relatedData, setRelatedData] = React.useState(data);
|
|
36
44
|
const [loading, setLoading] = React.useState(false);
|
|
45
|
+
const { t } = useDetailTranslation();
|
|
37
46
|
|
|
38
47
|
React.useEffect(() => {
|
|
39
48
|
if (api && !data.length) {
|
|
40
49
|
setLoading(true);
|
|
41
|
-
if (dataSource) {
|
|
50
|
+
if (dataSource && typeof dataSource.find === 'function') {
|
|
42
51
|
dataSource.find(api).then((result) => {
|
|
43
52
|
const items = Array.isArray(result)
|
|
44
53
|
? result
|
|
@@ -90,24 +99,44 @@ export const RelatedList: React.FC<RelatedListProps> = ({
|
|
|
90
99
|
}
|
|
91
100
|
}, [type, relatedData, columns, schema]);
|
|
92
101
|
|
|
102
|
+
const recordCountText = relatedData.length === 1
|
|
103
|
+
? t('detail.relatedRecordOne', { count: relatedData.length })
|
|
104
|
+
: t('detail.relatedRecords', { count: relatedData.length });
|
|
105
|
+
|
|
93
106
|
return (
|
|
94
107
|
<Card className={className}>
|
|
95
108
|
<CardHeader>
|
|
96
109
|
<CardTitle className="flex items-center justify-between">
|
|
97
|
-
<
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
110
|
+
<div className="flex items-center gap-2">
|
|
111
|
+
<span>{title}</span>
|
|
112
|
+
<span className="text-sm font-normal text-muted-foreground">
|
|
113
|
+
{recordCountText}
|
|
114
|
+
</span>
|
|
115
|
+
</div>
|
|
116
|
+
<div className="flex items-center gap-1">
|
|
117
|
+
{onNew && (
|
|
118
|
+
<Button variant="ghost" size="sm" onClick={onNew} className="gap-1 h-7 text-xs">
|
|
119
|
+
<Plus className="h-3.5 w-3.5" />
|
|
120
|
+
{t('detail.new')}
|
|
121
|
+
</Button>
|
|
122
|
+
)}
|
|
123
|
+
{onViewAll && (
|
|
124
|
+
<Button variant="ghost" size="sm" onClick={onViewAll} className="gap-1 h-7 text-xs">
|
|
125
|
+
{t('detail.viewAll')}
|
|
126
|
+
<ExternalLink className="h-3 w-3" />
|
|
127
|
+
</Button>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
101
130
|
</CardTitle>
|
|
102
131
|
</CardHeader>
|
|
103
132
|
<CardContent>
|
|
104
133
|
{loading ? (
|
|
105
134
|
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
|
106
|
-
|
|
135
|
+
{t('detail.loading')}
|
|
107
136
|
</div>
|
|
108
137
|
) : relatedData.length === 0 ? (
|
|
109
138
|
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
|
|
110
|
-
|
|
139
|
+
{t('detail.noRelatedRecords')}
|
|
111
140
|
</div>
|
|
112
141
|
) : (
|
|
113
142
|
<SchemaRenderer schema={viewSchema} />
|