@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.
@@ -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
- // Utility for class merging (assuming it's available in plugin context,
7
- // usually provided by @object-ui/components or utils, but here I'll just use string concat if not imported)
8
- // Actually @object-ui/components exports cn
9
- import { cn } from '@object-ui/components';
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
- export const ObjectGallery = (props: any) => {
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<any[]>([]);
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
- // Use data prop if available (from ListView)
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
- $filter: schema.filter
76
+ $filter: schema.filter,
36
77
  });
37
-
38
- let data: any[] = [];
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
- if (Array.isArray((results as any).value)) {
43
- data = (results as any).value;
44
- } else if (Array.isArray((results as any).data)) {
45
- data = (results as any).data;
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
- }, [schema.objectName, dataSource, boundData, schema.data, schema.filter, props.data]);
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 className={cn("grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 p-4", schema.className)}>
77
- {items.map((item: any, i: number) => (
78
- <div key={item._id || i} className="group relative border rounded-lg overflow-hidden bg-card text-card-foreground shadow-sm hover:shadow-md transition-all">
79
- <div className="aspect-square w-full overflow-hidden bg-muted relative">
80
- {item[imageField] ? (
81
- <img
82
- src={item[imageField]}
83
- alt={item[titleField]}
84
- className="h-full w-full object-cover transition-transform group-hover:scale-105"
85
- onError={(e) => {
86
- (e.target as HTMLImageElement).src = `https://placehold.co/400x400?text=${encodeURIComponent(item[titleField]?.[0] || '?')}`;
87
- }}
88
- />
89
- ) : (
90
- <div className="flex h-full w-full items-center justify-center bg-secondary/50 text-muted-foreground">
91
- <span className="text-4xl font-light opacity-20">{item[titleField]?.[0]?.toUpperCase()}</span>
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
- </div>
101
- </div>
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
  });
@@ -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={() => onViewChange(view)}
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 input', () => {
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 searchInput = screen.getByPlaceholderText(/find/i);
79
- expect(searchInput).toBeInTheDocument();
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
+ });
@@ -1,5 +1,4 @@
1
- import { describe, it, expect, beforeAll } from 'vitest';
2
- import { ComponentRegistry } from '@object-ui/core';
1
+ import { describe, it, expect } from 'vitest';
3
2
  import { ListView } from './index';
4
3
 
5
4
  describe('Plugin List Registration', () => {
package/vitest.config.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  /// <reference types="vitest" />
2
2
  import { defineConfig } from 'vite';
3
3
  import react from '@vitejs/plugin-react';
4
- import path from 'path';
5
4
 
6
5
  export default defineConfig({
7
6
  plugins: [react()],