Skip to content

Commit e684da6

Browse files
authored
feat(TextInputMapper): add component (#523)
1 parent ca9bab8 commit e684da6

File tree

8 files changed

+383
-15
lines changed

8 files changed

+383
-15
lines changed

.changeset/gold-turtles-draw.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@cube-dev/ui-kit': minor
3+
---
4+
5+
Add support for object values in Form.

.changeset/sour-tips-occur.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@cube-dev/ui-kit': minor
3+
---
4+
5+
Add TextInputMapper component.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { StoryFn } from '@storybook/react';
2+
import { userEvent, within } from '@storybook/test';
3+
4+
import { baseProps } from '../../../stories/lists/baseProps';
5+
import { Submit } from '../../actions';
6+
import { Form } from '../../form';
7+
import { TextInput } from '../TextInput/index';
8+
9+
import { TextInputMapper, CubeTextInputMapperProps } from './TextInputMapper';
10+
11+
export default {
12+
title: 'Forms/TextInputMapper',
13+
component: TextInputMapper,
14+
parameters: {
15+
controls: {
16+
exclude: baseProps,
17+
},
18+
},
19+
argTypes: {},
20+
};
21+
22+
const Template: StoryFn<CubeTextInputMapperProps> = ({ ...props }) => (
23+
<TextInputMapper
24+
name="field"
25+
{...props}
26+
onChange={(value) => console.log('! onChange', value)}
27+
/>
28+
);
29+
30+
const FormTemplate: StoryFn<CubeTextInputMapperProps> = ({ ...props }) => (
31+
<Form
32+
defaultValues={{ field: { name: 'value' } }}
33+
labelPosition="top"
34+
onSubmit={(data) => console.log('! onSubmit', data)}
35+
>
36+
<TextInputMapper
37+
name="field"
38+
label="Field Mapper"
39+
{...props}
40+
onChange={(value) => console.log('! onChange', value)}
41+
/>
42+
<TextInput name="field.name" label="TextInput" />
43+
<Submit>Submit</Submit>
44+
</Form>
45+
);
46+
47+
export const Default = Template.bind({});
48+
Default.args = {};
49+
50+
export const WithValue = Template.bind({});
51+
WithValue.args = { value: { name: 'value' } };
52+
53+
export const WithValueAndNewMapping = Template.bind({});
54+
WithValueAndNewMapping.args = {
55+
value: { name: 'value' },
56+
keyProps: { placeholder: 'Key placeholder' },
57+
valueProps: { placeholder: 'Value placeholder' },
58+
};
59+
60+
WithValueAndNewMapping.play = async ({ canvasElement }) => {
61+
const canvas = within(canvasElement);
62+
const button = await canvas.getByText('Mapping');
63+
64+
await userEvent.click(button);
65+
};
66+
67+
export const WithinForm = FormTemplate.bind({});
68+
WithinForm.args = {};
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import {
2+
ComponentType,
3+
forwardRef,
4+
useEffect,
5+
useMemo,
6+
useRef,
7+
useState,
8+
} from 'react';
9+
10+
import { useEvent } from '../../../_internal/hooks';
11+
import { FieldBaseProps } from '../../../shared';
12+
import { useFieldProps, useFormProps, wrapWithField } from '../../form';
13+
import { CloseIcon, PlusIcon } from '../../../icons';
14+
import { Button } from '../../actions';
15+
import { Block } from '../../Block';
16+
import { Flow } from '../../layout/Flow';
17+
import { Grid } from '../../layout/Grid';
18+
import { Space } from '../../layout/Space';
19+
import { TextInput } from '../TextInput';
20+
21+
type Mapping = {
22+
key: string;
23+
value: string;
24+
id: number;
25+
};
26+
27+
export interface CubeTextInputMapperProps extends FieldBaseProps {
28+
actionLabel?: string;
29+
isDisabled?: boolean;
30+
value?: Record<string, string>;
31+
onChange?: (value: Record<string, string> | undefined) => void;
32+
ValueComponent?: ComponentType<CubeTextInputMapperInputProps>;
33+
keyProps?: Partial<CubeTextInputMapperInputProps>;
34+
valueProps?: Partial<CubeTextInputMapperInputProps>;
35+
}
36+
37+
// remove duplicates in mappings
38+
function removeDuplicates(mappings: Mapping[]) {
39+
const keys = new Set<string>();
40+
41+
return mappings.filter(({ key }) => {
42+
if (keys.has(key)) {
43+
return false;
44+
}
45+
46+
keys.add(key);
47+
48+
return true;
49+
});
50+
}
51+
52+
function TextInputMapper(props: CubeTextInputMapperProps, ref: any) {
53+
props = useFormProps(props);
54+
props = useFieldProps(props, {
55+
defaultValidationTrigger: 'onChange',
56+
valuePropsMapper: ({ value, onChange }) => ({
57+
value: value != null ? value : {},
58+
onChange: onChange,
59+
}),
60+
});
61+
62+
const counterRef = useRef(0);
63+
64+
let {
65+
isDisabled,
66+
actionLabel,
67+
value,
68+
onChange,
69+
keyProps,
70+
valueProps,
71+
ValueComponent,
72+
} = props;
73+
74+
function extractLocalValues(
75+
value: Record<string, string>,
76+
localValues: Mapping[],
77+
) {
78+
const valueKeys = Object.keys(value);
79+
const localKeys = localValues.map(({ key }) => key);
80+
81+
localKeys.forEach((key) => {
82+
if (!valueKeys.includes(key) && key !== '') {
83+
localValues = localValues.filter(({ key: k }) => k !== key);
84+
}
85+
});
86+
87+
Object.entries(value ?? {}).forEach(([key, value]) => {
88+
const exist = localValues.find(({ key: k }) => k === key);
89+
90+
if (exist) {
91+
exist.value = value;
92+
} else {
93+
localValues.push({ key, value, id: counterRef.current++ });
94+
}
95+
});
96+
97+
return removeDuplicates(localValues);
98+
}
99+
100+
const [mappings, setMappings] = useState(() => {
101+
return extractLocalValues(value ?? {}, []);
102+
});
103+
104+
useEffect(() => {
105+
setMappings(extractLocalValues(value ?? {}, mappings));
106+
}, [JSON.stringify(value)]);
107+
108+
const onMappingsChange = useEvent((newMappings: Mapping[]) => {
109+
const newValue = newMappings.reduce(
110+
(acc, { key, value }) => {
111+
acc[key] = value;
112+
113+
return acc;
114+
},
115+
{} as Record<string, string>,
116+
);
117+
118+
const keys = Object.keys(newValue);
119+
120+
if (!keys.length) {
121+
onChange?.(undefined);
122+
} else {
123+
onChange?.(newValue);
124+
}
125+
});
126+
127+
const addNewMapping = useEvent(() => {
128+
setMappings((prev) => {
129+
return [...prev, { key: '', value: '', id: counterRef.current++ }];
130+
});
131+
});
132+
133+
const showNewButton = useMemo(() => {
134+
return (
135+
!mappings.length || !mappings.some(({ key, value }) => !key || !value)
136+
);
137+
}, [mappings]);
138+
139+
ValueComponent = ValueComponent ?? TextInputMapperInput;
140+
141+
const onKeyChange = useEvent((id: number, value: string) => {
142+
mappings.find((mapping) => {
143+
if (mapping.id === id) {
144+
mapping.key = value;
145+
}
146+
});
147+
148+
setMappings([...mappings]);
149+
});
150+
151+
const onValueChange = useEvent((id: number, value: string) => {
152+
mappings.find((mapping) => {
153+
if (mapping.id === id) {
154+
mapping.value = value;
155+
}
156+
});
157+
158+
setMappings([...mappings]);
159+
});
160+
161+
const onSubmit = useEvent(() => {
162+
onMappingsChange(mappings);
163+
});
164+
165+
const renderedMappings = useMemo(() => {
166+
return mappings.map((mapping) => {
167+
const { key, value, id } = mapping;
168+
169+
return (
170+
<Grid
171+
key={id}
172+
columns="minmax(0, 1fr) minmax(0, 1fr) min-content"
173+
gap="1x"
174+
>
175+
<TextInputMapperInput
176+
id={id}
177+
isDisabled={isDisabled}
178+
type="name"
179+
value={key}
180+
placeholder="Key"
181+
onChange={onKeyChange}
182+
onSubmit={onSubmit}
183+
{...keyProps}
184+
/>
185+
<ValueComponent
186+
id={id}
187+
type="value"
188+
isDisabled={!key || isDisabled}
189+
value={value}
190+
placeholder="Value"
191+
onChange={onValueChange}
192+
onSubmit={onSubmit}
193+
{...valueProps}
194+
/>
195+
<Button
196+
aria-label="Remove mapping"
197+
theme="danger"
198+
type="clear"
199+
icon={<CloseIcon />}
200+
onPress={() => {
201+
setMappings(mappings.filter((m) => m.id !== id));
202+
onMappingsChange(mappings.filter((m) => m.id !== id));
203+
}}
204+
/>
205+
</Grid>
206+
);
207+
});
208+
}, [JSON.stringify(mappings)]);
209+
210+
const element = (
211+
<Flow gap="1x">
212+
{[...renderedMappings]}
213+
{showNewButton ? (
214+
<Space gap={0}>
215+
{/** Hotfix for inconsistent alignment with the label **/}
216+
<Block styles={{ overflow: 'clip', width: 'max 0px' }}>&nbsp;</Block>
217+
<Button
218+
isDisabled={isDisabled}
219+
icon={<PlusIcon />}
220+
onPress={addNewMapping}
221+
>
222+
{actionLabel ? actionLabel : 'Mapping'}
223+
</Button>
224+
</Space>
225+
) : null}
226+
</Flow>
227+
);
228+
229+
return wrapWithField(element, ref, props);
230+
}
231+
232+
export interface CubeTextInputMapperInputProps {
233+
id: number;
234+
type: 'name' | 'value';
235+
value?: string;
236+
placeholder?: string;
237+
onChange?: (id: number, newValue: string) => void;
238+
onSubmit?: (id: number) => void;
239+
isDisabled?: boolean;
240+
}
241+
242+
function TextInputMapperInput(props: CubeTextInputMapperInputProps) {
243+
const { id, type, value, placeholder, ...rest } = props;
244+
245+
const onChange = useEvent((newValue: string) => {
246+
props.onChange?.(id, newValue);
247+
});
248+
249+
const onBlur = useEvent(() => {
250+
props.onSubmit?.(id);
251+
});
252+
253+
return (
254+
<TextInput
255+
qa="AddMapping"
256+
width="auto"
257+
{...rest}
258+
id={undefined}
259+
value={value}
260+
labelPosition="top"
261+
aria-label={placeholder}
262+
placeholder={placeholder}
263+
onChange={onChange}
264+
onBlur={onBlur}
265+
/>
266+
);
267+
}
268+
269+
const _TextInputMapper = forwardRef(TextInputMapper);
270+
271+
export { _TextInputMapper as TextInputMapper };
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './TextInputMapper';

src/components/fields/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ export * from './Slider';
1212
export * from './Switch/Switch';
1313
export * from './Select';
1414
export * from './ComboBox';
15+
export * from './TextInputMapper';

0 commit comments

Comments
 (0)