@object-ui/plugin-list 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.
@@ -6,11 +6,12 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
 
9
- import React, { useState, useEffect } from 'react';
10
- import { useDataScope, useSchemaContext } from '@object-ui/react';
11
- import { ComponentRegistry } from '@object-ui/core';
12
- import { cn, Card, CardContent } from '@object-ui/components';
13
- import type { GalleryConfig } from '@object-ui/types';
9
+ import React, { useState, useEffect, useCallback, useMemo, useContext } from 'react';
10
+ import { useDataScope, SchemaRendererContext, useNavigationOverlay } from '@object-ui/react';
11
+ import { ComponentRegistry, buildExpandFields } from '@object-ui/core';
12
+ import { cn, Card, CardContent, NavigationOverlay } from '@object-ui/components';
13
+ import type { GalleryConfig, ViewNavigationConfig, GroupingConfig } from '@object-ui/types';
14
+ import { ChevronRight, ChevronDown } from 'lucide-react';
14
15
 
15
16
  export interface ObjectGalleryProps {
16
17
  schema: {
@@ -20,6 +21,10 @@ export interface ObjectGalleryProps {
20
21
  data?: Record<string, unknown>[];
21
22
  className?: string;
22
23
  gallery?: GalleryConfig;
24
+ /** Navigation config for item click behavior */
25
+ navigation?: ViewNavigationConfig;
26
+ /** Grouping configuration for sectioned display */
27
+ grouping?: GroupingConfig;
23
28
  /** @deprecated Use gallery.coverField instead */
24
29
  imageField?: string;
25
30
  /** @deprecated Use gallery.titleField instead */
@@ -29,6 +34,8 @@ export interface ObjectGalleryProps {
29
34
  data?: Record<string, unknown>[];
30
35
  dataSource?: { find: (name: string, query: unknown) => Promise<unknown> };
31
36
  onCardClick?: (record: Record<string, unknown>) => void;
37
+ /** Callback when a row/item is clicked (overrides NavigationConfig) */
38
+ onRowClick?: (record: Record<string, unknown>) => void;
32
39
  }
33
40
 
34
41
  const GRID_CLASSES: Record<NonNullable<GalleryConfig['cardSize']>, string> = {
@@ -45,12 +52,20 @@ const ASPECT_CLASSES: Record<NonNullable<GalleryConfig['cardSize']>, string> = {
45
52
 
46
53
  export const ObjectGallery: React.FC<ObjectGalleryProps> = (props) => {
47
54
  const { schema } = props;
48
- const context = useSchemaContext();
49
- const dataSource = props.dataSource || context.dataSource;
55
+ const context = useContext(SchemaRendererContext);
56
+ const dataSource = props.dataSource || context?.dataSource;
50
57
  const boundData = useDataScope(schema.bind);
51
58
 
52
59
  const [fetchedData, setFetchedData] = useState<Record<string, unknown>[]>([]);
53
60
  const [loading, setLoading] = useState(false);
61
+ const [objectDef, setObjectDef] = useState<any>(null);
62
+
63
+ // --- NavigationConfig support ---
64
+ const navigation = useNavigationOverlay({
65
+ navigation: schema.navigation,
66
+ objectName: schema.objectName,
67
+ onRowClick: props.onRowClick ?? props.onCardClick,
68
+ });
54
69
 
55
70
  // Resolve GalleryConfig with backwards-compatible fallbacks
56
71
  const gallery = schema.gallery;
@@ -60,6 +75,22 @@ export const ObjectGallery: React.FC<ObjectGalleryProps> = (props) => {
60
75
  const titleField = gallery?.titleField ?? schema.titleField ?? 'name';
61
76
  const visibleFields = gallery?.visibleFields;
62
77
 
78
+ // Fetch object definition for metadata
79
+ useEffect(() => {
80
+ let isMounted = true;
81
+ const fetchMeta = async () => {
82
+ if (!dataSource || typeof dataSource.getObjectSchema !== 'function' || !schema.objectName) return;
83
+ try {
84
+ const def = await dataSource.getObjectSchema(schema.objectName);
85
+ if (isMounted) setObjectDef(def);
86
+ } catch (e) {
87
+ console.warn('Failed to fetch object def for ObjectGallery', e);
88
+ }
89
+ };
90
+ fetchMeta();
91
+ return () => { isMounted = false; };
92
+ }, [schema.objectName, dataSource]);
93
+
63
94
  useEffect(() => {
64
95
  let isMounted = true;
65
96
 
@@ -69,11 +100,14 @@ export const ObjectGallery: React.FC<ObjectGalleryProps> = (props) => {
69
100
  }
70
101
 
71
102
  const fetchData = async () => {
72
- if (!dataSource || !schema.objectName) return;
103
+ if (!dataSource || typeof dataSource.find !== 'function' || !schema.objectName) return;
73
104
  if (isMounted) setLoading(true);
74
105
  try {
106
+ // Auto-inject $expand for lookup/master_detail fields
107
+ const expand = buildExpandFields(objectDef?.fields);
75
108
  const results = await dataSource.find(schema.objectName, {
76
109
  $filter: schema.filter,
110
+ ...(expand.length > 0 ? { $expand: expand } : {}),
77
111
  });
78
112
 
79
113
  let data: Record<string, unknown>[] = [];
@@ -102,74 +136,168 @@ export const ObjectGallery: React.FC<ObjectGalleryProps> = (props) => {
102
136
  fetchData();
103
137
  }
104
138
  return () => { isMounted = false; };
105
- }, [schema.objectName, dataSource, boundData, schema.data, schema.filter, props.data]);
139
+ }, [schema.objectName, dataSource, boundData, schema.data, schema.filter, props.data, objectDef]);
106
140
 
107
141
  const items: Record<string, unknown>[] = props.data || boundData || schema.data || fetchedData || [];
108
142
 
143
+ // --- Grouping support ---
144
+ const groupingFields = schema.grouping?.fields;
145
+ const isGrouped = !!(groupingFields && groupingFields.length > 0);
146
+
147
+ const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
148
+
149
+ // Initialize collapsed state from grouping config
150
+ const defaultCollapsed = useMemo(() => {
151
+ if (!groupingFields) return false;
152
+ return groupingFields.some((f) => f.collapsed);
153
+ }, [groupingFields]);
154
+
155
+ const toggleGroup = useCallback((key: string) => {
156
+ setCollapsedGroups((prev) => ({
157
+ ...prev,
158
+ [key]: prev[key] !== undefined ? !prev[key] : !defaultCollapsed,
159
+ }));
160
+ }, [defaultCollapsed]);
161
+
162
+ const groupedItems = useMemo(() => {
163
+ if (!isGrouped || !groupingFields) return [];
164
+ const map = new Map<string, { label: string; items: Record<string, unknown>[] }>();
165
+ const keyOrder: string[] = [];
166
+ for (const item of items) {
167
+ const key = groupingFields.map((f) => String(item[f.field] ?? '')).join(' / ');
168
+ if (!map.has(key)) {
169
+ const label = groupingFields
170
+ .map((f) => {
171
+ const val = item[f.field];
172
+ return val !== undefined && val !== null && val !== '' ? String(val) : '(empty)';
173
+ })
174
+ .join(' / ');
175
+ map.set(key, { label, items: [] });
176
+ keyOrder.push(key);
177
+ }
178
+ map.get(key)!.items.push(item);
179
+ }
180
+ const primaryOrder = groupingFields[0]?.order ?? 'asc';
181
+ keyOrder.sort((a, b) => {
182
+ const cmp = a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
183
+ return primaryOrder === 'desc' ? -cmp : cmp;
184
+ });
185
+ return keyOrder.map((key) => {
186
+ const entry = map.get(key)!;
187
+ const collapsed = key in collapsedGroups ? collapsedGroups[key] : defaultCollapsed;
188
+ return { key, label: entry.label, items: entry.items, collapsed };
189
+ });
190
+ }, [items, groupingFields, isGrouped, collapsedGroups, defaultCollapsed]);
191
+
109
192
  if (loading && !items.length) return <div className="p-4 text-sm text-muted-foreground">Loading Gallery...</div>;
110
193
  if (!items.length) return <div className="p-4 text-sm text-muted-foreground">No items to display</div>;
111
194
 
112
- return (
195
+ const renderCard = (item: Record<string, unknown>, i: number) => {
196
+ const id = (item._id ?? item.id ?? i) as string | number;
197
+ const title = String(item[titleField] ?? 'Untitled');
198
+ const imageUrl = item[coverField] as string | undefined;
199
+
200
+ return (
201
+ <Card
202
+ key={id}
203
+ role="listitem"
204
+ className={cn(
205
+ 'group overflow-hidden transition-all hover:shadow-md',
206
+ (props.onCardClick || props.onRowClick || schema.navigation) && 'cursor-pointer',
207
+ )}
208
+ onClick={() => navigation.handleClick(item)}
209
+ >
210
+ <div className={cn('w-full overflow-hidden bg-muted relative', ASPECT_CLASSES[cardSize])}>
211
+ {imageUrl ? (
212
+ <img
213
+ src={imageUrl}
214
+ alt={title}
215
+ className={cn(
216
+ 'h-full w-full transition-transform group-hover:scale-105',
217
+ coverFit === 'cover' && 'object-cover',
218
+ coverFit === 'contain' && 'object-contain',
219
+ )}
220
+ />
221
+ ) : (
222
+ <div className="flex h-full w-full items-center justify-center bg-secondary/50 text-muted-foreground">
223
+ <span className="text-4xl font-light opacity-20">
224
+ {title[0]?.toUpperCase()}
225
+ </span>
226
+ </div>
227
+ )}
228
+ </div>
229
+ <CardContent className="p-3 border-t">
230
+ <h3 className="font-medium truncate text-sm" title={title}>
231
+ {title}
232
+ </h3>
233
+ {visibleFields && visibleFields.length > 0 && (
234
+ <div className="mt-1 space-y-0.5">
235
+ {visibleFields.map((field) => {
236
+ const value = item[field];
237
+ if (value == null) return null;
238
+ return (
239
+ <p key={field} className="text-xs text-muted-foreground truncate">
240
+ {String(value)}
241
+ </p>
242
+ );
243
+ })}
244
+ </div>
245
+ )}
246
+ </CardContent>
247
+ </Card>
248
+ );
249
+ };
250
+
251
+ const renderGrid = (gridItems: Record<string, unknown>[]) => (
113
252
  <div
114
253
  className={cn('grid gap-4 p-4', GRID_CLASSES[cardSize], schema.className)}
115
254
  role="list"
116
255
  >
117
- {items.map((item, i) => {
118
- const id = (item._id ?? item.id ?? i) as string | number;
119
- const title = String(item[titleField] ?? 'Untitled');
120
- const imageUrl = item[coverField] as string | undefined;
121
-
122
- return (
123
- <Card
124
- key={id}
125
- role="listitem"
126
- className={cn(
127
- 'group overflow-hidden transition-all hover:shadow-md',
128
- props.onCardClick && 'cursor-pointer',
129
- )}
130
- onClick={props.onCardClick ? () => props.onCardClick!(item) : undefined}
131
- >
132
- <div className={cn('w-full overflow-hidden bg-muted relative', ASPECT_CLASSES[cardSize])}>
133
- {imageUrl ? (
134
- <img
135
- src={imageUrl}
136
- alt={title}
137
- className={cn(
138
- 'h-full w-full transition-transform group-hover:scale-105',
139
- coverFit === 'cover' && 'object-cover',
140
- coverFit === 'contain' && 'object-contain',
141
- )}
142
- />
143
- ) : (
144
- <div className="flex h-full w-full items-center justify-center bg-secondary/50 text-muted-foreground">
145
- <span className="text-4xl font-light opacity-20">
146
- {title[0]?.toUpperCase()}
256
+ {gridItems.map((item, i) => renderCard(item, i))}
257
+ </div>
258
+ );
259
+
260
+ return (
261
+ <>
262
+ {isGrouped ? (
263
+ <div className="space-y-2">
264
+ {groupedItems.map((group) => (
265
+ <div key={group.key} className="border rounded-md">
266
+ <button
267
+ type="button"
268
+ className="flex w-full items-center gap-2 px-3 py-2 text-sm font-medium text-left bg-muted/50 hover:bg-muted transition-colors"
269
+ onClick={() => toggleGroup(group.key)}
270
+ >
271
+ {group.collapsed
272
+ ? <ChevronRight className="h-4 w-4 shrink-0" />
273
+ : <ChevronDown className="h-4 w-4 shrink-0" />}
274
+ <span>{group.label}</span>
275
+ <span className="ml-auto text-xs text-muted-foreground">{group.items.length}</span>
276
+ </button>
277
+ {!group.collapsed && renderGrid(group.items)}
278
+ </div>
279
+ ))}
280
+ </div>
281
+ ) : (
282
+ renderGrid(items)
283
+ )}
284
+ {navigation.isOverlay && (
285
+ <NavigationOverlay {...navigation} title="Gallery Item">
286
+ {(record) => (
287
+ <div className="space-y-3">
288
+ {Object.entries(record).map(([key, value]) => (
289
+ <div key={key} className="flex flex-col">
290
+ <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
291
+ {key.replace(/_/g, ' ')}
147
292
  </span>
293
+ <span className="text-sm">{String(value ?? '—')}</span>
148
294
  </div>
149
- )}
295
+ ))}
150
296
  </div>
151
- <CardContent className="p-3 border-t">
152
- <h3 className="font-medium truncate text-sm" title={title}>
153
- {title}
154
- </h3>
155
- {visibleFields && visibleFields.length > 0 && (
156
- <div className="mt-1 space-y-0.5">
157
- {visibleFields.map((field) => {
158
- const value = item[field];
159
- if (value == null) return null;
160
- return (
161
- <p key={field} className="text-xs text-muted-foreground truncate">
162
- {String(value)}
163
- </p>
164
- );
165
- })}
166
- </div>
167
- )}
168
- </CardContent>
169
- </Card>
170
- );
171
- })}
172
- </div>
297
+ )}
298
+ </NavigationOverlay>
299
+ )}
300
+ </>
173
301
  );
174
302
  };
175
303