@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.
Files changed (255) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3 -0
  3. package/dist/Admin.d.ts +17 -0
  4. package/dist/Admin.js +69 -0
  5. package/dist/RecordLink.d.ts +9 -0
  6. package/dist/RecordLink.js +43 -0
  7. package/dist/__mocks__/strato-core.js +50 -0
  8. package/dist/__mocks__to__delete/strato-core.js +50 -0
  9. package/dist/button/BulkDeleteButton.d.ts +7 -0
  10. package/dist/button/BulkDeleteButton.js +17 -0
  11. package/dist/button/Button.d.ts +6 -0
  12. package/dist/button/Button.js +6 -0
  13. package/dist/button/CreateButton.d.ts +6 -0
  14. package/dist/button/CreateButton.js +24 -0
  15. package/dist/button/EditButton.d.ts +8 -0
  16. package/dist/button/EditButton.js +24 -0
  17. package/dist/button/SaveButton.d.ts +6 -0
  18. package/dist/button/SaveButton.js +8 -0
  19. package/dist/button/index.d.ts +5 -0
  20. package/dist/button/index.js +5 -0
  21. package/dist/collection-hooks/index.d.ts +2 -0
  22. package/dist/collection-hooks/index.js +2 -0
  23. package/dist/collection-hooks/interfaces.d.ts +93 -0
  24. package/dist/collection-hooks/interfaces.js +1 -0
  25. package/dist/collection-hooks/useCollection.d.ts +3 -0
  26. package/dist/collection-hooks/useCollection.js +102 -0
  27. package/dist/create/Create.d.ts +40 -0
  28. package/dist/create/Create.js +34 -0
  29. package/dist/create/CreateHeader.d.ts +7 -0
  30. package/dist/create/CreateHeader.js +18 -0
  31. package/dist/create/index.d.ts +2 -0
  32. package/dist/create/index.js +2 -0
  33. package/dist/detail/KeyValuePairs.d.ts +36 -0
  34. package/dist/detail/KeyValuePairs.js +58 -0
  35. package/dist/detail/Show.d.ts +39 -0
  36. package/dist/detail/Show.js +40 -0
  37. package/dist/detail/ShowHeader.d.ts +7 -0
  38. package/dist/detail/ShowHeader.js +19 -0
  39. package/dist/detail/index.d.ts +3 -0
  40. package/dist/detail/index.js +3 -0
  41. package/dist/edit/Edit.d.ts +42 -0
  42. package/dist/edit/Edit.js +38 -0
  43. package/dist/edit/EditHeader.d.ts +7 -0
  44. package/dist/edit/EditHeader.js +18 -0
  45. package/dist/edit/index.d.ts +2 -0
  46. package/dist/edit/index.js +2 -0
  47. package/dist/field/ArrayField.d.ts +29 -0
  48. package/dist/field/ArrayField.js +30 -0
  49. package/dist/field/BadgeField.d.ts +12 -0
  50. package/dist/field/BadgeField.js +15 -0
  51. package/dist/field/BooleanField.d.ts +18 -0
  52. package/dist/field/BooleanField.js +14 -0
  53. package/dist/field/CurrencyField.d.ts +19 -0
  54. package/dist/field/CurrencyField.js +23 -0
  55. package/dist/field/DateField.d.ts +14 -0
  56. package/dist/field/DateField.js +17 -0
  57. package/dist/field/IdField.d.ts +17 -0
  58. package/dist/field/IdField.js +21 -0
  59. package/dist/field/NumberField.d.ts +14 -0
  60. package/dist/field/NumberField.js +18 -0
  61. package/dist/field/ReferenceField.d.ts +16 -0
  62. package/dist/field/ReferenceField.js +23 -0
  63. package/dist/field/ReferenceManyField.d.ts +55 -0
  64. package/dist/field/ReferenceManyField.js +19 -0
  65. package/dist/field/StatusIndicatorField.d.ts +56 -0
  66. package/dist/field/StatusIndicatorField.js +48 -0
  67. package/dist/field/TextField.d.ts +5 -0
  68. package/dist/field/TextField.js +11 -0
  69. package/dist/field/index.d.ts +23 -0
  70. package/dist/field/index.js +23 -0
  71. package/dist/field/types.d.ts +56 -0
  72. package/dist/field/types.js +1 -0
  73. package/dist/form/Form.d.ts +13 -0
  74. package/dist/form/Form.js +33 -0
  75. package/dist/form/index.d.ts +2 -0
  76. package/dist/form/index.js +2 -0
  77. package/dist/index.d.ts +22 -0
  78. package/dist/index.js +22 -0
  79. package/dist/input/AttributeEditor.d.ts +25 -0
  80. package/dist/input/AttributeEditor.js +80 -0
  81. package/dist/input/AutocompleteInput.d.ts +10 -0
  82. package/dist/input/AutocompleteInput.js +67 -0
  83. package/dist/input/FieldTitle.d.ts +8 -0
  84. package/dist/input/FieldTitle.js +29 -0
  85. package/dist/input/FormField.d.ts +7 -0
  86. package/dist/input/FormField.js +35 -0
  87. package/dist/input/FormFieldContext.d.ts +6 -0
  88. package/dist/input/FormFieldContext.js +3 -0
  89. package/dist/input/NumberInput.d.ts +7 -0
  90. package/dist/input/NumberInput.js +27 -0
  91. package/dist/input/ReferenceInput.d.ts +3 -0
  92. package/dist/input/ReferenceInput.js +25 -0
  93. package/dist/input/SelectInput.d.ts +15 -0
  94. package/dist/input/SelectInput.js +47 -0
  95. package/dist/input/SliderInput.d.ts +6 -0
  96. package/dist/input/SliderInput.js +25 -0
  97. package/dist/input/TextAreaInput.d.ts +6 -0
  98. package/dist/input/TextAreaInput.js +23 -0
  99. package/dist/input/TextInput.d.ts +7 -0
  100. package/dist/input/TextInput.js +23 -0
  101. package/dist/input/index.d.ts +11 -0
  102. package/dist/input/index.js +11 -0
  103. package/dist/input/types.d.ts +6 -0
  104. package/dist/input/types.js +1 -0
  105. package/dist/layout/AppLayout.d.ts +8 -0
  106. package/dist/layout/AppLayout.js +38 -0
  107. package/dist/layout/TopNavigation.d.ts +6 -0
  108. package/dist/layout/TopNavigation.js +53 -0
  109. package/dist/layout/index.d.ts +2 -0
  110. package/dist/layout/index.js +2 -0
  111. package/dist/list/Cards.d.ts +11 -0
  112. package/dist/list/Cards.js +27 -0
  113. package/dist/list/List.d.ts +43 -0
  114. package/dist/list/List.js +28 -0
  115. package/dist/list/Table.d.ts +112 -0
  116. package/dist/list/Table.examples.d.ts +1 -0
  117. package/dist/list/Table.examples.js +3 -0
  118. package/dist/list/Table.js +218 -0
  119. package/dist/list/TableHeader.d.ts +7 -0
  120. package/dist/list/TableHeader.js +22 -0
  121. package/dist/list/index.d.ts +4 -0
  122. package/dist/list/index.js +4 -0
  123. package/dist/preferences/index.d.ts +0 -0
  124. package/dist/preferences/index.js +1 -0
  125. package/dist/theme/ThemeManager.d.ts +2 -0
  126. package/dist/theme/ThemeManager.js +11 -0
  127. package/dist/theme/index.d.ts +2 -0
  128. package/dist/theme/index.js +2 -0
  129. package/package.json +73 -0
  130. package/src/Admin.test.tsx +32 -0
  131. package/src/Admin.tsx +123 -0
  132. package/src/RecordLink.stories.tsx +56 -0
  133. package/src/RecordLink.tsx +67 -0
  134. package/src/__mocks__/strato-core.tsx +52 -0
  135. package/src/button/BulkDeleteButton.stories.tsx +59 -0
  136. package/src/button/BulkDeleteButton.test.tsx +64 -0
  137. package/src/button/BulkDeleteButton.tsx +41 -0
  138. package/src/button/Button.stories.tsx +31 -0
  139. package/src/button/Button.tsx +12 -0
  140. package/src/button/CreateButton.stories.tsx +42 -0
  141. package/src/button/CreateButton.tsx +38 -0
  142. package/src/button/EditButton.stories.tsx +29 -0
  143. package/src/button/EditButton.tsx +38 -0
  144. package/src/button/SaveButton.stories.tsx +35 -0
  145. package/src/button/SaveButton.tsx +19 -0
  146. package/src/button/index.ts +5 -0
  147. package/src/collection-hooks/index.ts +2 -0
  148. package/src/collection-hooks/interfaces.ts +80 -0
  149. package/src/collection-hooks/useCollection.test.ts +413 -0
  150. package/src/collection-hooks/useCollection.ts +125 -0
  151. package/src/create/Create.test.tsx +63 -0
  152. package/src/create/Create.tsx +93 -0
  153. package/src/create/CreateHeader.tsx +34 -0
  154. package/src/create/index.ts +2 -0
  155. package/src/detail/KeyValuePairs.test.tsx +98 -0
  156. package/src/detail/KeyValuePairs.tsx +107 -0
  157. package/src/detail/Show.test.tsx +96 -0
  158. package/src/detail/Show.tsx +104 -0
  159. package/src/detail/ShowHeader.test.tsx +80 -0
  160. package/src/detail/ShowHeader.tsx +35 -0
  161. package/src/detail/index.ts +3 -0
  162. package/src/edit/Edit.test.tsx +91 -0
  163. package/src/edit/Edit.tsx +102 -0
  164. package/src/edit/EditHeader.tsx +34 -0
  165. package/src/edit/index.ts +2 -0
  166. package/src/field/ArrayField.tsx +51 -0
  167. package/src/field/BadgeField.tsx +33 -0
  168. package/src/field/BooleanField.stories.tsx +56 -0
  169. package/src/field/BooleanField.test.tsx +63 -0
  170. package/src/field/BooleanField.tsx +42 -0
  171. package/src/field/CurrencyField.stories.tsx +67 -0
  172. package/src/field/CurrencyField.tsx +45 -0
  173. package/src/field/DateField.stories.tsx +67 -0
  174. package/src/field/DateField.tsx +33 -0
  175. package/src/field/IdField.test.tsx +88 -0
  176. package/src/field/IdField.tsx +40 -0
  177. package/src/field/NumberField.stories.tsx +75 -0
  178. package/src/field/NumberField.tsx +35 -0
  179. package/src/field/ReferenceField.test.tsx +88 -0
  180. package/src/field/ReferenceField.tsx +64 -0
  181. package/src/field/ReferenceManyField.test.tsx +41 -0
  182. package/src/field/ReferenceManyField.tsx +73 -0
  183. package/src/field/StatusIndicatorField.stories.tsx +93 -0
  184. package/src/field/StatusIndicatorField.test.tsx +143 -0
  185. package/src/field/StatusIndicatorField.tsx +119 -0
  186. package/src/field/TextField.stories.tsx +45 -0
  187. package/src/field/TextField.tsx +17 -0
  188. package/src/field/index.ts +23 -0
  189. package/src/field/types.ts +58 -0
  190. package/src/form/Form.test.tsx +55 -0
  191. package/src/form/Form.tsx +66 -0
  192. package/src/form/index.ts +2 -0
  193. package/src/index.ts +25 -0
  194. package/src/input/AttributeEditor.test.tsx +147 -0
  195. package/src/input/AttributeEditor.tsx +185 -0
  196. package/src/input/AutocompleteInput.test.tsx +178 -0
  197. package/src/input/AutocompleteInput.tsx +116 -0
  198. package/src/input/FieldTitle.tsx +53 -0
  199. package/src/input/FormField.tsx +87 -0
  200. package/src/input/FormFieldContext.ts +9 -0
  201. package/src/input/NumberInput.tsx +56 -0
  202. package/src/input/ReferenceInput.test.tsx +35 -0
  203. package/src/input/ReferenceInput.tsx +36 -0
  204. package/src/input/SelectInput.tsx +91 -0
  205. package/src/input/SliderInput.test.tsx +103 -0
  206. package/src/input/SliderInput.tsx +49 -0
  207. package/src/input/TextAreaInput.tsx +48 -0
  208. package/src/input/TextInput.test.tsx +91 -0
  209. package/src/input/TextInput.tsx +51 -0
  210. package/src/input/index.ts +11 -0
  211. package/src/input/types.ts +14 -0
  212. package/src/layout/AppLayout.test.tsx +87 -0
  213. package/src/layout/AppLayout.tsx +60 -0
  214. package/src/layout/TopNavigation.test.tsx +78 -0
  215. package/src/layout/TopNavigation.tsx +84 -0
  216. package/src/layout/index.ts +2 -0
  217. package/src/list/Cards.tsx +58 -0
  218. package/src/list/List.tsx +76 -0
  219. package/src/list/Table.examples.tsx +11 -0
  220. package/src/list/Table.stories.tsx +73 -0
  221. package/src/list/Table.test.tsx +255 -0
  222. package/src/list/Table.tsx +438 -0
  223. package/src/list/TableHeader.test.tsx +114 -0
  224. package/src/list/TableHeader.tsx +44 -0
  225. package/src/list/index.ts +4 -0
  226. package/src/preferences/index.ts +0 -0
  227. package/src/stories/Button.stories.ts +54 -0
  228. package/src/stories/Button.tsx +31 -0
  229. package/src/stories/Configure.mdx +369 -0
  230. package/src/stories/Header.stories.ts +34 -0
  231. package/src/stories/Header.tsx +47 -0
  232. package/src/stories/Page.stories.ts +33 -0
  233. package/src/stories/Page.tsx +71 -0
  234. package/src/stories/RaStoryDecorator.tsx +38 -0
  235. package/src/stories/assets/accessibility.png +0 -0
  236. package/src/stories/assets/accessibility.svg +1 -0
  237. package/src/stories/assets/addon-library.png +0 -0
  238. package/src/stories/assets/assets.png +0 -0
  239. package/src/stories/assets/avif-test-image.avif +0 -0
  240. package/src/stories/assets/context.png +0 -0
  241. package/src/stories/assets/discord.svg +1 -0
  242. package/src/stories/assets/docs.png +0 -0
  243. package/src/stories/assets/figma-plugin.png +0 -0
  244. package/src/stories/assets/github.svg +1 -0
  245. package/src/stories/assets/share.png +0 -0
  246. package/src/stories/assets/styling.png +0 -0
  247. package/src/stories/assets/testing.png +0 -0
  248. package/src/stories/assets/theming.png +0 -0
  249. package/src/stories/assets/tutorials.svg +1 -0
  250. package/src/stories/assets/youtube.svg +1 -0
  251. package/src/stories/button.css +30 -0
  252. package/src/stories/header.css +32 -0
  253. package/src/stories/page.css +68 -0
  254. package/src/theme/ThemeManager.tsx +15 -0
  255. 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;