@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,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;
|