@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,147 @@
1
+
2
+ import { render } from '@testing-library/react';
3
+ import { vi, describe, it, expect, beforeEach } from 'vitest';
4
+ import { AttributeEditor } from './AttributeEditor';
5
+ import { useFormContext, useFieldArray } from 'react-hook-form';
6
+ import { useInput, useResourceContext } from '@strato-admin/core';
7
+ import { useFormFieldContext } from './FormFieldContext';
8
+
9
+ // Mock Cloudscape components
10
+ vi.mock('@cloudscape-design/components/attribute-editor', () => ({
11
+ default: ({ items, definition, onAddButtonClick, disableAddButton, hideAddButton }: any) => (
12
+ <div data-testid="attribute-editor">
13
+ {!hideAddButton && (
14
+ <button onClick={onAddButtonClick} disabled={disableAddButton} data-testid="add-button">
15
+ Add item
16
+ </button>
17
+ )}
18
+ {items.map((item: any, index: number) => (
19
+ <div key={index} data-testid={`item-${index}`}>
20
+ {definition.map((def: any, defIndex: number) => (
21
+ <div key={defIndex} data-testid={`field-${defIndex}`}>
22
+ {def.control(item, index)}
23
+ </div>
24
+ ))}
25
+ </div>
26
+ ))}
27
+ </div>
28
+ ),
29
+ }));
30
+
31
+ vi.mock('@cloudscape-design/components/box', () => ({
32
+ default: ({ children }: any) => <div>{children}</div>,
33
+ }));
34
+
35
+ // Mock ra-core hooks
36
+ vi.mock('@strato-admin/core', () => ({
37
+ useInput: vi.fn(),
38
+ useResourceContext: vi.fn(),
39
+ RecordContextProvider: ({ children }: any) => <>{children}</>,
40
+ ArrayInputContext: {
41
+ Provider: ({ children }: any) => <>{children}</>,
42
+ },
43
+ }));
44
+
45
+ // Mock react-hook-form hooks
46
+ vi.mock('react-hook-form', () => ({
47
+ useFormContext: vi.fn(),
48
+ useFieldArray: vi.fn(),
49
+ }));
50
+
51
+ // Mock local components and contexts
52
+ vi.mock('./FormFieldContext', () => ({
53
+ useFormFieldContext: vi.fn(),
54
+ FormFieldContext: {
55
+ Provider: ({ children }: any) => <>{children}</>,
56
+ },
57
+ }));
58
+
59
+ vi.mock('./FieldTitle', () => ({
60
+ FieldTitle: () => <div data-testid="field-title" />,
61
+ }));
62
+
63
+ vi.mock('./TextInput', () => ({
64
+ default: ({ source }: any) => <div data-testid="text-input" data-source={source} />,
65
+ }));
66
+
67
+ vi.mock('./FormField', () => ({
68
+ default: ({ children }: any) => <div data-testid="form-field">{children}</div>,
69
+ }));
70
+
71
+ describe('AttributeEditor', () => {
72
+ const defaultFormContext = { control: {} };
73
+ const defaultFieldArray = {
74
+ fields: [{}],
75
+ append: vi.fn(),
76
+ remove: vi.fn(),
77
+ };
78
+ const defaultInput = {
79
+ id: 'test',
80
+ field: { name: 'test', value: [], onChange: vi.fn(), onBlur: vi.fn() },
81
+ fieldState: { error: undefined, isTouched: false, isDirty: false },
82
+ formState: { isSubmitted: false },
83
+ isRequired: false,
84
+ };
85
+
86
+ beforeEach(() => {
87
+ vi.mocked(useFormContext).mockReturnValue(defaultFormContext as any);
88
+ vi.mocked(useFieldArray).mockReturnValue(defaultFieldArray as any);
89
+ vi.mocked(useInput).mockReturnValue(defaultInput as any);
90
+ vi.mocked(useFormFieldContext).mockReturnValue(undefined);
91
+ vi.mocked(useResourceContext).mockReturnValue('test-resource');
92
+ });
93
+
94
+ it('should prefix source prop of immediate children', () => {
95
+ const Child = ({ source }: any) => <div data-testid="child">{source}</div>;
96
+
97
+ const { getByTestId } = render(
98
+ <AttributeEditor source="products">
99
+ <Child source="id" />
100
+ </AttributeEditor>,
101
+ );
102
+
103
+ expect(getByTestId('child').textContent).toBe('products.0.id');
104
+ });
105
+
106
+ it('should NOT add source prop to children that do not have one', () => {
107
+ const NonInput = ({ children }: any) => <div data-testid="non-input">{children}</div>;
108
+
109
+ const { getByTestId } = render(
110
+ <AttributeEditor source="products">
111
+ <NonInput />
112
+ </AttributeEditor>,
113
+ );
114
+
115
+ expect(getByTestId('non-input').getAttribute('source')).toBeNull();
116
+ });
117
+
118
+ it('should disable the add button when disableAddButton is true', () => {
119
+ vi.mocked(useFieldArray).mockReturnValue({
120
+ ...defaultFieldArray,
121
+ fields: [],
122
+ } as any);
123
+
124
+ const { getByTestId } = render(
125
+ <AttributeEditor source="products" disableAddButton>
126
+ <div resource="id" />
127
+ </AttributeEditor>,
128
+ );
129
+
130
+ expect(getByTestId('add-button').hasAttribute('disabled')).toBe(true);
131
+ });
132
+
133
+ it('should hide the add button when hideAddButton is true', () => {
134
+ vi.mocked(useFieldArray).mockReturnValue({
135
+ ...defaultFieldArray,
136
+ fields: [],
137
+ } as any);
138
+
139
+ const { queryByTestId } = render(
140
+ <AttributeEditor source="products" hideAddButton>
141
+ <div resource="id" />
142
+ </AttributeEditor>,
143
+ );
144
+
145
+ expect(queryByTestId('add-button')).toBeNull();
146
+ });
147
+ });
@@ -0,0 +1,185 @@
1
+ import React, { useMemo } from 'react';
2
+ import { useInput, RecordContextProvider, useResourceContext } from '@strato-admin/core';
3
+ import { useFieldArray, useFormContext } from 'react-hook-form';
4
+ import CloudscapeAttributeEditor from '@cloudscape-design/components/attribute-editor';
5
+ import Box from '@cloudscape-design/components/box';
6
+ import { FieldTitle } from './FieldTitle';
7
+ import TextInput from './TextInput';
8
+ import FormField from './FormField';
9
+ import { FormFieldContext, useFormFieldContext } from './FormFieldContext';
10
+ import { InputProps } from './types';
11
+
12
+ export interface AttributeEditorItemProps {
13
+ source: string;
14
+ label?: string | false;
15
+ field?: React.ComponentType<any>;
16
+ validate?: any;
17
+ defaultValue?: any;
18
+ children?: React.ReactNode;
19
+ }
20
+
21
+ export const Item = (_props: AttributeEditorItemProps) => {
22
+ // This is a placeholder component used to collect props.
23
+ // The actual rendering is handled by the AttributeEditor.
24
+ return null;
25
+ };
26
+
27
+ export interface AttributeEditorProps extends Omit<InputProps, 'source'> {
28
+ source?: string;
29
+ children: React.ReactNode;
30
+ addButtonText?: string;
31
+ removeButtonText?: string;
32
+ empty?: React.ReactNode;
33
+ disableAddButton?: boolean;
34
+ hideAddButton?: boolean;
35
+ }
36
+
37
+ export const AttributeEditor = (props: AttributeEditorProps) => {
38
+ const {
39
+ children,
40
+ label,
41
+ source: sourceProp,
42
+ validate,
43
+ defaultValue,
44
+ addButtonText = 'Add item',
45
+ removeButtonText = 'Remove item',
46
+ empty,
47
+ disableAddButton,
48
+ hideAddButton,
49
+ ...rest
50
+ } = props;
51
+
52
+ const { control } = useFormContext();
53
+ const contextValue = useFormFieldContext();
54
+ const resource = useResourceContext();
55
+
56
+ // Attempt to get source from context if not provided
57
+ const source = sourceProp || contextValue?.source || '';
58
+
59
+ const inputState =
60
+ contextValue ??
61
+ useInput({
62
+ source,
63
+ validate,
64
+ defaultValue,
65
+ ...rest,
66
+ });
67
+
68
+ const { fields, append, remove } = useFieldArray({
69
+ control,
70
+ name: source,
71
+ });
72
+
73
+ const handleAdd = () => {
74
+ append({});
75
+ };
76
+
77
+ const handleRemove = (index: number) => {
78
+ remove(index);
79
+ };
80
+
81
+ const definition = useMemo(() => {
82
+ return React.Children.map(children, (child) => {
83
+ if (!React.isValidElement(child)) return null;
84
+
85
+ const childProps = child.props as any;
86
+ const childSource = childProps.source;
87
+ const childLabel = childProps.label;
88
+ const childValidate = childProps.validate;
89
+
90
+ // Determine if the field is required by checking validators
91
+ const isRequired = Array.isArray(childValidate)
92
+ ? childValidate.some((v: any) => v.isRequired)
93
+ : childValidate?.isRequired || childProps.isRequired;
94
+
95
+ return {
96
+ label: <FieldTitle label={childLabel} source={childSource} resource={resource} isRequired={isRequired} />,
97
+ control: (item: any, index: number) => {
98
+ const prefixedSource = `${source}.${index}.${childSource}`;
99
+
100
+ let content;
101
+ if (child.type === Item) {
102
+ const {
103
+ field: FieldComponent,
104
+ children: itemChildren,
105
+ validate: itemValidate,
106
+ defaultValue: itemDefaultValue,
107
+ } = childProps;
108
+ content = itemChildren ? (
109
+ <FormField source={prefixedSource} label={false} validate={itemValidate} defaultValue={itemDefaultValue}>
110
+ {itemChildren}
111
+ </FormField>
112
+ ) : FieldComponent ? (
113
+ <FieldComponent
114
+ source={prefixedSource}
115
+ label={false}
116
+ validate={itemValidate}
117
+ defaultValue={itemDefaultValue}
118
+ />
119
+ ) : (
120
+ <TextInput
121
+ source={prefixedSource}
122
+ label={false}
123
+ validate={itemValidate}
124
+ defaultValue={itemDefaultValue}
125
+ />
126
+ );
127
+ } else {
128
+ // Standard child (e.g. TextInput)
129
+ content = React.cloneElement(
130
+ child as React.ReactElement<any>,
131
+ {
132
+ source: prefixedSource,
133
+ label: false,
134
+ } as any,
135
+ );
136
+ }
137
+
138
+ return (
139
+ <RecordContextProvider value={item}>
140
+ <Box padding={{ top: 's' }}>{content}</Box>
141
+ </RecordContextProvider>
142
+ );
143
+ },
144
+ };
145
+ })?.filter(Boolean) as any[];
146
+ }, [children, source, resource]);
147
+
148
+ const inner = (
149
+ <FormFieldContext.Provider value={undefined}>
150
+ <CloudscapeAttributeEditor
151
+ items={fields}
152
+ definition={definition}
153
+ onAddButtonClick={handleAdd}
154
+ onRemoveButtonClick={({ detail: { itemIndex } }) => handleRemove(itemIndex)}
155
+ empty={
156
+ empty || (
157
+ <Box textAlign="center" color="inherit">
158
+ No items added yet.
159
+ </Box>
160
+ )
161
+ }
162
+ addButtonText={addButtonText}
163
+ removeButtonText={removeButtonText}
164
+ disableAddButton={disableAddButton}
165
+ hideAddButton={hideAddButton}
166
+ />
167
+ </FormFieldContext.Provider>
168
+ );
169
+
170
+ if (contextValue) {
171
+ return inner;
172
+ }
173
+
174
+ return (
175
+ <FormFieldContext.Provider value={{ ...inputState, source }}>
176
+ <FormField {...props} source={source}>
177
+ {inner}
178
+ </FormField>
179
+ </FormFieldContext.Provider>
180
+ );
181
+ };
182
+
183
+ AttributeEditor.Item = Item;
184
+
185
+ export default AttributeEditor;
@@ -0,0 +1,178 @@
1
+
2
+ import { render, fireEvent } from '@testing-library/react';
3
+ import { vi, describe, it, expect, beforeEach } from 'vitest';
4
+ import { useInput, useResourceContext, useChoicesContext } from '@strato-admin/core';
5
+ import { AutocompleteInput } from './AutocompleteInput';
6
+
7
+ // Mock ra-core
8
+ vi.mock('@strato-admin/core', () => ({
9
+ useInput: vi.fn(),
10
+ useResourceContext: vi.fn(),
11
+ useChoicesContext: vi.fn().mockReturnValue({ allChoices: [], isPending: false }),
12
+ useTranslate: () => (key: string) => key,
13
+ useGetRecordRepresentation: () => (record: any) => record?.name || record?.id || record,
14
+ ValidationError: ({ error }: any) => <span>{error}</span>,
15
+ }));
16
+
17
+ // Mock FieldTitle
18
+ vi.mock('./FieldTitle', () => ({
19
+ FieldTitle: ({ label, source }: any) => <span data-testid="field-title">{label || source}</span>,
20
+ }));
21
+
22
+ // Mock Cloudscape components
23
+ vi.mock('@cloudscape-design/components/autosuggest', () => ({
24
+ default: ({ value, onChange, id, options, onBlur, onSelect }: any) => (
25
+ <div data-testid="cloudscape-autosuggest">
26
+ <input
27
+ id={id}
28
+ value={value}
29
+ onChange={(e) => onChange({ detail: { value: e.target.value } })}
30
+ onBlur={() => onBlur()}
31
+ />
32
+ <ul>
33
+ {options.map((opt: any) => (
34
+ <li
35
+ key={opt.value}
36
+ onClick={() => onSelect({ detail: { value: opt.value } })}
37
+ data-testid={`option-${opt.value}`}
38
+ >
39
+ {opt.label}
40
+ </li>
41
+ ))}
42
+ </ul>
43
+ </div>
44
+ ),
45
+ }));
46
+
47
+ vi.mock('@cloudscape-design/components/form-field', () => ({
48
+ default: ({ children, label, errorText, id }: any) => (
49
+ <div data-testid="form-field" id={id}>
50
+ <label>{label}</label>
51
+ {children}
52
+ {errorText && <span data-testid="error-text">{errorText}</span>}
53
+ </div>
54
+ ),
55
+ }));
56
+
57
+ describe('AutocompleteInput', () => {
58
+ beforeEach(() => {
59
+ vi.clearAllMocks();
60
+ (useChoicesContext as any).mockReturnValue({ allChoices: [], isPending: false });
61
+ });
62
+
63
+ const choices = [
64
+ { id: '1', name: 'Option 1' },
65
+ { id: '2', name: 'Option 2' },
66
+ ];
67
+
68
+ it('should render correctly and resolve ID to label', () => {
69
+ (useInput as any).mockReturnValue({
70
+ id: 'test-autocomplete',
71
+ field: { value: '1', onChange: vi.fn(), onBlur: vi.fn() },
72
+ fieldState: { isTouched: false, invalid: false, error: null },
73
+ formState: { isSubmitted: false },
74
+ isRequired: false,
75
+ });
76
+ (useChoicesContext as any).mockReturnValue({ allChoices: choices, isPending: false });
77
+ (useResourceContext as any).mockReturnValue('products');
78
+
79
+ const { getByTestId, getByText } = render(
80
+ <AutocompleteInput source="category" label="Category" choices={choices} />,
81
+ );
82
+
83
+ expect(getByTestId('cloudscape-autosuggest')).toBeDefined();
84
+ const input = getByTestId('cloudscape-autosuggest').querySelector('input');
85
+ expect(input?.value).toBe('Option 1');
86
+ expect(getByText('Category')).toBeDefined();
87
+ expect(getByText('Option 1')).toBeDefined();
88
+ expect(getByText('Option 2')).toBeDefined();
89
+ });
90
+
91
+ it('should not display the ID while loading choices', () => {
92
+ (useChoicesContext as any).mockReturnValue({ allChoices: [], isPending: true });
93
+
94
+ (useInput as any).mockReturnValue({
95
+ id: 'test-autocomplete',
96
+ field: { value: '1', onChange: vi.fn(), onBlur: vi.fn() },
97
+ fieldState: { isTouched: false, invalid: false, error: null },
98
+ formState: { isSubmitted: false },
99
+ isRequired: false,
100
+ });
101
+
102
+ const { getByTestId } = render(<AutocompleteInput source="category" />);
103
+
104
+ const input = getByTestId('cloudscape-autosuggest').querySelector('input');
105
+ expect(input?.value).toBe('');
106
+ });
107
+
108
+ it('should show error when touched and invalid', () => {
109
+ (useInput as any).mockReturnValue({
110
+ id: 'test-autocomplete',
111
+ field: { value: '', onChange: vi.fn(), onBlur: vi.fn() },
112
+ fieldState: { isTouched: true, invalid: true, error: { message: 'Required' } },
113
+ formState: { isSubmitted: false },
114
+ isRequired: true,
115
+ });
116
+
117
+ const { getByTestId } = render(<AutocompleteInput source="category" choices={choices} />);
118
+
119
+ expect(getByTestId('error-text').textContent).toBe('Required');
120
+ });
121
+
122
+ it('should call field.onChange when an option is selected', () => {
123
+ const onChange = vi.fn();
124
+ (useInput as any).mockReturnValue({
125
+ id: 'test-autocomplete',
126
+ field: { value: '', onChange, onBlur: vi.fn() },
127
+ fieldState: { isTouched: false, invalid: false, error: null },
128
+ formState: { isSubmitted: false },
129
+ isRequired: false,
130
+ });
131
+ (useChoicesContext as any).mockReturnValue({ allChoices: choices, isPending: false });
132
+
133
+ const { getByTestId } = render(<AutocompleteInput source="category" choices={choices} />);
134
+
135
+ const option = getByTestId('option-1');
136
+ fireEvent.click(option);
137
+
138
+ expect(onChange).toHaveBeenCalledWith('1');
139
+ });
140
+
141
+ it('should call field.onChange with null when input is cleared', () => {
142
+ const onChange = vi.fn();
143
+ (useInput as any).mockReturnValue({
144
+ id: 'test-autocomplete',
145
+ field: { value: '1', onChange, onBlur: vi.fn() },
146
+ fieldState: { isTouched: false, invalid: false, error: null },
147
+ formState: { isSubmitted: false },
148
+ isRequired: false,
149
+ });
150
+
151
+ const { getByTestId } = render(<AutocompleteInput source="category" choices={choices} />);
152
+
153
+ const input = getByTestId('cloudscape-autosuggest').querySelector('input');
154
+ if (input) {
155
+ fireEvent.change(input, { target: { value: '' } });
156
+ }
157
+ expect(onChange).toHaveBeenCalledWith(null);
158
+ });
159
+
160
+ it('should call field.onBlur when input is blurred', () => {
161
+ const onBlur = vi.fn();
162
+ (useInput as any).mockReturnValue({
163
+ id: 'test-autocomplete',
164
+ field: { value: '', onChange: vi.fn(), onBlur },
165
+ fieldState: { isTouched: false, invalid: false, error: null },
166
+ formState: { isSubmitted: false },
167
+ isRequired: false,
168
+ });
169
+
170
+ const { getByTestId } = render(<AutocompleteInput source="category" choices={choices} />);
171
+
172
+ const input = getByTestId('cloudscape-autosuggest').querySelector('input');
173
+ if (input) {
174
+ fireEvent.blur(input);
175
+ }
176
+ expect(onBlur).toHaveBeenCalled();
177
+ });
178
+ });
@@ -0,0 +1,116 @@
1
+ import React, { useState, useEffect, useMemo } from 'react';
2
+ import {
3
+ useInput,
4
+ useResourceContext,
5
+ useChoicesContext,
6
+ useGetRecordRepresentation,
7
+ } from '@strato-admin/core';
8
+ import CloudscapeAutosuggest, {
9
+ AutosuggestProps as CloudscapeAutosuggestProps,
10
+ } from '@cloudscape-design/components/autosuggest';
11
+ import { FormField } from './FormField';
12
+ import { FormFieldContext, useFormFieldContext } from './FormFieldContext';
13
+ import { InputProps } from './types';
14
+
15
+ export interface AutocompleteInputProps
16
+ extends Omit<CloudscapeAutosuggestProps, 'onChange' | 'value' | 'options' | 'onBlur'>,
17
+ InputProps {
18
+ choices?: Array<{ id: string | number; [key: string]: any }>;
19
+ }
20
+
21
+ export const AutocompleteInput = (props: AutocompleteInputProps) => {
22
+ const { label, source, defaultValue, validate, choices: choicesProp, ...rest } = props;
23
+ const resource = useResourceContext();
24
+ const { allChoices, isPending, setFilters } = useChoicesContext(props);
25
+ const getRecordRepresentation = useGetRecordRepresentation(resource);
26
+ const context = useFormFieldContext();
27
+ const inputState =
28
+ context ??
29
+ useInput({
30
+ source,
31
+ defaultValue,
32
+ validate,
33
+ ...rest,
34
+ });
35
+
36
+ const { id, field } = inputState;
37
+
38
+ const choices = choicesProp || allChoices || [];
39
+
40
+ const selectedChoice = useMemo(
41
+ () => choices.find((c) => String(c.id) === String(field.value)),
42
+ [choices, field.value],
43
+ );
44
+
45
+ const [filterValue, setFilterValue] = useState('');
46
+
47
+ // Keep track of the last value we synced from the field
48
+ const lastSyncedValue = React.useRef<any>(undefined);
49
+
50
+ useEffect(() => {
51
+ if (selectedChoice) {
52
+ setFilterValue(String(getRecordRepresentation(selectedChoice)));
53
+ lastSyncedValue.current = field.value;
54
+ } else if (!field.value) {
55
+ setFilterValue('');
56
+ lastSyncedValue.current = field.value;
57
+ } else if (field.value !== lastSyncedValue.current) {
58
+ // If we have a value but no choice yet, and we are not loading,
59
+ // we show the ID as a last resort.
60
+ if (!isPending) {
61
+ setFilterValue(String(field.value));
62
+ lastSyncedValue.current = field.value;
63
+ }
64
+ }
65
+ }, [field.value, selectedChoice, getRecordRepresentation, isPending]);
66
+
67
+ const options = useMemo(
68
+ () =>
69
+ choices.map((choice) => ({
70
+ label: String(getRecordRepresentation(choice)),
71
+ value: String(choice.id),
72
+ })),
73
+ [choices, getRecordRepresentation],
74
+ );
75
+
76
+ const handleChange = ({ detail }: { detail: { value: string } }) => {
77
+ setFilterValue(detail.value);
78
+ if (setFilters) {
79
+ setFilters(detail.value);
80
+ }
81
+ // If the user cleared the input, we clear the field value
82
+ if (detail.value === '') {
83
+ field.onChange(null);
84
+ }
85
+ };
86
+
87
+ const handleSelect = ({ detail }: { detail: { value: string } }) => {
88
+ field.onChange(detail.value);
89
+ };
90
+
91
+ const inner = (
92
+ <CloudscapeAutosuggest
93
+ {...rest}
94
+ id={id}
95
+ options={options}
96
+ value={filterValue}
97
+ statusType={isPending ? 'loading' : 'finished'}
98
+ expandToViewport={true}
99
+ onChange={handleChange}
100
+ onSelect={handleSelect}
101
+ onBlur={() => field.onBlur()}
102
+ />
103
+ );
104
+
105
+ if (context) {
106
+ return inner;
107
+ }
108
+
109
+ return (
110
+ <FormFieldContext.Provider value={inputState}>
111
+ <FormField {...props}>{inner}</FormField>
112
+ </FormFieldContext.Provider>
113
+ );
114
+ };
115
+
116
+ export default AutocompleteInput;
@@ -0,0 +1,53 @@
1
+
2
+ import React from 'react';
3
+ import { useTranslate, useResourceDefinitions } from '@strato-admin/core';
4
+ import { humanize } from 'inflection';
5
+
6
+ export interface FieldTitleProps {
7
+ resource?: string;
8
+ source?: string;
9
+ label?: string;
10
+ isRequired?: boolean;
11
+ }
12
+
13
+ export const FieldTitle = (props: FieldTitleProps) => {
14
+ const { resource, source, label, isRequired } = props;
15
+ const translate = useTranslate();
16
+ const definitions = useResourceDefinitions();
17
+
18
+ const labelString = React.useMemo(() => {
19
+ if (label !== undefined) {
20
+ return translate(label, { _: label });
21
+ }
22
+ if (!resource || !source) {
23
+ return source ? humanize(source) : '';
24
+ }
25
+
26
+ const definition = definitions[resource] as any;
27
+ const fieldDefinition = definition?.fields?.[source];
28
+ const defaultLabel = fieldDefinition?.label
29
+ ? translate(fieldDefinition.label, { _: fieldDefinition.label })
30
+ : source
31
+ ? humanize(source)
32
+ : '';
33
+
34
+ return translate(`resources.${resource}.fields.${source}`, {
35
+ _: defaultLabel,
36
+ });
37
+ }, [label, translate, resource, source, definitions]);
38
+
39
+ return (
40
+ <span>
41
+ {labelString}
42
+ {!isRequired && (
43
+ <span
44
+ style={{ color: '#687078', fontWeight: 'normal', fontStyle: 'italic', fontSize: '12px', marginLeft: '4px' }}
45
+ >
46
+ (optional)
47
+ </span>
48
+ )}
49
+ </span>
50
+ );
51
+ };
52
+
53
+ export default FieldTitle;