Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gold-turtles-draw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cube-dev/ui-kit': minor
---

Add support for object values in Form.
5 changes: 5 additions & 0 deletions .changeset/sour-tips-occur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cube-dev/ui-kit': minor
---

Add TextInputMapper component.
68 changes: 68 additions & 0 deletions src/components/fields/TextInputMapper/TextInputMapper.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { StoryFn } from '@storybook/react';
import { userEvent, within } from '@storybook/test';

import { baseProps } from '../../../stories/lists/baseProps';
import { Submit } from '../../actions';
import { Form } from '../../form';
import { TextInput } from '../TextInput/index';

import { TextInputMapper, CubeTextInputMapperProps } from './TextInputMapper';

export default {
title: 'Forms/TextInputMapper',
component: TextInputMapper,
parameters: {
controls: {
exclude: baseProps,
},
},
argTypes: {},
};

const Template: StoryFn<CubeTextInputMapperProps> = ({ ...props }) => (
<TextInputMapper
name="field"
{...props}
onChange={(value) => console.log('! onChange', value)}
/>
);

const FormTemplate: StoryFn<CubeTextInputMapperProps> = ({ ...props }) => (
<Form
defaultValues={{ field: { name: 'value' } }}
labelPosition="top"
onSubmit={(data) => console.log('! onSubmit', data)}
>
<TextInputMapper
name="field"
label="Field Mapper"
{...props}
onChange={(value) => console.log('! onChange', value)}
/>
<TextInput name="field.name" label="TextInput" />
<Submit>Submit</Submit>
</Form>
);

export const Default = Template.bind({});
Default.args = {};

export const WithValue = Template.bind({});
WithValue.args = { value: { name: 'value' } };

export const WithValueAndNewMapping = Template.bind({});
WithValueAndNewMapping.args = {
value: { name: 'value' },
keyProps: { placeholder: 'Key placeholder' },
valueProps: { placeholder: 'Value placeholder' },
};

WithValueAndNewMapping.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = await canvas.getByText('Mapping');

await userEvent.click(button);
};

export const WithinForm = FormTemplate.bind({});
WithinForm.args = {};
271 changes: 271 additions & 0 deletions src/components/fields/TextInputMapper/TextInputMapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import {
ComponentType,
forwardRef,
useEffect,
useMemo,
useRef,
useState,
} from 'react';

import { useEvent } from '../../../_internal/hooks';
import { FieldBaseProps } from '../../../shared';
import { useFieldProps, useFormProps, wrapWithField } from '../../form';
import { CloseIcon, PlusIcon } from '../../../icons';
import { Button } from '../../actions';
import { Block } from '../../Block';
import { Flow } from '../../layout/Flow';
import { Grid } from '../../layout/Grid';
import { Space } from '../../layout/Space';
import { TextInput } from '../TextInput';

type Mapping = {
key: string;
value: string;
id: number;
};

export interface CubeTextInputMapperProps extends FieldBaseProps {
actionLabel?: string;
isDisabled?: boolean;
value?: Record<string, string>;
onChange?: (value: Record<string, string> | undefined) => void;
ValueComponent?: ComponentType<CubeTextInputMapperInputProps>;
keyProps?: Partial<CubeTextInputMapperInputProps>;
valueProps?: Partial<CubeTextInputMapperInputProps>;
}

// remove duplicates in mappings
function removeDuplicates(mappings: Mapping[]) {
const keys = new Set<string>();

return mappings.filter(({ key }) => {
if (keys.has(key)) {
return false;
}

keys.add(key);

return true;
});
}

