@strato-admin/cloudscape 0.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/LICENSE +21 -0
- package/README.md +3 -0
- package/dist/Admin.d.ts +17 -0
- package/dist/Admin.js +69 -0
- package/dist/RecordLink.d.ts +9 -0
- package/dist/RecordLink.js +43 -0
- package/dist/__mocks__/strato-core.js +50 -0
- package/dist/__mocks__to__delete/strato-core.js +50 -0
- package/dist/button/BulkDeleteButton.d.ts +7 -0
- package/dist/button/BulkDeleteButton.js +17 -0
- package/dist/button/Button.d.ts +6 -0
- package/dist/button/Button.js +6 -0
- package/dist/button/CreateButton.d.ts +6 -0
- package/dist/button/CreateButton.js +24 -0
- package/dist/button/EditButton.d.ts +8 -0
- package/dist/button/EditButton.js +24 -0
- package/dist/button/SaveButton.d.ts +6 -0
- package/dist/button/SaveButton.js +8 -0
- package/dist/button/index.d.ts +5 -0
- package/dist/button/index.js +5 -0
- package/dist/collection-hooks/index.d.ts +2 -0
- package/dist/collection-hooks/index.js +2 -0
- package/dist/collection-hooks/interfaces.d.ts +93 -0
- package/dist/collection-hooks/interfaces.js +1 -0
- package/dist/collection-hooks/useCollection.d.ts +3 -0
- package/dist/collection-hooks/useCollection.js +102 -0
- package/dist/create/Create.d.ts +40 -0
- package/dist/create/Create.js +34 -0
- package/dist/create/CreateHeader.d.ts +7 -0
- package/dist/create/CreateHeader.js +18 -0
- package/dist/create/index.d.ts +2 -0
- package/dist/create/index.js +2 -0
- package/dist/detail/KeyValuePairs.d.ts +36 -0
- package/dist/detail/KeyValuePairs.js +58 -0
- package/dist/detail/Show.d.ts +39 -0
- package/dist/detail/Show.js +40 -0
- package/dist/detail/ShowHeader.d.ts +7 -0
- package/dist/detail/ShowHeader.js +19 -0
- package/dist/detail/index.d.ts +3 -0
- package/dist/detail/index.js +3 -0
- package/dist/edit/Edit.d.ts +42 -0
- package/dist/edit/Edit.js +38 -0
- package/dist/edit/EditHeader.d.ts +7 -0
- package/dist/edit/EditHeader.js +18 -0
- package/dist/edit/index.d.ts +2 -0
- package/dist/edit/index.js +2 -0
- package/dist/field/ArrayField.d.ts +29 -0
- package/dist/field/ArrayField.js +30 -0
- package/dist/field/BadgeField.d.ts +12 -0
- package/dist/field/BadgeField.js +15 -0
- package/dist/field/BooleanField.d.ts +18 -0
- package/dist/field/BooleanField.js +14 -0
- package/dist/field/CurrencyField.d.ts +19 -0
- package/dist/field/CurrencyField.js +23 -0
- package/dist/field/DateField.d.ts +14 -0
- package/dist/field/DateField.js +17 -0
- package/dist/field/IdField.d.ts +17 -0
- package/dist/field/IdField.js +21 -0
- package/dist/field/NumberField.d.ts +14 -0
- package/dist/field/NumberField.js +18 -0
- package/dist/field/ReferenceField.d.ts +16 -0
- package/dist/field/ReferenceField.js +23 -0
- package/dist/field/ReferenceManyField.d.ts +55 -0
- package/dist/field/ReferenceManyField.js +19 -0
- package/dist/field/StatusIndicatorField.d.ts +56 -0
- package/dist/field/StatusIndicatorField.js +48 -0
- package/dist/field/TextField.d.ts +5 -0
- package/dist/field/TextField.js +11 -0
- package/dist/field/index.d.ts +23 -0
- package/dist/field/index.js +23 -0
- package/dist/field/types.d.ts +56 -0
- package/dist/field/types.js +1 -0
- package/dist/form/Form.d.ts +13 -0
- package/dist/form/Form.js +33 -0
- package/dist/form/index.d.ts +2 -0
- package/dist/form/index.js +2 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +22 -0
- package/dist/input/AttributeEditor.d.ts +25 -0
- package/dist/input/AttributeEditor.js +80 -0
- package/dist/input/AutocompleteInput.d.ts +10 -0
- package/dist/input/AutocompleteInput.js +67 -0
- package/dist/input/FieldTitle.d.ts +8 -0
- package/dist/input/FieldTitle.js +29 -0
- package/dist/input/FormField.d.ts +7 -0
- package/dist/input/FormField.js +35 -0
- package/dist/input/FormFieldContext.d.ts +6 -0
- package/dist/input/FormFieldContext.js +3 -0
- package/dist/input/NumberInput.d.ts +7 -0
- package/dist/input/NumberInput.js +27 -0
- package/dist/input/ReferenceInput.d.ts +3 -0
- package/dist/input/ReferenceInput.js +25 -0
- package/dist/input/SelectInput.d.ts +15 -0
- package/dist/input/SelectInput.js +47 -0
- package/dist/input/SliderInput.d.ts +6 -0
- package/dist/input/SliderInput.js +25 -0
- package/dist/input/TextAreaInput.d.ts +6 -0
- package/dist/input/TextAreaInput.js +23 -0
- package/dist/input/TextInput.d.ts +7 -0
- package/dist/input/TextInput.js +23 -0
- package/dist/input/index.d.ts +11 -0
- package/dist/input/index.js +11 -0
- package/dist/input/types.d.ts +6 -0
- package/dist/input/types.js +1 -0
- package/dist/layout/AppLayout.d.ts +8 -0
- package/dist/layout/AppLayout.js +38 -0
- package/dist/layout/TopNavigation.d.ts +6 -0
- package/dist/layout/TopNavigation.js +53 -0
- package/dist/layout/index.d.ts +2 -0
- package/dist/layout/index.js +2 -0
- package/dist/list/Cards.d.ts +11 -0
- package/dist/list/Cards.js +27 -0
- package/dist/list/List.d.ts +43 -0
- package/dist/list/List.js +28 -0
- package/dist/list/Table.d.ts +112 -0
- package/dist/list/Table.examples.d.ts +1 -0
- package/dist/list/Table.examples.js +3 -0
- package/dist/list/Table.js +218 -0
- package/dist/list/TableHeader.d.ts +7 -0
- package/dist/list/TableHeader.js +22 -0
- package/dist/list/index.d.ts +4 -0
- package/dist/list/index.js +4 -0
- package/dist/preferences/index.d.ts +0 -0
- package/dist/preferences/index.js +1 -0
- package/dist/theme/ThemeManager.d.ts +2 -0
- package/dist/theme/ThemeManager.js +11 -0
- package/dist/theme/index.d.ts +2 -0
- package/dist/theme/index.js +2 -0
- package/package.json +73 -0
- package/src/Admin.test.tsx +32 -0
- package/src/Admin.tsx +123 -0
- package/src/RecordLink.stories.tsx +56 -0
- package/src/RecordLink.tsx +67 -0
- package/src/__mocks__/strato-core.tsx +52 -0
- package/src/button/BulkDeleteButton.stories.tsx +59 -0
- package/src/button/BulkDeleteButton.test.tsx +64 -0
- package/src/button/BulkDeleteButton.tsx +41 -0
- package/src/button/Button.stories.tsx +31 -0
- package/src/button/Button.tsx +12 -0
- package/src/button/CreateButton.stories.tsx +42 -0
- package/src/button/CreateButton.tsx +38 -0
- package/src/button/EditButton.stories.tsx +29 -0
- package/src/button/EditButton.tsx +38 -0
- package/src/button/SaveButton.stories.tsx +35 -0
- package/src/button/SaveButton.tsx +19 -0
- package/src/button/index.ts +5 -0
- package/src/collection-hooks/index.ts +2 -0
- package/src/collection-hooks/interfaces.ts +80 -0
- package/src/collection-hooks/useCollection.test.ts +413 -0
- package/src/collection-hooks/useCollection.ts +125 -0
- package/src/create/Create.test.tsx +63 -0
- package/src/create/Create.tsx +93 -0
- package/src/create/CreateHeader.tsx +34 -0
- package/src/create/index.ts +2 -0
- package/src/detail/KeyValuePairs.test.tsx +98 -0
- package/src/detail/KeyValuePairs.tsx +107 -0
- package/src/detail/Show.test.tsx +96 -0
- package/src/detail/Show.tsx +104 -0
- package/src/detail/ShowHeader.test.tsx +80 -0
- package/src/detail/ShowHeader.tsx +35 -0
- package/src/detail/index.ts +3 -0
- package/src/edit/Edit.test.tsx +91 -0
- package/src/edit/Edit.tsx +102 -0
- package/src/edit/EditHeader.tsx +34 -0
- package/src/edit/index.ts +2 -0
- package/src/field/ArrayField.tsx +51 -0
- package/src/field/BadgeField.tsx +33 -0
- package/src/field/BooleanField.stories.tsx +56 -0
- package/src/field/BooleanField.test.tsx +63 -0
- package/src/field/BooleanField.tsx +42 -0
- package/src/field/CurrencyField.stories.tsx +67 -0
- package/src/field/CurrencyField.tsx +45 -0
- package/src/field/DateField.stories.tsx +67 -0
- package/src/field/DateField.tsx +33 -0
- package/src/field/IdField.test.tsx +88 -0
- package/src/field/IdField.tsx +40 -0
- package/src/field/NumberField.stories.tsx +75 -0
- package/src/field/NumberField.tsx +35 -0
- package/src/field/ReferenceField.test.tsx +88 -0
- package/src/field/ReferenceField.tsx +64 -0
- package/src/field/ReferenceManyField.test.tsx +41 -0
- package/src/field/ReferenceManyField.tsx +73 -0
- package/src/field/StatusIndicatorField.stories.tsx +93 -0
- package/src/field/StatusIndicatorField.test.tsx +143 -0
- package/src/field/StatusIndicatorField.tsx +119 -0
- package/src/field/TextField.stories.tsx +45 -0
- package/src/field/TextField.tsx +17 -0
- package/src/field/index.ts +23 -0
- package/src/field/types.ts +58 -0
- package/src/form/Form.test.tsx +55 -0
- package/src/form/Form.tsx +66 -0
- package/src/form/index.ts +2 -0
- package/src/index.ts +25 -0
- package/src/input/AttributeEditor.test.tsx +147 -0
- package/src/input/AttributeEditor.tsx +185 -0
- package/src/input/AutocompleteInput.test.tsx +178 -0
- package/src/input/AutocompleteInput.tsx +116 -0
- package/src/input/FieldTitle.tsx +53 -0
- package/src/input/FormField.tsx +87 -0
- package/src/input/FormFieldContext.ts +9 -0
- package/src/input/NumberInput.tsx +56 -0
- package/src/input/ReferenceInput.test.tsx +35 -0
- package/src/input/ReferenceInput.tsx +36 -0
- package/src/input/SelectInput.tsx +91 -0
- package/src/input/SliderInput.test.tsx +103 -0
- package/src/input/SliderInput.tsx +49 -0
- package/src/input/TextAreaInput.tsx +48 -0
- package/src/input/TextInput.test.tsx +91 -0
- package/src/input/TextInput.tsx +51 -0
- package/src/input/index.ts +11 -0
- package/src/input/types.ts +14 -0
- package/src/layout/AppLayout.test.tsx +87 -0
- package/src/layout/AppLayout.tsx +60 -0
- package/src/layout/TopNavigation.test.tsx +78 -0
- package/src/layout/TopNavigation.tsx +84 -0
- package/src/layout/index.ts +2 -0
- package/src/list/Cards.tsx +58 -0
- package/src/list/List.tsx +76 -0
- package/src/list/Table.examples.tsx +11 -0
- package/src/list/Table.stories.tsx +73 -0
- package/src/list/Table.test.tsx +255 -0
- package/src/list/Table.tsx +438 -0
- package/src/list/TableHeader.test.tsx +114 -0
- package/src/list/TableHeader.tsx +44 -0
- package/src/list/index.ts +4 -0
- package/src/preferences/index.ts +0 -0
- package/src/stories/Button.stories.ts +54 -0
- package/src/stories/Button.tsx +31 -0
- package/src/stories/Configure.mdx +369 -0
- package/src/stories/Header.stories.ts +34 -0
- package/src/stories/Header.tsx +47 -0
- package/src/stories/Page.stories.ts +33 -0
- package/src/stories/Page.tsx +71 -0
- package/src/stories/RaStoryDecorator.tsx +38 -0
- package/src/stories/assets/accessibility.png +0 -0
- package/src/stories/assets/accessibility.svg +1 -0
- package/src/stories/assets/addon-library.png +0 -0
- package/src/stories/assets/assets.png +0 -0
- package/src/stories/assets/avif-test-image.avif +0 -0
- package/src/stories/assets/context.png +0 -0
- package/src/stories/assets/discord.svg +1 -0
- package/src/stories/assets/docs.png +0 -0
- package/src/stories/assets/figma-plugin.png +0 -0
- package/src/stories/assets/github.svg +1 -0
- package/src/stories/assets/share.png +0 -0
- package/src/stories/assets/styling.png +0 -0
- package/src/stories/assets/testing.png +0 -0
- package/src/stories/assets/theming.png +0 -0
- package/src/stories/assets/tutorials.svg +1 -0
- package/src/stories/assets/youtube.svg +1 -0
- package/src/stories/button.css +30 -0
- package/src/stories/header.css +32 -0
- package/src/stories/page.css +68 -0
- package/src/theme/ThemeManager.tsx +15 -0
- package/src/theme/index.ts +2 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, cleanup } from '@testing-library/react';
|
|
3
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
4
|
+
import { useResourceContext, useListContext, useResourceDefinitions } from '@strato-admin/core';
|
|
5
|
+
import { useCollection } from '../collection-hooks';
|
|
6
|
+
import Table from './Table';
|
|
7
|
+
import CloudscapeTable from '@cloudscape-design/components/table';
|
|
8
|
+
|
|
9
|
+
// Mock ra-core
|
|
10
|
+
vi.mock('@strato-admin/core', () => import('../__mocks__/strato-core'));
|
|
11
|
+
|
|
12
|
+
// Mock react-router-dom
|
|
13
|
+
vi.mock('react-router-dom', () => ({
|
|
14
|
+
useNavigate: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// Mock useCollection
|
|
18
|
+
vi.mock('../collection-hooks', () => ({
|
|
19
|
+
useCollection: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
// Mock Cloudscape components
|
|
23
|
+
vi.mock('@cloudscape-design/components/table', () => ({
|
|
24
|
+
default: vi.fn(({ header }: any) => (
|
|
25
|
+
<div data-testid="cloudscape-table">{header && <div data-testid="table-header">{header}</div>}</div>
|
|
26
|
+
)),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock('@cloudscape-design/components/pagination', () => ({
|
|
30
|
+
default: () => <div data-testid="pagination" />,
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
vi.mock('@cloudscape-design/components/header', () => ({
|
|
34
|
+
default: ({ children, actions }: any) => (
|
|
35
|
+
<header data-testid="header">
|
|
36
|
+
<div data-testid="header-title">{children}</div>
|
|
37
|
+
{actions && <div data-testid="header-actions">{actions}</div>}
|
|
38
|
+
</header>
|
|
39
|
+
),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
vi.mock('@cloudscape-design/components/box', () => ({
|
|
43
|
+
default: ({ children }: any) => <div>{children}</div>,
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
vi.mock('@cloudscape-design/components/space-between', () => ({
|
|
47
|
+
default: ({ children }: any) => <div>{children}</div>,
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
describe('DataTable', () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.clearAllMocks();
|
|
53
|
+
(useCollection as any).mockReturnValue({
|
|
54
|
+
items: [],
|
|
55
|
+
paginationProps: {},
|
|
56
|
+
collectionProps: {},
|
|
57
|
+
filterProps: {},
|
|
58
|
+
preferencesProps: {
|
|
59
|
+
preferences: {
|
|
60
|
+
stripedRows: false,
|
|
61
|
+
wrapLines: false,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
(useListContext as any).mockReturnValue({ total: 0, isPending: false });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
cleanup();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should generate column IDs with resource prefix', () => {
|
|
73
|
+
(useResourceContext as any).mockReturnValue('products');
|
|
74
|
+
|
|
75
|
+
render(
|
|
76
|
+
<Table>
|
|
77
|
+
<Table.Column source="name" label="Product Name" />
|
|
78
|
+
<Table.Column source="price" label="Price" />
|
|
79
|
+
<Table.DateColumn source="lastRestocked" label="Last Restocked" />
|
|
80
|
+
<Table.BooleanColumn source="isEcoFriendly" label="Eco-Friendly" />
|
|
81
|
+
</Table>,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const tableProps = (CloudscapeTable as any).mock.calls[0][0];
|
|
85
|
+
expect(tableProps.columnDefinitions[0].id).toBe('products___name');
|
|
86
|
+
expect(tableProps.columnDefinitions[1].id).toBe('products___price');
|
|
87
|
+
expect(tableProps.columnDefinitions[2].id).toBe('products___lastRestocked');
|
|
88
|
+
expect(tableProps.columnDefinitions[3].id).toBe('products___isEcoFriendly');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should include sortingField in column definitions', () => {
|
|
92
|
+
(useResourceContext as any).mockReturnValue('products');
|
|
93
|
+
|
|
94
|
+
render(
|
|
95
|
+
<Table>
|
|
96
|
+
<Table.Column source="name" label="Product Name" />
|
|
97
|
+
</Table>,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const tableProps = (CloudscapeTable as any).mock.calls[0][0];
|
|
101
|
+
expect(tableProps.columnDefinitions[0].sortingField).toBe('name');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should generate column IDs with index if source is missing', () => {
|
|
105
|
+
(useResourceContext as any).mockReturnValue('categories');
|
|
106
|
+
|
|
107
|
+
render(
|
|
108
|
+
<Table>
|
|
109
|
+
<Table.Column label="No Source" />
|
|
110
|
+
</Table>,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const tableProps = (CloudscapeTable as any).mock.calls[0][0];
|
|
114
|
+
expect(tableProps.columnDefinitions[0].id).toBe('categories___col-0');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should generate column IDs without resource if resource is not available', () => {
|
|
118
|
+
(useResourceContext as any).mockReturnValue(undefined);
|
|
119
|
+
|
|
120
|
+
render(
|
|
121
|
+
<Table>
|
|
122
|
+
<Table.Column source="name" />
|
|
123
|
+
</Table>,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const tableProps = (CloudscapeTable as any).mock.calls[0][0];
|
|
127
|
+
expect(tableProps.columnDefinitions[0].id).toBe('name');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should reorder columns based on preferences', () => {
|
|
131
|
+
(useResourceContext as any).mockReturnValue('products');
|
|
132
|
+
(useCollection as any).mockReturnValue({
|
|
133
|
+
items: [],
|
|
134
|
+
paginationProps: {},
|
|
135
|
+
collectionProps: {},
|
|
136
|
+
filterProps: {},
|
|
137
|
+
preferencesProps: {
|
|
138
|
+
preferences: {
|
|
139
|
+
contentDisplay: [
|
|
140
|
+
{ id: 'products___price', visible: true },
|
|
141
|
+
{ id: 'products___name', visible: true },
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
render(
|
|
148
|
+
<Table>
|
|
149
|
+
<Table.Column source="name" label="Product Name" />
|
|
150
|
+
<Table.Column source="price" label="Price" />
|
|
151
|
+
</Table>,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const tableProps = (CloudscapeTable as any).mock.calls[0][0];
|
|
155
|
+
// Cloudscape handles the ordering via columnDisplay prop
|
|
156
|
+
expect(tableProps.columnDisplay).toEqual([
|
|
157
|
+
{ id: 'products___price', visible: true },
|
|
158
|
+
{ id: 'products___name', visible: true },
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should pass actions to TableHeader', () => {
|
|
163
|
+
(useResourceContext as any).mockReturnValue('products');
|
|
164
|
+
|
|
165
|
+
const { getByTestId, queryByText } = render(
|
|
166
|
+
<Table actions={<button>Custom Action</button>}>
|
|
167
|
+
<Table.Column source="name" label="Product Name" />
|
|
168
|
+
</Table>,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(getByTestId('header-actions')).toBeDefined();
|
|
172
|
+
expect(queryByText('Custom Action')).toBeDefined();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should pass actions={null} to TableHeader', () => {
|
|
176
|
+
(useResourceContext as any).mockReturnValue('products');
|
|
177
|
+
|
|
178
|
+
const { queryByTestId } = render(
|
|
179
|
+
<Table actions={null}>
|
|
180
|
+
<Table.Column source="name" label="Product Name" />
|
|
181
|
+
</Table>,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// TableHeader.test.tsx already verifies that it doesn't render children if actions={null}
|
|
185
|
+
// Here we check that header-actions div is not rendered (because of our mock)
|
|
186
|
+
expect(queryByTestId('header-actions')).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should pass selectionType to CloudscapeTable', () => {
|
|
190
|
+
render(
|
|
191
|
+
<Table selectionType="multi">
|
|
192
|
+
<Table.Column source="name" label="Product Name" />
|
|
193
|
+
</Table>,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const tableProps = (CloudscapeTable as any).mock.calls[0][0];
|
|
197
|
+
expect(tableProps.selectionType).toBe('multi');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should pass default visible fields to useCollection', () => {
|
|
201
|
+
(useResourceContext as any).mockReturnValue('products');
|
|
202
|
+
|
|
203
|
+
render(
|
|
204
|
+
<Table defaultVisibleFields={['name', 'price']}>
|
|
205
|
+
<Table.Column source="id" label="ID" />
|
|
206
|
+
<Table.Column source="name" label="Name" />
|
|
207
|
+
<Table.Column source="price" label="Price" />
|
|
208
|
+
<Table.Column source="category" label="Category" />
|
|
209
|
+
</Table>,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const collectionCall = (useCollection as any).mock.calls[0][0];
|
|
213
|
+
expect(collectionCall.preferences.visibleContent).toEqual(['products___name', 'products___price']);
|
|
214
|
+
expect(collectionCall.preferences.contentDisplay).toEqual([
|
|
215
|
+
{ id: 'products___id', visible: false },
|
|
216
|
+
{ id: 'products___name', visible: true },
|
|
217
|
+
{ id: 'products___price', visible: true },
|
|
218
|
+
{ id: 'products___category', visible: false },
|
|
219
|
+
]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should default to first 5 columns if defaultVisibleFields is not provided', () => {
|
|
223
|
+
(useResourceContext as any).mockReturnValue('products');
|
|
224
|
+
|
|
225
|
+
render(
|
|
226
|
+
<Table>
|
|
227
|
+
<Table.Column source="c1" />
|
|
228
|
+
<Table.Column source="c2" />
|
|
229
|
+
<Table.Column source="c3" />
|
|
230
|
+
<Table.Column source="c4" />
|
|
231
|
+
<Table.Column source="c5" />
|
|
232
|
+
<Table.Column source="c6" />
|
|
233
|
+
</Table>,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const collectionCall = (useCollection as any).mock.calls[0][0];
|
|
237
|
+
expect(collectionCall.preferences.visibleContent).toEqual([
|
|
238
|
+
'products___c1',
|
|
239
|
+
'products___c2',
|
|
240
|
+
'products___c3',
|
|
241
|
+
'products___c4',
|
|
242
|
+
'products___c5',
|
|
243
|
+
]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should hide header when title={null}', () => {
|
|
247
|
+
const { queryByTestId } = render(
|
|
248
|
+
<Table title={null}>
|
|
249
|
+
<Table.Column source="name" label="Product Name" />
|
|
250
|
+
</Table>,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
expect(queryByTestId('table-header')).toBeNull();
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import CloudscapeTable, { TableProps as CloudscapeTableProps } from '@cloudscape-design/components/table';
|
|
3
|
+
import Pagination from '@cloudscape-design/components/pagination';
|
|
4
|
+
import Box from '@cloudscape-design/components/box';
|
|
5
|
+
import TextFilter from '@cloudscape-design/components/text-filter';
|
|
6
|
+
import CollectionPreferences from '@cloudscape-design/components/collection-preferences';
|
|
7
|
+
import { RecordContextProvider, RaRecord, useResourceContext, useFieldSchema, useResourceDefinition, useGetResourceLabel, useTranslateLabel, useTranslate } from '@strato-admin/core';
|
|
8
|
+
import { useCollection } from '../collection-hooks';
|
|
9
|
+
import TextField from '../field/TextField';
|
|
10
|
+
import NumberField from '../field/NumberField';
|
|
11
|
+
import DateField from '../field/DateField';
|
|
12
|
+
import BooleanField from '../field/BooleanField';
|
|
13
|
+
import ReferenceField from '../field/ReferenceField';
|
|
14
|
+
import { type RecordLinkType } from '../RecordLink';
|
|
15
|
+
import { TableHeader } from './TableHeader';
|
|
16
|
+
|
|
17
|
+
export type CloudscapeColumnDefinitionProps = Partial<
|
|
18
|
+
Omit<CloudscapeTableProps.ColumnDefinition<any>, 'id' | 'header' | 'cell' | 'sortingField'>
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
export interface ColumnProps extends CloudscapeColumnDefinitionProps {
|
|
22
|
+
source?: string;
|
|
23
|
+
label?: string | React.ReactNode;
|
|
24
|
+
header?: React.ReactNode;
|
|
25
|
+
children?: React.ReactNode;
|
|
26
|
+
sortable?: boolean;
|
|
27
|
+
link?: RecordLinkType;
|
|
28
|
+
field?: React.ComponentType<any>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const Column = ({ children, source, link, field: FieldComponent }: ColumnProps) => {
|
|
32
|
+
if (children) {
|
|
33
|
+
return (
|
|
34
|
+
<>
|
|
35
|
+
{React.Children.map(children, (child) =>
|
|
36
|
+
React.isValidElement(child) ? React.cloneElement(child, { source } as any) : child
|
|
37
|
+
)}
|
|
38
|
+
</>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (FieldComponent) {
|
|
42
|
+
return <FieldComponent link={link} source={source} />;
|
|
43
|
+
}
|
|
44
|
+
return <TextField link={link} source={source} />;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export interface NumberColumnProps extends ColumnProps {
|
|
48
|
+
source?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const NumberColumn = ({ children, source, link, field: FieldComponent }: NumberColumnProps) => {
|
|
52
|
+
if (children) {
|
|
53
|
+
return (
|
|
54
|
+
<>
|
|
55
|
+
{React.Children.map(children, (child) =>
|
|
56
|
+
React.isValidElement(child) ? React.cloneElement(child, { source } as any) : child
|
|
57
|
+
)}
|
|
58
|
+
</>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (FieldComponent) {
|
|
62
|
+
return <FieldComponent link={link} source={source} />;
|
|
63
|
+
}
|
|
64
|
+
return <NumberField link={link} source={source} />;
|
|
65
|
+
};
|
|
66
|
+
(NumberColumn as any).isNumberColumn = true;
|
|
67
|
+
|
|
68
|
+
export interface DateColumnProps extends ColumnProps {
|
|
69
|
+
source?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const DateColumn = ({ children, source, link, field: FieldComponent }: DateColumnProps) => {
|
|
73
|
+
if (children) {
|
|
74
|
+
return (
|
|
75
|
+
<>
|
|
76
|
+
{React.Children.map(children, (child) =>
|
|
77
|
+
React.isValidElement(child) ? React.cloneElement(child, { source } as any) : child
|
|
78
|
+
)}
|
|
79
|
+
</>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (FieldComponent) {
|
|
83
|
+
return <FieldComponent link={link} source={source} />;
|
|
84
|
+
}
|
|
85
|
+
return <DateField link={link} source={source} />;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export interface BooleanColumnProps extends ColumnProps {
|
|
89
|
+
source?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const BooleanColumn = ({ children, source, field: FieldComponent }: BooleanColumnProps) => {
|
|
93
|
+
if (children) {
|
|
94
|
+
return (
|
|
95
|
+
<>
|
|
96
|
+
{React.Children.map(children, (child) =>
|
|
97
|
+
React.isValidElement(child) ? React.cloneElement(child, { source } as any) : child
|
|
98
|
+
)}
|
|
99
|
+
</>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (FieldComponent) {
|
|
103
|
+
return <FieldComponent source={source} />;
|
|
104
|
+
}
|
|
105
|
+
return <BooleanField source={source} />;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export interface ReferenceColumnProps extends ColumnProps {
|
|
109
|
+
source?: string;
|
|
110
|
+
reference: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const ReferenceColumn = ({ children, source, reference, link, field: FieldComponent }: ReferenceColumnProps) => {
|
|
114
|
+
// ReferenceCol requires reference, so we pass it down
|
|
115
|
+
if (FieldComponent) {
|
|
116
|
+
return (
|
|
117
|
+
<FieldComponent reference={reference} link={link} source={source}>
|
|
118
|
+
{children}
|
|
119
|
+
</FieldComponent>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return (
|
|
123
|
+
<ReferenceField reference={reference} link={link} source={source}>
|
|
124
|
+
{children}
|
|
125
|
+
</ReferenceField>
|
|
126
|
+
);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Properties for the Table component.
|
|
131
|
+
*/
|
|
132
|
+
export interface TableProps<RecordType extends RaRecord = any> extends Partial<
|
|
133
|
+
Omit<CloudscapeTableProps<RecordType>, 'items' | 'columnDefinitions' | 'preferences'>
|
|
134
|
+
> {
|
|
135
|
+
/**
|
|
136
|
+
* The title content of the table. Can be a string or a React node.
|
|
137
|
+
*/
|
|
138
|
+
title?: React.ReactNode;
|
|
139
|
+
/**
|
|
140
|
+
* Actions to display in the table header, typically a button group.
|
|
141
|
+
*/
|
|
142
|
+
actions?: React.ReactNode;
|
|
143
|
+
/**
|
|
144
|
+
* The columns to display, usually using `Table.Column` and its variants.
|
|
145
|
+
*/
|
|
146
|
+
children?: React.ReactNode;
|
|
147
|
+
/**
|
|
148
|
+
* Include only these fields from the schema.
|
|
149
|
+
*/
|
|
150
|
+
include?: string[];
|
|
151
|
+
/**
|
|
152
|
+
* Exclude these fields from the schema.
|
|
153
|
+
*/
|
|
154
|
+
exclude?: string[];
|
|
155
|
+
/**
|
|
156
|
+
* Whether to enable text filtering.
|
|
157
|
+
* @default true
|
|
158
|
+
*/
|
|
159
|
+
filtering?: boolean;
|
|
160
|
+
/**
|
|
161
|
+
* Placeholder text for the filter input.
|
|
162
|
+
* @default "Search..."
|
|
163
|
+
*/
|
|
164
|
+
filteringPlaceholder?: string;
|
|
165
|
+
/**
|
|
166
|
+
* Options for the page size selector.
|
|
167
|
+
*/
|
|
168
|
+
pageSizeOptions?: ReadonlyArray<{ value: number; label?: string }>;
|
|
169
|
+
/**
|
|
170
|
+
* Whether to show the preferences button or custom preferences content.
|
|
171
|
+
* @default true
|
|
172
|
+
*/
|
|
173
|
+
preferences?: boolean | React.ReactNode;
|
|
174
|
+
/**
|
|
175
|
+
* Whether columns can be reordered by the user.
|
|
176
|
+
* @default true
|
|
177
|
+
*/
|
|
178
|
+
reorderable?: boolean;
|
|
179
|
+
/**
|
|
180
|
+
* The fields to display by default.
|
|
181
|
+
* Can be an array of field sources/IDs.
|
|
182
|
+
* If not specified, the first 5 fields will be shown.
|
|
183
|
+
*/
|
|
184
|
+
defaultVisibleFields?: string[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const defaultPageSizeOptions = [
|
|
188
|
+
{ value: 10, label: '10 items' },
|
|
189
|
+
{ value: 25, label: '25 items' },
|
|
190
|
+
{ value: 50, label: '50 items' },
|
|
191
|
+
{ value: 100, label: '100 items' },
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The Table component provides a declarative way to build data tables with Cloudscape components
|
|
196
|
+
* while integrating with React Admin's data fetching and state management.
|
|
197
|
+
*
|
|
198
|
+
* @example
|
|
199
|
+
* ```tsx
|
|
200
|
+
* <Table title="Products">
|
|
201
|
+
* <Table.Column source="name" label="Name" />
|
|
202
|
+
* <Table.NumberColumn source="price" label="Price" />
|
|
203
|
+
* </Table>
|
|
204
|
+
* ```
|
|
205
|
+
*/
|
|
206
|
+
export const Table = <RecordType extends RaRecord = any>({
|
|
207
|
+
title,
|
|
208
|
+
actions,
|
|
209
|
+
children,
|
|
210
|
+
include,
|
|
211
|
+
exclude,
|
|
212
|
+
filtering = true,
|
|
213
|
+
filteringPlaceholder,
|
|
214
|
+
pageSizeOptions = defaultPageSizeOptions,
|
|
215
|
+
preferences = true,
|
|
216
|
+
reorderable = true,
|
|
217
|
+
defaultVisibleFields,
|
|
218
|
+
selectionType,
|
|
219
|
+
...props
|
|
220
|
+
}: TableProps<RecordType>) => {
|
|
221
|
+
const resource = useResourceContext();
|
|
222
|
+
const translate = useTranslate();
|
|
223
|
+
const translateLabel = useTranslateLabel();
|
|
224
|
+
const schemaChildren = useFieldSchema();
|
|
225
|
+
const resourceDefinition = useResourceDefinition({ resource });
|
|
226
|
+
|
|
227
|
+
const finalSelectionType = selectionType ?? (resourceDefinition?.options?.canDelete ? 'multi' : undefined);
|
|
228
|
+
|
|
229
|
+
const finalChildren = React.useMemo(() => {
|
|
230
|
+
const baseChildren = children || schemaChildren;
|
|
231
|
+
let result = React.Children.toArray(baseChildren);
|
|
232
|
+
|
|
233
|
+
if (include) {
|
|
234
|
+
result = result.filter(
|
|
235
|
+
(child) => React.isValidElement(child) && include.includes((child.props as any).source)
|
|
236
|
+
);
|
|
237
|
+
} else if (exclude) {
|
|
238
|
+
result = result.filter(
|
|
239
|
+
(child) => React.isValidElement(child) && !exclude.includes((child.props as any).source)
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return result;
|
|
244
|
+
}, [children, schemaChildren, include, exclude]);
|
|
245
|
+
|
|
246
|
+
// 1. Extract columns and options before calling useCollection
|
|
247
|
+
const extractedColumns = React.useMemo(() => {
|
|
248
|
+
const columns: any[] = [];
|
|
249
|
+
const options: { id: string; label: string; alwaysVisible?: boolean }[] = [];
|
|
250
|
+
|
|
251
|
+
finalChildren.forEach((child, index) => {
|
|
252
|
+
if (!React.isValidElement(child)) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const {
|
|
257
|
+
source,
|
|
258
|
+
label,
|
|
259
|
+
header: childHeader,
|
|
260
|
+
sortable,
|
|
261
|
+
link,
|
|
262
|
+
field,
|
|
263
|
+
children: childChildren,
|
|
264
|
+
...restColumnProps
|
|
265
|
+
} = child.props as any;
|
|
266
|
+
|
|
267
|
+
const isNumberColumn = (child.type as any)?.isNumberColumn;
|
|
268
|
+
|
|
269
|
+
const headerLabel = translateLabel({ label, resource, source });
|
|
270
|
+
const finalHeader = isNumberColumn ? <Box textAlign="right">{headerLabel}</Box> : headerLabel;
|
|
271
|
+
|
|
272
|
+
const columnId = source || `col-${index}`;
|
|
273
|
+
const id = resource ? `${resource}___${columnId}` : columnId;
|
|
274
|
+
|
|
275
|
+
columns.push({
|
|
276
|
+
...restColumnProps,
|
|
277
|
+
id,
|
|
278
|
+
header: finalHeader,
|
|
279
|
+
cell: (item: RecordType) => {
|
|
280
|
+
const content = <RecordContextProvider value={item}>{child as any}</RecordContextProvider>;
|
|
281
|
+
return isNumberColumn ? <Box textAlign="right">{content}</Box> : content;
|
|
282
|
+
},
|
|
283
|
+
sortingField: sortable !== false ? source : undefined,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// If we have a meaningful label/header string, allow toggling
|
|
287
|
+
if (typeof headerLabel === 'string') {
|
|
288
|
+
options.push({
|
|
289
|
+
id,
|
|
290
|
+
label: headerLabel,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
return { columns, options };
|
|
296
|
+
}, [finalChildren, resource, translateLabel]);
|
|
297
|
+
|
|
298
|
+
const defaultVisibleContent = React.useMemo(() => {
|
|
299
|
+
if (extractedColumns.options.length === 0) return undefined;
|
|
300
|
+
|
|
301
|
+
if (defaultVisibleFields) {
|
|
302
|
+
// Map user-provided fields to their actual IDs
|
|
303
|
+
return extractedColumns.options
|
|
304
|
+
.filter((opt) => {
|
|
305
|
+
const column = extractedColumns.columns.find((c) => c.id === opt.id);
|
|
306
|
+
return (
|
|
307
|
+
defaultVisibleFields.includes(opt.id) ||
|
|
308
|
+
(column?.sortingField && defaultVisibleFields.includes(column.sortingField))
|
|
309
|
+
);
|
|
310
|
+
})
|
|
311
|
+
.map((opt) => opt.id);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Default to first 5 toggleable columns
|
|
315
|
+
return extractedColumns.options.slice(0, 5).map((opt) => opt.id);
|
|
316
|
+
}, [extractedColumns, defaultVisibleFields]);
|
|
317
|
+
|
|
318
|
+
const defaultContentDisplay = React.useMemo(() => {
|
|
319
|
+
if (extractedColumns.options.length === 0) return undefined;
|
|
320
|
+
|
|
321
|
+
const visibleIds = defaultVisibleContent || [];
|
|
322
|
+
|
|
323
|
+
return extractedColumns.options.map((opt) => ({
|
|
324
|
+
id: opt.id,
|
|
325
|
+
visible: visibleIds.includes(opt.id),
|
|
326
|
+
}));
|
|
327
|
+
}, [extractedColumns.options, defaultVisibleContent]);
|
|
328
|
+
|
|
329
|
+
const { items, paginationProps, collectionProps, filterProps, preferencesProps } = useCollection<RecordType>({
|
|
330
|
+
filtering: {},
|
|
331
|
+
pagination: {},
|
|
332
|
+
sorting: {},
|
|
333
|
+
preferences: {
|
|
334
|
+
pageSizeOptions,
|
|
335
|
+
visibleContentOptions:
|
|
336
|
+
!reorderable && extractedColumns.options.length > 0 ? extractedColumns.options : undefined,
|
|
337
|
+
contentDisplayOptions:
|
|
338
|
+
reorderable && extractedColumns.options.length > 0 ? extractedColumns.options : undefined,
|
|
339
|
+
visibleContent: defaultVisibleContent,
|
|
340
|
+
contentDisplay: defaultContentDisplay,
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// 2. Filter columnDefinitions if reordering is disabled (Cloudscape Table handles it if columnDisplay is passed)
|
|
345
|
+
const columnDefinitions = React.useMemo(() => {
|
|
346
|
+
if (reorderable || !preferencesProps.preferences.visibleContent) {
|
|
347
|
+
return extractedColumns.columns;
|
|
348
|
+
}
|
|
349
|
+
return extractedColumns.columns.filter((col) => {
|
|
350
|
+
// Always show columns that are not in options (non-toggleable columns like Actions)
|
|
351
|
+
const isToggleable = extractedColumns.options.some((opt) => opt.id === col.id);
|
|
352
|
+
if (!isToggleable) return true;
|
|
353
|
+
|
|
354
|
+
return preferencesProps.preferences.visibleContent?.includes(col.id);
|
|
355
|
+
});
|
|
356
|
+
}, [extractedColumns.columns, extractedColumns.options, preferencesProps.preferences.visibleContent, reorderable]);
|
|
357
|
+
|
|
358
|
+
const getResourceLabel = useGetResourceLabel();
|
|
359
|
+
|
|
360
|
+
const tableHeader = React.useMemo(() => {
|
|
361
|
+
if (title === null || title === false) {
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
if (React.isValidElement(title)) {
|
|
365
|
+
return title;
|
|
366
|
+
}
|
|
367
|
+
const finalTitle = title !== undefined ? title : getResourceLabel(resource as string, 2);
|
|
368
|
+
return <TableHeader title={finalTitle} actions={actions} />;
|
|
369
|
+
}, [title, actions, resource, getResourceLabel]);
|
|
370
|
+
|
|
371
|
+
return (
|
|
372
|
+
<CloudscapeTable
|
|
373
|
+
{...collectionProps}
|
|
374
|
+
{...props}
|
|
375
|
+
selectionType={finalSelectionType}
|
|
376
|
+
stripedRows={preferencesProps.preferences.stripedRows}
|
|
377
|
+
wrapLines={preferencesProps.preferences.wrapLines}
|
|
378
|
+
columnDefinitions={columnDefinitions}
|
|
379
|
+
columnDisplay={reorderable ? preferencesProps.preferences.contentDisplay : undefined}
|
|
380
|
+
items={items || []}
|
|
381
|
+
header={tableHeader}
|
|
382
|
+
filter={
|
|
383
|
+
filtering && (
|
|
384
|
+
<TextFilter
|
|
385
|
+
{...filterProps}
|
|
386
|
+
/>
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
pagination={<Pagination {...paginationProps} />}
|
|
390
|
+
preferences={
|
|
391
|
+
preferences === true || pageSizeOptions ? (
|
|
392
|
+
<CollectionPreferences
|
|
393
|
+
{...preferencesProps}
|
|
394
|
+
pageSizePreference={
|
|
395
|
+
pageSizeOptions
|
|
396
|
+
? {
|
|
397
|
+
options: pageSizeOptions,
|
|
398
|
+
}
|
|
399
|
+
: undefined
|
|
400
|
+
}
|
|
401
|
+
visibleContentPreference={
|
|
402
|
+
!reorderable && extractedColumns.options.length > 0
|
|
403
|
+
? {
|
|
404
|
+
title: translate('ra.action.select_columns', { _: 'Select visible columns' }),
|
|
405
|
+
options: [
|
|
406
|
+
{
|
|
407
|
+
label: translate('ra.action.select_columns', { _: 'Select visible columns' }),
|
|
408
|
+
options: extractedColumns.options,
|
|
409
|
+
},
|
|
410
|
+
],
|
|
411
|
+
}
|
|
412
|
+
: undefined
|
|
413
|
+
}
|
|
414
|
+
contentDisplayPreference={
|
|
415
|
+
reorderable && extractedColumns.options.length > 0
|
|
416
|
+
? {
|
|
417
|
+
title: translate('ra.action.select_columns', { _: 'Select visible columns' }),
|
|
418
|
+
options: extractedColumns.options,
|
|
419
|
+
}
|
|
420
|
+
: undefined
|
|
421
|
+
}
|
|
422
|
+
/>
|
|
423
|
+
) : React.isValidElement(preferences) ? (
|
|
424
|
+
preferences
|
|
425
|
+
) : undefined
|
|
426
|
+
}
|
|
427
|
+
/>
|
|
428
|
+
);
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
Table.Column = Column;
|
|
432
|
+
Table.NumberColumn = NumberColumn;
|
|
433
|
+
Table.DateColumn = DateColumn;
|
|
434
|
+
Table.BooleanColumn = BooleanColumn;
|
|
435
|
+
Table.ReferenceColumn = ReferenceColumn;
|
|
436
|
+
Table.Header = TableHeader;
|
|
437
|
+
|
|
438
|
+
export default Table;
|