@object-ui/plugin-detail 0.5.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 +18 -0
- package/LICENSE +21 -0
- package/README.md +197 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +53257 -0
- package/dist/index.umd.cjs +95 -0
- package/dist/plugin-detail.css +1 -0
- package/dist/src/DetailSection.d.ts +16 -0
- package/dist/src/DetailSection.d.ts.map +1 -0
- package/dist/src/DetailTabs.d.ts +16 -0
- package/dist/src/DetailTabs.d.ts.map +1 -0
- package/dist/src/DetailView.d.ts +19 -0
- package/dist/src/DetailView.d.ts.map +1 -0
- package/dist/src/RelatedList.d.ts +19 -0
- package/dist/src/RelatedList.d.ts.map +1 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.d.ts.map +1 -0
- package/package.json +53 -0
- package/src/DetailSection.tsx +121 -0
- package/src/DetailTabs.tsx +73 -0
- package/src/DetailView.tsx +212 -0
- package/src/RelatedList.tsx +93 -0
- package/src/__tests__/DetailView.test.tsx +249 -0
- package/src/index.tsx +86 -0
- package/src/registration.test.tsx +18 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +56 -0
- package/vitest.config.ts +13 -0
- package/vitest.setup.ts +1 -0
|
@@ -0,0 +1,212 @@
|
|
|
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, Skeleton } from '@object-ui/components';
|
|
11
|
+
import { ArrowLeft, Edit, Trash2, MoreHorizontal } from 'lucide-react';
|
|
12
|
+
import { DetailSection } from './DetailSection';
|
|
13
|
+
import { DetailTabs } from './DetailTabs';
|
|
14
|
+
import { RelatedList } from './RelatedList';
|
|
15
|
+
import { SchemaRenderer } from '@object-ui/react';
|
|
16
|
+
import type { DetailViewSchema, DataSource } from '@object-ui/types';
|
|
17
|
+
|
|
18
|
+
export interface DetailViewProps {
|
|
19
|
+
schema: DetailViewSchema;
|
|
20
|
+
dataSource?: DataSource;
|
|
21
|
+
className?: string;
|
|
22
|
+
onEdit?: () => void;
|
|
23
|
+
onDelete?: () => void;
|
|
24
|
+
onBack?: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const DetailView: React.FC<DetailViewProps> = ({
|
|
28
|
+
schema,
|
|
29
|
+
dataSource,
|
|
30
|
+
className,
|
|
31
|
+
onEdit,
|
|
32
|
+
onDelete,
|
|
33
|
+
onBack,
|
|
34
|
+
}) => {
|
|
35
|
+
const [data, setData] = React.useState<any>(schema.data);
|
|
36
|
+
const [loading, setLoading] = React.useState(!schema.data && !!((schema.api && schema.resourceId) || (dataSource && schema.objectName && schema.resourceId)));
|
|
37
|
+
|
|
38
|
+
// Fetch data if API or DataSource provided
|
|
39
|
+
React.useEffect(() => {
|
|
40
|
+
// If inline data provided, use it
|
|
41
|
+
if (schema.data) {
|
|
42
|
+
setData(schema.data);
|
|
43
|
+
setLoading(false);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (dataSource && schema.objectName && schema.resourceId) {
|
|
48
|
+
setLoading(true);
|
|
49
|
+
dataSource.findOne(schema.objectName, schema.resourceId).then((result) => {
|
|
50
|
+
setData(result);
|
|
51
|
+
setLoading(false);
|
|
52
|
+
}).catch((err) => {
|
|
53
|
+
console.error('Failed to fetch detail data:', err);
|
|
54
|
+
setLoading(false);
|
|
55
|
+
});
|
|
56
|
+
} else if (schema.api && schema.resourceId) {
|
|
57
|
+
setLoading(true);
|
|
58
|
+
// TODO: Fetch from API
|
|
59
|
+
// This would integrate with the data provider
|
|
60
|
+
setTimeout(() => {
|
|
61
|
+
setLoading(false);
|
|
62
|
+
}, 500);
|
|
63
|
+
}
|
|
64
|
+
}, [schema.api, schema.resourceId]);
|
|
65
|
+
|
|
66
|
+
const handleBack = React.useCallback(() => {
|
|
67
|
+
if (onBack) {
|
|
68
|
+
onBack();
|
|
69
|
+
} else if (schema.backUrl) {
|
|
70
|
+
window.location.href = schema.backUrl;
|
|
71
|
+
} else {
|
|
72
|
+
window.history.back();
|
|
73
|
+
}
|
|
74
|
+
}, [onBack, schema.backUrl]);
|
|
75
|
+
|
|
76
|
+
const handleEdit = React.useCallback(() => {
|
|
77
|
+
if (onEdit) {
|
|
78
|
+
onEdit();
|
|
79
|
+
} else if (schema.editUrl) {
|
|
80
|
+
window.location.href = schema.editUrl;
|
|
81
|
+
}
|
|
82
|
+
// TODO: Implement inline edit mode
|
|
83
|
+
// else {
|
|
84
|
+
// setEditMode(true);
|
|
85
|
+
// }
|
|
86
|
+
}, [onEdit, schema.editUrl]);
|
|
87
|
+
|
|
88
|
+
const handleDelete = React.useCallback(() => {
|
|
89
|
+
// TODO: Replace with proper confirmation dialog component
|
|
90
|
+
const confirmMessage = schema.deleteConfirmation || 'Are you sure you want to delete this record?';
|
|
91
|
+
if (window.confirm(confirmMessage)) {
|
|
92
|
+
onDelete?.();
|
|
93
|
+
}
|
|
94
|
+
}, [onDelete, schema.deleteConfirmation]);
|
|
95
|
+
|
|
96
|
+
if (loading || schema.loading) {
|
|
97
|
+
return (
|
|
98
|
+
<div className={cn('space-y-4', className)}>
|
|
99
|
+
<Skeleton className="h-10 w-full" />
|
|
100
|
+
<Skeleton className="h-64 w-full" />
|
|
101
|
+
<Skeleton className="h-48 w-full" />
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div className={cn('space-y-6', className)}>
|
|
108
|
+
{/* Header */}
|
|
109
|
+
<div className="flex items-center justify-between">
|
|
110
|
+
<div className="flex items-center gap-4">
|
|
111
|
+
{(schema.showBack ?? true) && (
|
|
112
|
+
<Button variant="ghost" size="icon" onClick={handleBack}>
|
|
113
|
+
<ArrowLeft className="h-4 w-4" />
|
|
114
|
+
</Button>
|
|
115
|
+
)}
|
|
116
|
+
<div>
|
|
117
|
+
<h1 className="text-2xl font-bold">{schema.title || 'Details'}</h1>
|
|
118
|
+
{schema.objectName && (
|
|
119
|
+
<p className="text-sm text-muted-foreground mt-1">
|
|
120
|
+
{schema.objectName} #{schema.resourceId}
|
|
121
|
+
</p>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<div className="flex items-center gap-2">
|
|
127
|
+
{schema.actions?.map((action, index) => (
|
|
128
|
+
<SchemaRenderer key={index} schema={action} data={data} />
|
|
129
|
+
))}
|
|
130
|
+
|
|
131
|
+
{schema.showEdit && (
|
|
132
|
+
<Button variant="outline" onClick={handleEdit}>
|
|
133
|
+
<Edit className="h-4 w-4 mr-2" />
|
|
134
|
+
Edit
|
|
135
|
+
</Button>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
{schema.showDelete && (
|
|
139
|
+
<Button variant="destructive" onClick={handleDelete}>
|
|
140
|
+
<Trash2 className="h-4 w-4 mr-2" />
|
|
141
|
+
Delete
|
|
142
|
+
</Button>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
<Button variant="ghost" size="icon">
|
|
146
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
147
|
+
</Button>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
{/* Custom Header */}
|
|
152
|
+
{schema.header && (
|
|
153
|
+
<div>
|
|
154
|
+
<SchemaRenderer schema={schema.header} data={data} />
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
{/* Sections */}
|
|
159
|
+
{schema.sections && schema.sections.length > 0 && (
|
|
160
|
+
<div className="space-y-4">
|
|
161
|
+
{schema.sections.map((section, index) => (
|
|
162
|
+
<DetailSection
|
|
163
|
+
key={index}
|
|
164
|
+
section={section}
|
|
165
|
+
data={data}
|
|
166
|
+
/>
|
|
167
|
+
))}
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
{/* Direct Fields (if no sections) */}
|
|
172
|
+
{schema.fields && schema.fields.length > 0 && !schema.sections?.length && (
|
|
173
|
+
<DetailSection
|
|
174
|
+
section={{
|
|
175
|
+
fields: schema.fields,
|
|
176
|
+
columns: schema.columns || 2,
|
|
177
|
+
}}
|
|
178
|
+
data={data}
|
|
179
|
+
/>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{/* Tabs */}
|
|
183
|
+
{schema.tabs && schema.tabs.length > 0 && (
|
|
184
|
+
<DetailTabs tabs={schema.tabs} data={data} />
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{/* Related Lists */}
|
|
188
|
+
{schema.related && schema.related.length > 0 && (
|
|
189
|
+
<div className="space-y-4">
|
|
190
|
+
<h2 className="text-xl font-semibold">Related</h2>
|
|
191
|
+
{schema.related.map((related, index) => (
|
|
192
|
+
<RelatedList
|
|
193
|
+
key={index}
|
|
194
|
+
title={related.title}
|
|
195
|
+
type={related.type}
|
|
196
|
+
api={related.api}
|
|
197
|
+
data={related.data}
|
|
198
|
+
columns={related.columns as any}
|
|
199
|
+
/>
|
|
200
|
+
))}
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
|
|
204
|
+
{/* Custom Footer */}
|
|
205
|
+
{schema.footer && (
|
|
206
|
+
<div>
|
|
207
|
+
<SchemaRenderer schema={schema.footer} data={data} />
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
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 { Card, CardHeader, CardTitle, CardContent } from '@object-ui/components';
|
|
11
|
+
import { SchemaRenderer } from '@object-ui/react';
|
|
12
|
+
|
|
13
|
+
export interface RelatedListProps {
|
|
14
|
+
title: string;
|
|
15
|
+
type: 'list' | 'grid' | 'table';
|
|
16
|
+
api?: string;
|
|
17
|
+
data?: any[];
|
|
18
|
+
schema?: any;
|
|
19
|
+
columns?: any[];
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const RelatedList: React.FC<RelatedListProps> = ({
|
|
24
|
+
title,
|
|
25
|
+
type,
|
|
26
|
+
api,
|
|
27
|
+
data = [],
|
|
28
|
+
schema,
|
|
29
|
+
columns,
|
|
30
|
+
className,
|
|
31
|
+
}) => {
|
|
32
|
+
const [relatedData] = React.useState(data);
|
|
33
|
+
const [loading, setLoading] = React.useState(false);
|
|
34
|
+
|
|
35
|
+
React.useEffect(() => {
|
|
36
|
+
if (api && !data.length) {
|
|
37
|
+
setLoading(true);
|
|
38
|
+
// TODO: Fetch data from API
|
|
39
|
+
// This would integrate with the data provider
|
|
40
|
+
setLoading(false);
|
|
41
|
+
}
|
|
42
|
+
}, [api, data]);
|
|
43
|
+
|
|
44
|
+
const viewSchema = React.useMemo(() => {
|
|
45
|
+
if (schema) return schema;
|
|
46
|
+
|
|
47
|
+
// Auto-generate schema based on type
|
|
48
|
+
switch (type) {
|
|
49
|
+
case 'grid':
|
|
50
|
+
case 'table':
|
|
51
|
+
return {
|
|
52
|
+
type: 'data-table',
|
|
53
|
+
data: relatedData,
|
|
54
|
+
columns: columns || [],
|
|
55
|
+
pagination: relatedData.length > 10,
|
|
56
|
+
pageSize: 10,
|
|
57
|
+
};
|
|
58
|
+
case 'list':
|
|
59
|
+
return {
|
|
60
|
+
type: 'data-list',
|
|
61
|
+
data: relatedData,
|
|
62
|
+
};
|
|
63
|
+
default:
|
|
64
|
+
return { type: 'div', children: 'No view configured' };
|
|
65
|
+
}
|
|
66
|
+
}, [type, relatedData, columns, schema]);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Card className={className}>
|
|
70
|
+
<CardHeader>
|
|
71
|
+
<CardTitle className="flex items-center justify-between">
|
|
72
|
+
<span>{title}</span>
|
|
73
|
+
<span className="text-sm font-normal text-muted-foreground">
|
|
74
|
+
{relatedData.length} record{relatedData.length !== 1 ? 's' : ''}
|
|
75
|
+
</span>
|
|
76
|
+
</CardTitle>
|
|
77
|
+
</CardHeader>
|
|
78
|
+
<CardContent>
|
|
79
|
+
{loading ? (
|
|
80
|
+
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
|
81
|
+
Loading...
|
|
82
|
+
</div>
|
|
83
|
+
) : relatedData.length === 0 ? (
|
|
84
|
+
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
|
|
85
|
+
No related records found
|
|
86
|
+
</div>
|
|
87
|
+
) : (
|
|
88
|
+
<SchemaRenderer schema={viewSchema} />
|
|
89
|
+
)}
|
|
90
|
+
</CardContent>
|
|
91
|
+
</Card>
|
|
92
|
+
);
|
|
93
|
+
};
|
|
@@ -0,0 +1,249 @@
|
|
|
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 { describe, it, expect, vi } from 'vitest';
|
|
10
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
11
|
+
import { DetailView } from '../DetailView';
|
|
12
|
+
import type { DetailViewSchema } from '@object-ui/types';
|
|
13
|
+
|
|
14
|
+
describe('DetailView', () => {
|
|
15
|
+
it('should be exported', () => {
|
|
16
|
+
expect(DetailView).toBeDefined();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should be a function', () => {
|
|
20
|
+
expect(typeof DetailView).toBe('function');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should render with basic schema', () => {
|
|
24
|
+
const schema: DetailViewSchema = {
|
|
25
|
+
type: 'detail-view',
|
|
26
|
+
title: 'Contact Details',
|
|
27
|
+
data: {
|
|
28
|
+
name: 'John Doe',
|
|
29
|
+
email: 'john@example.com',
|
|
30
|
+
},
|
|
31
|
+
fields: [
|
|
32
|
+
{ name: 'name', label: 'Name' },
|
|
33
|
+
{ name: 'email', label: 'Email' },
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const { container } = render(<DetailView schema={schema} />);
|
|
38
|
+
expect(container).toBeTruthy();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should render title', () => {
|
|
42
|
+
const schema: DetailViewSchema = {
|
|
43
|
+
type: 'detail-view',
|
|
44
|
+
title: 'Contact Details',
|
|
45
|
+
data: { name: 'John Doe' },
|
|
46
|
+
fields: [{ name: 'name', label: 'Name' }],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
render(<DetailView schema={schema} />);
|
|
50
|
+
expect(screen.getByText('Contact Details')).toBeInTheDocument();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should render back button when showBack is true', () => {
|
|
54
|
+
const onBack = vi.fn();
|
|
55
|
+
const schema: DetailViewSchema = {
|
|
56
|
+
type: 'detail-view',
|
|
57
|
+
title: 'Contact Details',
|
|
58
|
+
data: { name: 'John Doe' },
|
|
59
|
+
fields: [{ name: 'name', label: 'Name' }],
|
|
60
|
+
showBack: true,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
render(<DetailView schema={schema} onBack={onBack} />);
|
|
64
|
+
|
|
65
|
+
const buttons = screen.getAllByRole('button');
|
|
66
|
+
const backButton = buttons.find(btn =>
|
|
67
|
+
btn.querySelector('svg') !== null
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(backButton).toBeTruthy();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should call onBack when back button is clicked', () => {
|
|
74
|
+
const onBack = vi.fn();
|
|
75
|
+
const schema: DetailViewSchema = {
|
|
76
|
+
type: 'detail-view',
|
|
77
|
+
title: 'Contact Details',
|
|
78
|
+
data: { name: 'John Doe' },
|
|
79
|
+
fields: [{ name: 'name', label: 'Name' }],
|
|
80
|
+
showBack: true,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
render(<DetailView schema={schema} onBack={onBack} />);
|
|
84
|
+
|
|
85
|
+
const buttons = screen.getAllByRole('button');
|
|
86
|
+
const backButton = buttons.find(btn =>
|
|
87
|
+
btn.querySelector('svg') !== null
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (backButton) {
|
|
91
|
+
fireEvent.click(backButton);
|
|
92
|
+
expect(onBack).toHaveBeenCalled();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should render edit button when showEdit is true', () => {
|
|
97
|
+
const schema: DetailViewSchema = {
|
|
98
|
+
type: 'detail-view',
|
|
99
|
+
title: 'Contact Details',
|
|
100
|
+
data: { name: 'John Doe' },
|
|
101
|
+
fields: [{ name: 'name', label: 'Name' }],
|
|
102
|
+
showEdit: true,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
render(<DetailView schema={schema} />);
|
|
106
|
+
|
|
107
|
+
// Edit button should be present
|
|
108
|
+
const buttons = screen.getAllByRole('button');
|
|
109
|
+
expect(buttons.length).toBeGreaterThan(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should call onEdit when edit button is clicked', () => {
|
|
113
|
+
const onEdit = vi.fn();
|
|
114
|
+
const schema: DetailViewSchema = {
|
|
115
|
+
type: 'detail-view',
|
|
116
|
+
title: 'Contact Details',
|
|
117
|
+
data: { name: 'John Doe' },
|
|
118
|
+
fields: [{ name: 'name', label: 'Name' }],
|
|
119
|
+
showEdit: true,
|
|
120
|
+
// Disable back button to ensure it's not the first button found if using generic search
|
|
121
|
+
showBack: false
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
render(<DetailView schema={schema} onEdit={onEdit} />);
|
|
125
|
+
|
|
126
|
+
// Find button with text "Edit"
|
|
127
|
+
const editButton = screen.getByRole('button', { name: /edit/i });
|
|
128
|
+
|
|
129
|
+
if (editButton) {
|
|
130
|
+
fireEvent.click(editButton);
|
|
131
|
+
expect(onEdit).toHaveBeenCalled();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should render delete button when showDelete is true', () => {
|
|
136
|
+
const schema: DetailViewSchema = {
|
|
137
|
+
type: 'detail-view',
|
|
138
|
+
title: 'Contact Details',
|
|
139
|
+
data: { name: 'John Doe' },
|
|
140
|
+
fields: [{ name: 'name', label: 'Name' }],
|
|
141
|
+
showDelete: true,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
render(<DetailView schema={schema} />);
|
|
145
|
+
|
|
146
|
+
const buttons = screen.getAllByRole('button');
|
|
147
|
+
expect(buttons.length).toBeGreaterThan(0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should render sections when provided', () => {
|
|
151
|
+
const schema: DetailViewSchema = {
|
|
152
|
+
type: 'detail-view',
|
|
153
|
+
title: 'Contact Details',
|
|
154
|
+
data: {
|
|
155
|
+
name: 'John Doe',
|
|
156
|
+
email: 'john@example.com',
|
|
157
|
+
phone: '123-456-7890',
|
|
158
|
+
},
|
|
159
|
+
sections: [
|
|
160
|
+
{
|
|
161
|
+
title: 'Basic Information',
|
|
162
|
+
fields: [
|
|
163
|
+
{ name: 'name', label: 'Name' },
|
|
164
|
+
{ name: 'email', label: 'Email' },
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
title: 'Contact Information',
|
|
169
|
+
fields: [
|
|
170
|
+
{ name: 'phone', label: 'Phone' },
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
render(<DetailView schema={schema} />);
|
|
177
|
+
|
|
178
|
+
expect(screen.getByText('Basic Information')).toBeInTheDocument();
|
|
179
|
+
expect(screen.getByText('Contact Information')).toBeInTheDocument();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should render tabs when provided', () => {
|
|
183
|
+
const schema: DetailViewSchema = {
|
|
184
|
+
type: 'detail-view',
|
|
185
|
+
title: 'Account Details',
|
|
186
|
+
data: { name: 'Acme Corp' },
|
|
187
|
+
tabs: [
|
|
188
|
+
{
|
|
189
|
+
key: 'details',
|
|
190
|
+
label: 'Details',
|
|
191
|
+
content: {
|
|
192
|
+
type: 'text',
|
|
193
|
+
text: 'Details content',
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
key: 'activity',
|
|
198
|
+
label: 'Activity',
|
|
199
|
+
content: {
|
|
200
|
+
type: 'text',
|
|
201
|
+
text: 'Activity content',
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
render(<DetailView schema={schema} />);
|
|
208
|
+
|
|
209
|
+
expect(screen.getByText('Details')).toBeInTheDocument();
|
|
210
|
+
expect(screen.getByText('Activity')).toBeInTheDocument();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should render related lists when provided', () => {
|
|
214
|
+
const schema: DetailViewSchema = {
|
|
215
|
+
type: 'detail-view',
|
|
216
|
+
title: 'Account Details',
|
|
217
|
+
data: { name: 'Acme Corp' },
|
|
218
|
+
fields: [{ name: 'name', label: 'Name' }],
|
|
219
|
+
related: [
|
|
220
|
+
{
|
|
221
|
+
title: 'Contacts',
|
|
222
|
+
type: 'table',
|
|
223
|
+
data: [],
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
render(<DetailView schema={schema} />);
|
|
229
|
+
|
|
230
|
+
expect(screen.getByText('Contacts')).toBeInTheDocument();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should show loading skeleton when loading is true', () => {
|
|
234
|
+
const schema: DetailViewSchema = {
|
|
235
|
+
type: 'detail-view',
|
|
236
|
+
title: 'Contact Details',
|
|
237
|
+
data: { name: 'John Doe' },
|
|
238
|
+
fields: [{ name: 'name', label: 'Name' }],
|
|
239
|
+
loading: true,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const { container } = render(<DetailView schema={schema} />);
|
|
243
|
+
|
|
244
|
+
// Check for skeleton elements (they typically have animate-pulse class)
|
|
245
|
+
// DetailedView uses Skeleton component which has animate-pulse class
|
|
246
|
+
const skeletons = container.querySelectorAll('.animate-pulse');
|
|
247
|
+
expect(skeletons.length).toBeGreaterThan(0);
|
|
248
|
+
});
|
|
249
|
+
});
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
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 { ComponentRegistry } from '@object-ui/core';
|
|
10
|
+
import { DetailView } from './DetailView';
|
|
11
|
+
import { DetailSection } from './DetailSection';
|
|
12
|
+
import { DetailTabs } from './DetailTabs';
|
|
13
|
+
import { RelatedList } from './RelatedList';
|
|
14
|
+
import type { DetailViewSchema } from '@object-ui/types';
|
|
15
|
+
|
|
16
|
+
export { DetailView, DetailSection, DetailTabs, RelatedList };
|
|
17
|
+
export type { DetailViewProps } from './DetailView';
|
|
18
|
+
export type { DetailSectionProps } from './DetailSection';
|
|
19
|
+
export type { DetailTabsProps } from './DetailTabs';
|
|
20
|
+
export type { RelatedListProps } from './RelatedList';
|
|
21
|
+
|
|
22
|
+
// Register DetailView component
|
|
23
|
+
ComponentRegistry.register('detail-view', DetailView, {
|
|
24
|
+
namespace: 'plugin-detail',
|
|
25
|
+
label: 'Detail View',
|
|
26
|
+
category: 'Views',
|
|
27
|
+
icon: 'FileText',
|
|
28
|
+
inputs: [
|
|
29
|
+
{ name: 'title', type: 'string', label: 'Title' },
|
|
30
|
+
{ name: 'objectName', type: 'string', label: 'Object Name' },
|
|
31
|
+
{ name: 'resourceId', type: 'string', label: 'Resource ID' },
|
|
32
|
+
{ name: 'api', type: 'string', label: 'API Endpoint' },
|
|
33
|
+
{ name: 'data', type: 'object', label: 'Data' },
|
|
34
|
+
{ name: 'sections', type: 'array', label: 'Sections' },
|
|
35
|
+
{ name: 'fields', type: 'array', label: 'Fields' },
|
|
36
|
+
{ name: 'tabs', type: 'array', label: 'Tabs' },
|
|
37
|
+
{ name: 'related', type: 'array', label: 'Related Lists' },
|
|
38
|
+
{ name: 'actions', type: 'array', label: 'Actions' },
|
|
39
|
+
{ name: 'showBack', type: 'boolean', label: 'Show Back Button', defaultValue: true },
|
|
40
|
+
{ name: 'showEdit', type: 'boolean', label: 'Show Edit Button', defaultValue: false },
|
|
41
|
+
{ name: 'showDelete', type: 'boolean', label: 'Show Delete Button', defaultValue: false },
|
|
42
|
+
],
|
|
43
|
+
defaultProps: {
|
|
44
|
+
title: 'Detail View',
|
|
45
|
+
showBack: true,
|
|
46
|
+
showEdit: false,
|
|
47
|
+
showDelete: false,
|
|
48
|
+
sections: [],
|
|
49
|
+
fields: [],
|
|
50
|
+
tabs: [],
|
|
51
|
+
related: [],
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Register DetailSection component
|
|
56
|
+
ComponentRegistry.register('detail-section', DetailSection, {
|
|
57
|
+
namespace: 'plugin-detail',
|
|
58
|
+
label: 'Detail Section',
|
|
59
|
+
category: 'Detail Components',
|
|
60
|
+
inputs: [
|
|
61
|
+
{ name: 'title', type: 'string', label: 'Title' },
|
|
62
|
+
{ name: 'description', type: 'string', label: 'Description' },
|
|
63
|
+
{ name: 'fields', type: 'array', label: 'Fields', required: true },
|
|
64
|
+
{ name: 'collapsible', type: 'boolean', label: 'Collapsible', defaultValue: false },
|
|
65
|
+
{ name: 'defaultCollapsed', type: 'boolean', label: 'Default Collapsed', defaultValue: false },
|
|
66
|
+
{ name: 'columns', type: 'number', label: 'Columns', defaultValue: 2 },
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Register RelatedList component
|
|
71
|
+
ComponentRegistry.register('related-list', RelatedList, {
|
|
72
|
+
namespace: 'plugin-detail',
|
|
73
|
+
label: 'Related List',
|
|
74
|
+
category: 'Detail Components',
|
|
75
|
+
inputs: [
|
|
76
|
+
{ name: 'title', type: 'string', label: 'Title', required: true },
|
|
77
|
+
{ name: 'type', type: 'enum', label: 'Type', enum: [
|
|
78
|
+
{ label: 'List', value: 'list' },
|
|
79
|
+
{ label: 'Grid', value: 'grid' },
|
|
80
|
+
{ label: 'Table', value: 'table' }
|
|
81
|
+
], defaultValue: 'table' },
|
|
82
|
+
{ name: 'api', type: 'string', label: 'API Endpoint' },
|
|
83
|
+
{ name: 'data', type: 'array', label: 'Data' },
|
|
84
|
+
{ name: 'columns', type: 'array', label: 'Columns' },
|
|
85
|
+
],
|
|
86
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
2
|
+
import { ComponentRegistry } from '@object-ui/core';
|
|
3
|
+
|
|
4
|
+
describe('Plugin Detail Registration', () => {
|
|
5
|
+
beforeAll(async () => {
|
|
6
|
+
await import('./index');
|
|
7
|
+
}, 15000); // Increase timeout to 15 seconds for async import
|
|
8
|
+
|
|
9
|
+
it('registers detail-view component', () => {
|
|
10
|
+
// We must use getConfig to retrieve the metadata (label, category, etc.)
|
|
11
|
+
// .get() only returns the React Component.
|
|
12
|
+
const config = ComponentRegistry.getConfig('detail-view');
|
|
13
|
+
|
|
14
|
+
expect(config).toBeDefined();
|
|
15
|
+
expect(config?.label).toBe('Detail View');
|
|
16
|
+
expect(config?.category).toBe('Views');
|
|
17
|
+
});
|
|
18
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "dist",
|
|
5
|
+
"jsx": "react-jsx",
|
|
6
|
+
"baseUrl": ".",
|
|
7
|
+
"paths": {
|
|
8
|
+
"@/*": ["src/*"]
|
|
9
|
+
},
|
|
10
|
+
"noEmit": false,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"composite": true,
|
|
13
|
+
"declarationMap": true,
|
|
14
|
+
"skipLibCheck": true
|
|
15
|
+
},
|
|
16
|
+
"include": ["src"],
|
|
17
|
+
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"]
|
|
18
|
+
}
|