function TextInputMapper(props: CubeTextInputMapperProps, ref: any) {
props = useFormProps(props);
props = useFieldProps(props, {
defaultValidationTrigger: 'onChange',
valuePropsMapper: ({ value, onChange }) => ({
value: value != null ? value : {},
onChange: onChange,
}),
});

const counterRef = useRef(0);

let {
isDisabled,
actionLabel,
value,
onChange,
keyProps,
valueProps,
ValueComponent,
} = props;

function extractLocalValues(
value: Record<string, string>,
localValues: Mapping[],
) {
const valueKeys = Object.keys(value);
const localKeys = localValues.map(({ key }) => key);

localKeys.forEach((key) => {
if (!valueKeys.includes(key) && key !== '') {
localValues = localValues.filter(({ key: k }) => k !== key);
}
});

Object.entries(value ?? {}).forEach(([key, value]) => {
const exist = localValues.find(({ key: k }) => k === key);

if (exist) {
exist.value = value;
} else {
localValues.push({ key, value, id: counterRef.current++ });
}
});

return removeDuplicates(localValues);
}

const [mappings, setMappings] = useState(() => {
return extractLocalValues(value ?? {}, []);
});

useEffect(() => {
setMappings(extractLocalValues(value ?? {}, mappings));
}, [JSON.stringify(value)]);

const onMappingsChange = useEvent((newMappings: Mapping[]) => {
const newValue = newMappings.reduce(
(acc, { key, value }) => {
acc[key] = value;

return acc;
},
{} as Record<string, string>,
);

const keys = Object.keys(newValue);

if (!keys.length) {
onChange?.(undefined);
} else {
onChange?.(newValue);
}
});

const addNewMapping = useEvent(() => {
setMappings((prev) => {
return [...prev, { key: '', value: '', id: counterRef.current++ }];
});
});

const showNewButton = useMemo(() => {
return (
!mappings.length || !mappings.some(({ key, value }) => !key || !value)
);
}, [mappings]);

ValueComponent = ValueComponent ?? TextInputMapperInput;

const onKeyChange = useEvent((id: number, value: string) => {
mappings.find((mapping) => {

Check warning on line 142 in src/components/fields/TextInputMapper/TextInputMapper.tsx

View workflow job for this annotation

GitHub Actions / Tests & lint

Array.prototype.find() expects a return value from arrow function
if (mapping.id === id) {
mapping.key = value;
}
});

setMappings([...mappings]);
});

const onValueChange = useEvent((id: number, value: string) => {
mappings.find((mapping) => {

Check warning on line 152 in src/components/fields/TextInputMapper/TextInputMapper.tsx

View workflow job for this annotation

GitHub Actions / Tests & lint

Array.prototype.find() expects a return value from arrow function
if (mapping.id === id) {
mapping.value = value;
}
});

setMappings([...mappings]);
});

const onSubmit = useEvent(() => {
onMappingsChange(mappings);
});

const renderedMappings = useMemo(() => {
return mappings.map((mapping) => {
const { key, value, id } = mapping;

return (
<Grid
key={id}
columns="minmax(0, 1fr) minmax(0, 1fr) min-content"
gap="1x"
>
<TextInputMapperInput
id={id}
isDisabled={isDisabled}
type="name"
value={key}
placeholder="Key"
onChange={onKeyChange}
onSubmit={onSubmit}
{...keyProps}
/>
<ValueComponent
id={id}
type="value"
isDisabled={!key || isDisabled}
value={value}
placeholder="Value"
onChange={onValueChange}
onSubmit={onSubmit}
{...valueProps}
/>
<Button
aria-label="Remove mapping"
theme="danger"
type="clear"
icon={<CloseIcon />}
onPress={() => {
setMappings(mappings.filter((m) => m.id !== id));
onMappingsChange(mappings.filter((m) => m.id !== id));
}}
/>
</Grid>
);
});
}, [JSON.stringify(mappings)]);

const element = (
<Flow gap="1x">
{[...renderedMappings]}
{showNewButton ? (
<Space gap={0}>
{/** Hotfix for inconsistent alignment with the label **/}
<Block styles={{ overflow: 'clip', width: 'max 0px' }}>&nbsp;</Block>
<Button
isDisabled={isDisabled}
icon={<PlusIcon />}
onPress={addNewMapping}
>
{actionLabel ? actionLabel : 'Mapping'}
</Button>
</Space>
) : null}
</Flow>
);

return wrapWithField(element, ref, props);
}

export interface CubeTextInputMapperInputProps {
id: number;
type: 'name' | 'value';
value?: string;
placeholder?: string;
onChange?: (id: number, newValue: string) => void;
onSubmit?: (id: number) => void;
isDisabled?: boolean;
}

function TextInputMapperInput(props: CubeTextInputMapperInputProps) {
const { id, type, value, placeholder, ...rest } = props;

const onChange = useEvent((newValue: string) => {
props.onChange?.(id, newValue);
});

const onBlur = useEvent(() => {
props.onSubmit?.(id);
});

return (
<TextInput
qa="AddMapping"
width="auto"
{...rest}
id={undefined}
value={value}
labelPosition="top"
aria-label={placeholder}
placeholder={placeholder}
onChange={onChange}
onBlur={onBlur}
/>
);
}

const _TextInputMapper = forwardRef(TextInputMapper);

export { _TextInputMapper as TextInputMapper };
1 change: 1 addition & 0 deletions src/components/fields/TextInputMapper/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './TextInputMapper';
1 change: 1 addition & 0 deletions src/components/fields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export * from './Slider';
export * from './Switch/Switch';
export * from './Select';
export * from './ComboBox';
export * from './TextInputMapper';
Loading
Loading