@object-ui/plugin-list 0.5.1 → 3.0.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 +8 -8
- package/CHANGELOG.md +35 -0
- package/dist/index.js +23567 -21568
- package/dist/index.umd.cjs +37 -28
- package/dist/plugin-list.css +1 -1
- package/dist/src/ListView.d.ts +4 -0
- package/dist/src/ListView.d.ts.map +1 -1
- package/dist/src/ListView.stories.d.ts +24 -0
- package/dist/src/ListView.stories.d.ts.map +1 -0
- package/dist/src/ObjectGallery.d.ts +23 -1
- package/dist/src/ObjectGallery.d.ts.map +1 -1
- package/dist/src/ViewSwitcher.d.ts +2 -0
- package/dist/src/ViewSwitcher.d.ts.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +9 -8
- package/src/ListView.stories.tsx +64 -0
- package/src/ListView.tsx +239 -120
- package/src/ObjectGallery.tsx +122 -53
- package/src/ViewSwitcher.tsx +23 -2
- package/src/__tests__/ListView.test.tsx +16 -7
- package/src/__tests__/ListViewPersistence.test.tsx +3 -3
- package/src/index.tsx +23 -1
- package/src/registration.test.tsx +1 -2
- package/vitest.config.ts +0 -1
package/src/ObjectGallery.tsx
CHANGED
|
@@ -1,27 +1,69 @@
|
|
|
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
|
+
*/
|
|
1
8
|
|
|
2
9
|
import React, { useState, useEffect } from 'react';
|
|
3
10
|
import { useDataScope, useSchemaContext } from '@object-ui/react';
|
|
4
11
|
import { ComponentRegistry } from '@object-ui/core';
|
|
12
|
+
import { cn, Card, CardContent } from '@object-ui/components';
|
|
13
|
+
import type { GalleryConfig } from '@object-ui/types';
|
|
5
14
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
15
|
+
export interface ObjectGalleryProps {
|
|
16
|
+
schema: {
|
|
17
|
+
objectName?: string;
|
|
18
|
+
bind?: string;
|
|
19
|
+
filter?: unknown;
|
|
20
|
+
data?: Record<string, unknown>[];
|
|
21
|
+
className?: string;
|
|
22
|
+
gallery?: GalleryConfig;
|
|
23
|
+
/** @deprecated Use gallery.coverField instead */
|
|
24
|
+
imageField?: string;
|
|
25
|
+
/** @deprecated Use gallery.titleField instead */
|
|
26
|
+
titleField?: string;
|
|
27
|
+
subtitleField?: string;
|
|
28
|
+
};
|
|
29
|
+
data?: Record<string, unknown>[];
|
|
30
|
+
dataSource?: { find: (name: string, query: unknown) => Promise<unknown> };
|
|
31
|
+
onCardClick?: (record: Record<string, unknown>) => void;
|
|
32
|
+
}
|
|
10
33
|
|
|
11
|
-
|
|
34
|
+
const GRID_CLASSES: Record<NonNullable<GalleryConfig['cardSize']>, string> = {
|
|
35
|
+
small: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6',
|
|
36
|
+
medium: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
|
|
37
|
+
large: 'grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const ASPECT_CLASSES: Record<NonNullable<GalleryConfig['cardSize']>, string> = {
|
|
41
|
+
small: 'aspect-square',
|
|
42
|
+
medium: 'aspect-[4/3]',
|
|
43
|
+
large: 'aspect-[16/10]',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const ObjectGallery: React.FC<ObjectGalleryProps> = (props) => {
|
|
12
47
|
const { schema } = props;
|
|
13
48
|
const context = useSchemaContext();
|
|
14
49
|
const dataSource = props.dataSource || context.dataSource;
|
|
15
50
|
const boundData = useDataScope(schema.bind);
|
|
16
51
|
|
|
17
|
-
const [fetchedData, setFetchedData] = useState<
|
|
52
|
+
const [fetchedData, setFetchedData] = useState<Record<string, unknown>[]>([]);
|
|
18
53
|
const [loading, setLoading] = useState(false);
|
|
19
54
|
|
|
55
|
+
// Resolve GalleryConfig with backwards-compatible fallbacks
|
|
56
|
+
const gallery = schema.gallery;
|
|
57
|
+
const coverField = gallery?.coverField ?? schema.imageField ?? 'image';
|
|
58
|
+
const coverFit = gallery?.coverFit ?? 'cover';
|
|
59
|
+
const cardSize = gallery?.cardSize ?? 'medium';
|
|
60
|
+
const titleField = gallery?.titleField ?? schema.titleField ?? 'name';
|
|
61
|
+
const visibleFields = gallery?.visibleFields;
|
|
62
|
+
|
|
20
63
|
useEffect(() => {
|
|
21
64
|
let isMounted = true;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if ((props.data && Array.isArray(props.data))) {
|
|
65
|
+
|
|
66
|
+
if (props.data && Array.isArray(props.data)) {
|
|
25
67
|
setFetchedData(props.data);
|
|
26
68
|
return;
|
|
27
69
|
}
|
|
@@ -30,22 +72,22 @@ export const ObjectGallery = (props: any) => {
|
|
|
30
72
|
if (!dataSource || !schema.objectName) return;
|
|
31
73
|
if (isMounted) setLoading(true);
|
|
32
74
|
try {
|
|
33
|
-
// Apply filtering?
|
|
34
75
|
const results = await dataSource.find(schema.objectName, {
|
|
35
|
-
|
|
76
|
+
$filter: schema.filter,
|
|
36
77
|
});
|
|
37
|
-
|
|
38
|
-
let data:
|
|
78
|
+
|
|
79
|
+
let data: Record<string, unknown>[] = [];
|
|
39
80
|
if (Array.isArray(results)) {
|
|
40
81
|
data = results;
|
|
41
82
|
} else if (results && typeof results === 'object') {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
83
|
+
const r = results as Record<string, unknown>;
|
|
84
|
+
if (Array.isArray(r.records)) {
|
|
85
|
+
data = r.records as Record<string, unknown>[];
|
|
86
|
+
} else if (Array.isArray(r.data)) {
|
|
87
|
+
data = r.data as Record<string, unknown>[];
|
|
46
88
|
}
|
|
47
89
|
}
|
|
48
|
-
|
|
90
|
+
|
|
49
91
|
if (isMounted) {
|
|
50
92
|
setFetchedData(data);
|
|
51
93
|
}
|
|
@@ -55,51 +97,78 @@ export const ObjectGallery = (props: any) => {
|
|
|
55
97
|
if (isMounted) setLoading(false);
|
|
56
98
|
}
|
|
57
99
|
};
|
|
58
|
-
|
|
100
|
+
|
|
59
101
|
if (schema.objectName && !boundData && !schema.data && !props.data) {
|
|
60
102
|
fetchData();
|
|
61
103
|
}
|
|
62
104
|
return () => { isMounted = false; };
|
|
63
|
-
|
|
105
|
+
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter, props.data]);
|
|
64
106
|
|
|
65
|
-
const items = props.data || boundData || schema.data || fetchedData || [];
|
|
66
|
-
|
|
67
|
-
// Config
|
|
68
|
-
const imageField = schema.imageField || 'image';
|
|
69
|
-
const titleField = schema.titleField || 'name';
|
|
70
|
-
const subtitleField = schema.subtitleField;
|
|
107
|
+
const items: Record<string, unknown>[] = props.data || boundData || schema.data || fetchedData || [];
|
|
71
108
|
|
|
72
109
|
if (loading && !items.length) return <div className="p-4 text-sm text-muted-foreground">Loading Gallery...</div>;
|
|
73
110
|
if (!items.length) return <div className="p-4 text-sm text-muted-foreground">No items to display</div>;
|
|
74
111
|
|
|
75
112
|
return (
|
|
76
|
-
<div
|
|
77
|
-
{
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
</div>
|
|
93
|
-
)}
|
|
94
|
-
</div>
|
|
95
|
-
<div className="p-3 border-t">
|
|
96
|
-
<h3 className="font-medium truncate text-sm" title={item[titleField]}>{item[titleField] || 'Untitled'}</h3>
|
|
97
|
-
{subtitleField && (
|
|
98
|
-
<p className="text-xs text-muted-foreground truncate mt-1">{item[subtitleField]}</p>
|
|
113
|
+
<div
|
|
114
|
+
className={cn('grid gap-4 p-4', GRID_CLASSES[cardSize], schema.className)}
|
|
115
|
+
role="list"
|
|
116
|
+
>
|
|
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',
|
|
99
129
|
)}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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()}
|
|
147
|
+
</span>
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
</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
|
+
})}
|
|
103
172
|
</div>
|
|
104
173
|
);
|
|
105
174
|
};
|
|
@@ -107,5 +176,5 @@ export const ObjectGallery = (props: any) => {
|
|
|
107
176
|
ComponentRegistry.register('object-gallery', ObjectGallery, {
|
|
108
177
|
namespace: 'plugin-list',
|
|
109
178
|
label: 'Gallery View',
|
|
110
|
-
category: 'view'
|
|
179
|
+
category: 'view',
|
|
111
180
|
});
|
package/src/ViewSwitcher.tsx
CHANGED
|
@@ -32,6 +32,8 @@ export interface ViewSwitcherProps {
|
|
|
32
32
|
availableViews?: ViewType[];
|
|
33
33
|
onViewChange: (view: ViewType) => void;
|
|
34
34
|
className?: string;
|
|
35
|
+
/** Enable animated transitions between views (default: true) */
|
|
36
|
+
animated?: boolean;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
const VIEW_ICONS: Record<ViewType, React.ReactNode> = {
|
|
@@ -59,16 +61,35 @@ export const ViewSwitcher: React.FC<ViewSwitcherProps> = ({
|
|
|
59
61
|
availableViews = ['grid', 'kanban'],
|
|
60
62
|
onViewChange,
|
|
61
63
|
className,
|
|
64
|
+
animated = true,
|
|
62
65
|
}) => {
|
|
66
|
+
const handleViewChange = React.useCallback(
|
|
67
|
+
(view: ViewType) => {
|
|
68
|
+
if (!animated || view === currentView) {
|
|
69
|
+
onViewChange(view);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (typeof document !== 'undefined' && 'startViewTransition' in document) {
|
|
74
|
+
(document as Document & {
|
|
75
|
+
startViewTransition: (cb: () => void) => { finished: Promise<void> };
|
|
76
|
+
}).startViewTransition(() => onViewChange(view));
|
|
77
|
+
} else {
|
|
78
|
+
onViewChange(view);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
[animated, currentView, onViewChange],
|
|
82
|
+
);
|
|
83
|
+
|
|
63
84
|
return (
|
|
64
|
-
<div className={cn("flex items-center gap-1 bg-transparent", className)}>
|
|
85
|
+
<div className={cn("flex items-center gap-1 bg-transparent oui-view-switcher", className)}>
|
|
65
86
|
{availableViews.map((view) => {
|
|
66
87
|
const isActive = currentView === view;
|
|
67
88
|
return (
|
|
68
89
|
<button
|
|
69
90
|
key={view}
|
|
70
91
|
type="button"
|
|
71
|
-
onClick={() =>
|
|
92
|
+
onClick={() => handleViewChange(view)}
|
|
72
93
|
aria-label={VIEW_LABELS[view]}
|
|
73
94
|
title={VIEW_LABELS[view]}
|
|
74
95
|
aria-pressed={isActive}
|
|
@@ -66,7 +66,7 @@ describe('ListView', () => {
|
|
|
66
66
|
expect(container).toBeTruthy();
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
it('should render search
|
|
69
|
+
it('should render search button', () => {
|
|
70
70
|
const schema: ListViewSchema = {
|
|
71
71
|
type: 'list-view',
|
|
72
72
|
objectName: 'contacts',
|
|
@@ -75,11 +75,11 @@ describe('ListView', () => {
|
|
|
75
75
|
};
|
|
76
76
|
|
|
77
77
|
renderWithProvider(<ListView schema={schema} />);
|
|
78
|
-
const
|
|
79
|
-
expect(
|
|
78
|
+
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
79
|
+
expect(searchButton).toBeInTheDocument();
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
it('should call onSearchChange when search input changes', () => {
|
|
82
|
+
it('should expand search and call onSearchChange when search input changes', () => {
|
|
83
83
|
const onSearchChange = vi.fn();
|
|
84
84
|
const schema: ListViewSchema = {
|
|
85
85
|
type: 'list-view',
|
|
@@ -89,8 +89,12 @@ describe('ListView', () => {
|
|
|
89
89
|
};
|
|
90
90
|
|
|
91
91
|
renderWithProvider(<ListView schema={schema} onSearchChange={onSearchChange} />);
|
|
92
|
-
const searchInput = screen.getByPlaceholderText(/find/i);
|
|
93
92
|
|
|
93
|
+
// Click search button to expand
|
|
94
|
+
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
95
|
+
fireEvent.click(searchButton);
|
|
96
|
+
|
|
97
|
+
const searchInput = screen.getByPlaceholderText(/find/i);
|
|
94
98
|
fireEvent.change(searchInput, { target: { value: 'test' } });
|
|
95
99
|
expect(onSearchChange).toHaveBeenCalledWith('test');
|
|
96
100
|
});
|
|
@@ -108,7 +112,7 @@ describe('ListView', () => {
|
|
|
108
112
|
},
|
|
109
113
|
};
|
|
110
114
|
|
|
111
|
-
renderWithProvider(<ListView schema={schema} />);
|
|
115
|
+
renderWithProvider(<ListView schema={schema} showViewSwitcher={true} />);
|
|
112
116
|
|
|
113
117
|
// Find kanban view button and click it
|
|
114
118
|
// ViewSwitcher uses buttons with aria-label
|
|
@@ -196,13 +200,18 @@ describe('ListView', () => {
|
|
|
196
200
|
};
|
|
197
201
|
|
|
198
202
|
renderWithProvider(<ListView schema={schema} />);
|
|
203
|
+
|
|
204
|
+
// Click search button to expand search input
|
|
205
|
+
const searchButton = screen.getByRole('button', { name: /search/i });
|
|
206
|
+
fireEvent.click(searchButton);
|
|
207
|
+
|
|
199
208
|
const searchInput = screen.getByPlaceholderText(/find/i) as HTMLInputElement;
|
|
200
209
|
|
|
201
210
|
// Type in search
|
|
202
211
|
fireEvent.change(searchInput, { target: { value: 'test' } });
|
|
203
212
|
expect(searchInput.value).toBe('test');
|
|
204
213
|
|
|
205
|
-
// Find and click clear button
|
|
214
|
+
// Find and click clear button (the X button inside the expanded search)
|
|
206
215
|
const buttons = screen.getAllByRole('button');
|
|
207
216
|
const clearButton = buttons.find(btn =>
|
|
208
217
|
btn.querySelector('svg') !== null && searchInput.value !== ''
|
|
@@ -56,7 +56,7 @@ describe('ListView Persistence', () => {
|
|
|
56
56
|
},
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
-
renderWithProvider(<ListView schema={schema} />);
|
|
59
|
+
renderWithProvider(<ListView schema={schema} showViewSwitcher={true} />);
|
|
60
60
|
|
|
61
61
|
// Simulate changing to kanban view
|
|
62
62
|
const kanbanButton = screen.getByLabelText('Kanban');
|
|
@@ -89,7 +89,7 @@ describe('ListView Persistence', () => {
|
|
|
89
89
|
},
|
|
90
90
|
};
|
|
91
91
|
|
|
92
|
-
renderWithProvider(<ListView schema={viewB_Schema} />);
|
|
92
|
+
renderWithProvider(<ListView schema={viewB_Schema} showViewSwitcher={true} />);
|
|
93
93
|
|
|
94
94
|
// Should use the schema default 'kanban' (since no storage exists for THIS view id)
|
|
95
95
|
// It should NOT use 'grid' from the global/default view.
|
|
@@ -117,7 +117,7 @@ describe('ListView Persistence', () => {
|
|
|
117
117
|
},
|
|
118
118
|
};
|
|
119
119
|
|
|
120
|
-
renderWithProvider(<ListView schema={schema} />);
|
|
120
|
+
renderWithProvider(<ListView schema={schema} showViewSwitcher={true} />);
|
|
121
121
|
|
|
122
122
|
// Should respect schema ('grid') because storage persistence is currently disabled
|
|
123
123
|
const kanbanButton = screen.getByLabelText('Kanban');
|
package/src/index.tsx
CHANGED
|
@@ -10,10 +10,10 @@ import { ComponentRegistry } from '@object-ui/core';
|
|
|
10
10
|
import { ListView } from './ListView';
|
|
11
11
|
import { ViewSwitcher } from './ViewSwitcher';
|
|
12
12
|
import { ObjectGallery } from './ObjectGallery';
|
|
13
|
-
import type { ListViewSchema } from '@object-ui/types';
|
|
14
13
|
|
|
15
14
|
export { ListView, ViewSwitcher, ObjectGallery };
|
|
16
15
|
export type { ListViewProps } from './ListView';
|
|
16
|
+
export type { ObjectGalleryProps } from './ObjectGallery';
|
|
17
17
|
export type { ViewSwitcherProps, ViewType } from './ViewSwitcher';
|
|
18
18
|
|
|
19
19
|
// Register ListView component
|
|
@@ -45,3 +45,25 @@ ComponentRegistry.register('list-view', ListView, {
|
|
|
45
45
|
options: {},
|
|
46
46
|
}
|
|
47
47
|
});
|
|
48
|
+
|
|
49
|
+
// Alias for generic view
|
|
50
|
+
ComponentRegistry.register('list', ListView, {
|
|
51
|
+
namespace: 'view',
|
|
52
|
+
category: 'view',
|
|
53
|
+
label: 'List',
|
|
54
|
+
icon: 'LayoutList',
|
|
55
|
+
inputs: [
|
|
56
|
+
{ name: 'objectName', type: 'string', label: 'Object Name', required: true },
|
|
57
|
+
{ name: 'viewType', type: 'enum', label: 'Default View', enum: [
|
|
58
|
+
{ label: 'Grid', value: 'grid' },
|
|
59
|
+
{ label: 'List', value: 'list' },
|
|
60
|
+
{ label: 'Kanban', value: 'kanban' },
|
|
61
|
+
{ label: 'Calendar', value: 'calendar' },
|
|
62
|
+
{ label: 'Chart', value: 'chart' }
|
|
63
|
+
], defaultValue: 'grid' },
|
|
64
|
+
{ name: 'fields', type: 'array', label: 'Fields' },
|
|
65
|
+
{ name: 'filters', type: 'array', label: 'Filters' },
|
|
66
|
+
{ name: 'sort', type: 'array', label: 'Sort' },
|
|
67
|
+
{ name: 'options', type: 'object', label: 'View Options' },
|
|
68
|
+
]
|
|
69
|
+
});
|