Skip to content

Commit a61e6db

Browse files
committed
feat(TextInputMapper): add component
1 parent ca9bab8 commit a61e6db

File tree

5 files changed

+352
-13
lines changed

5 files changed

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

0 commit comments

Comments
 (0)