Skip to content

Commit

Permalink
Add KeyValue component to connector form (#1970)
Browse files Browse the repository at this point in the history
  • Loading branch information
korvin89 authored Jan 9, 2025
1 parent 793e3fe commit e4b2734
Show file tree
Hide file tree
Showing 12 changed files with 315 additions and 4 deletions.
11 changes: 10 additions & 1 deletion src/shared/schema/bi/types/forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,14 @@ export type HiddenItem = BaseItem & {
defaultValue?: ConnectionData[keyof ConnectionData];
};

export type KeyValueItem = BaseItem & {
id: 'key_value';
keys: SelectProps['options'];
keySelectProps?: Partial<SelectProps>;
valueInputProps?: Partial<TextInputProps>;
secret?: boolean;
};

export type ConnectorFormItem =
| LabelItem
| InputItem
Expand All @@ -133,7 +141,8 @@ export type ConnectorFormItem =
| PlainTextItem
| DescriptionItem
| FileInputItem
| HiddenItem;
| HiddenItem
| KeyValueItem;

export type CustomizableRow = {
items: ConnectorFormItem[];
Expand Down
2 changes: 1 addition & 1 deletion src/shared/types/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type {ConnectionQueryTypeValues} from '../constants';

export type ConnectionData = Record<
string,
string | number | boolean | unknown[] | null | undefined | ConnectionOptions
string | number | boolean | unknown[] | null | undefined | Record<string, unknown>
>;

export type ConnectionOptions = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Description,
FileInput,
Input,
KeyValue,
Label,
PlainText,
RadioButton,
Expand Down Expand Up @@ -82,6 +83,10 @@ export const FormItem = ({item, readonly}: {item: ConnectorFormItem; readonly?:
case 'hidden': {
return null;
}
case 'key_value': {
const {id: _id, ...itemProps} = item;
return <KeyValue {...itemProps} />;
}
default: {
logger.logError(`FormItem (conn): unknown item id "${(item as ConnectorFormItem).id}"`);
return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.conn-form-key-value {
display: flex;
flex-direction: column;
row-gap: 10px;
width: 100%;

&__add-button {
width: fit-content;
}

&__entry {
display: flex;
column-gap: 10px;
}

&__key-select {
flex-shrink: 0;
max-width: 40%;
}

&__value-input {
flex-shrink: 1;
flex-basis: auto;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React from 'react';

import {Plus, Xmark} from '@gravity-ui/icons';
import {Button, Icon, PasswordInput, Select, TextInput} from '@gravity-ui/uikit';
import block from 'bem-cn-lite';
import type {KeyValueItem} from 'shared/schema/types';

import {i18n10647} from '../../../../constants';

import {useKeyValueProps, useKeyValueState} from './hooks';
import type {KeyValueEntry, KeyValueProps} from './types';

import './KeyValue.scss';

const b = block('conn-form-key-value');
const ICON_SIZE = 18;

type KeyValueEntryViewProps = Omit<KeyValueItem, 'id' | 'name'> & {
index: number;
entry: KeyValueEntry;
onDelete: (index: number) => void;
onUpdate: (index: number, updates: Partial<KeyValueEntry>) => void;
};

const KeyValueEntryView = (props: KeyValueEntryViewProps) => {
const {index, keys, entry, keySelectProps, valueInputProps, secret, onDelete, onUpdate} = props;
let placeholder = valueInputProps?.placeholder;

if (entry.initial && secret && !placeholder) {
placeholder = i18n10647['label_secret-value'];
}

if (entry.value === null) {
return null;
}

return (
<div className={b('entry')}>
<Select
{...keySelectProps}
className={b('key-select')}
options={keys}
value={[entry.key]}
onUpdate={(value) => {
onUpdate(index, {key: value[0]});
}}
validationState={entry.error ? 'invalid' : undefined}
errorMessage={i18n10647['label_duplicated-keys']}
/>
{secret ? (
<PasswordInput
{...valueInputProps}
className={b('value-input')}
value={entry.value}
hideCopyButton={true}
placeholder={placeholder}
onUpdate={(value) => {
onUpdate(index, {value});
}}
/>
) : (
<TextInput
{...valueInputProps}
className={b('value-input')}
value={entry.value}
onUpdate={(value) => {
onUpdate(index, {value});
}}
/>
)}
<Button view="flat" onClick={() => onDelete(index)}>
<Icon data={Xmark} size={ICON_SIZE} />
</Button>
</div>
);
};

export const KeyValue = (props: KeyValueProps) => {
const {keys = [], keySelectProps, valueInputProps, secret} = props;
const {value, updateForm} = useKeyValueProps(props);
const {keyValues, handleAddKeyValue, handleUpdateKeyValue, handleDeleteKeyValue} =
useKeyValueState({
value,
updateForm,
});

return (
<div className={b()}>
{keyValues.map((item, index) => {
return (
<KeyValueEntryView
key={`${item.key}-${index}`}
index={index}
keys={keys}
keySelectProps={keySelectProps}
valueInputProps={valueInputProps}
entry={item}
secret={secret}
onDelete={handleDeleteKeyValue}
onUpdate={handleUpdateKeyValue}
/>
);
})}
<Button className={b('add-button')} onClick={handleAddKeyValue}>
<Icon data={Plus} size={ICON_SIZE} />
{i18n10647['button_add']}
</Button>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import React from 'react';

import {batch, useDispatch, useSelector} from 'react-redux';

import {ValidationErrorType} from '../../../../constants';
import {
changeForm,
changeInnerForm,
formSelector,
innerFormSelector,
setValidationErrors,
validationErrorsSelector,
} from '../../../../store';
import type {ValidationError} from '../../../../typings';
import {getValidationError} from '../../../../utils';

import type {KeyValueEntry, KeyValueProps} from './types';

type KeyValueResult = {
entries: Record<string, KeyValueEntry['value']>;
};

const initialEntriesToKeyValues = (entries: KeyValueResult['entries'] = {}): KeyValueEntry[] => {
return Object.entries(entries).map(([key, value]) => {
return {key, value, initial: true};
});
};

const keyValuesToEntries = (keyValues: KeyValueEntry[] = []): KeyValueResult['entries'] => {
return keyValues.reduce<KeyValueResult['entries']>(
(acc, {key, value, error, initial, touched}) => {
if (key && !error && (!initial || (initial && touched))) {
acc[key] = value;
}
return acc;
},
{},
);
};

const getValidatedKeyValues = (keyValues: KeyValueEntry[]) => {
return keyValues.map((item, index) => {
const resultItem = {...item};
const hasDuplicatedKey = keyValues.some(
({key}, innerIndex) => innerIndex !== index && key && key === item.key,
);
if (hasDuplicatedKey) {
resultItem.error = 'duplicated-key';
} else {
resultItem.error = undefined;
}
return resultItem;
});
};

export function useKeyValueProps(props: KeyValueProps) {
const {name, inner, keys, keySelectProps, valueInputProps, secret} = props;
const dispatch = useDispatch();
const form = useSelector(formSelector);
const innerForm = useSelector(innerFormSelector);
const validationErrors = useSelector(validationErrorsSelector);
const value = (inner ? innerForm[name] : form[name]) as KeyValueResult | undefined;
const error = getValidationError(name, validationErrors);

const updateForm = (nextKeyValues: KeyValueEntry[]) => {
const validatedNextKeyValues = getValidatedKeyValues(nextKeyValues);
const formUpdates: KeyValueResult = {
entries: keyValuesToEntries(validatedNextKeyValues),
};

batch(() => {
if (inner) {
dispatch(changeInnerForm({[name]: formUpdates}));
} else {
dispatch(changeForm({[name]: formUpdates}));
}

const hasErrors = validatedNextKeyValues.some((keyValue) => Boolean(keyValue.error));

if (hasErrors) {
const errors: ValidationError[] = [
...validationErrors,
{type: ValidationErrorType.DuplicatedKey, name},
];
dispatch(setValidationErrors({errors}));
} else if (error) {
const errors = validationErrors.filter((err) => err.name !== error.name);
dispatch(setValidationErrors({errors}));
}
});
};

return {value, keys, keySelectProps, valueInputProps, secret, updateForm};
}

export function useKeyValueState(props: {
value: KeyValueResult | undefined;
updateForm: (nextKeyValues: KeyValueEntry[]) => void;
}) {
const {value, updateForm} = props;
const [keyValues, setKeyValues] = React.useState<KeyValueEntry[]>(
initialEntriesToKeyValues(value?.entries),
);

const updateKeyValues = (nextKeyValues: KeyValueEntry[]) => {
const validatedNextKeyValues = getValidatedKeyValues(nextKeyValues);
updateForm(validatedNextKeyValues);
setKeyValues(validatedNextKeyValues);
};

const handleAddKeyValue = () => {
updateKeyValues([...keyValues, {key: '', value: ''}]);
};

const handleUpdateKeyValue = (index: number, updates: Partial<KeyValueEntry>) => {
const nextKeyValues = [...keyValues];
const updatedKeyValue = nextKeyValues[index];
nextKeyValues[index] = {
...updatedKeyValue,
...updates,
touched: true,
};
updateKeyValues(nextKeyValues);
};

const handleDeleteKeyValue = (index: number) => {
const deletedItem = keyValues[index];
const nextKeyValues: KeyValueEntry[] = deletedItem.initial
? [
...keyValues.slice(0, index),
{key: deletedItem.key, value: null},
...keyValues.slice(index + 1),
]
: [...keyValues.slice(0, index), ...keyValues.slice(index + 1)];
updateKeyValues(nextKeyValues);
};

return {keyValues, handleAddKeyValue, handleUpdateKeyValue, handleDeleteKeyValue};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type {KeyValueItem} from 'shared/schema/types';

export type KeyValueProps = Omit<KeyValueItem, 'id'>;

export type KeyValueEntry = {
key: string;
value: string | null;
error?: 'duplicated-key';
initial?: boolean;
touched?: boolean;
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
display: inline-flex;
align-items: center;
width: 152px;
line-height: 16px;
line-height: 28px;

&__inner-content {
width: inherit;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from './Datepicker/Datepicker';
export * from './Description/Description';
export * from './Input/Input';
export * from './FileInput/FileInput';
export * from './KeyValue/KeyValue';
export * from './Label/Label';
export * from './MarkdownItem/MarkdownItem';
export * from './PlainText/PlainText';
Expand Down
7 changes: 7 additions & 0 deletions src/ui/units/connections/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,10 @@ export const ConverterErrorCode = {
TOO_MANY_COLUMNS: 'ERR.FILE.TOO_MANY_COLUMNS',
UNSUPPORTED_DOCUMENT: 'ERR.FILE.UNSUPPORTED_DOCUMENT',
};

// TODO: CHARTS-10647 i18n
export const i18n10647 = {
button_add: 'Add',
'label_duplicated-keys': 'Duplicated keys',
'label_secret-value': 'Secret value',
};
1 change: 1 addition & 0 deletions src/ui/units/connections/constants/validation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export enum ValidationErrorType {
Required = 'required',
Length = 'length',
DuplicatedKey = 'duplicated-key',
}
5 changes: 4 additions & 1 deletion src/ui/units/connections/utils/validation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {I18n} from 'i18n';

import {ValidationErrorType} from '../constants';
import {ValidationErrorType, i18n10647} from '../constants';
import type {ValidationError} from '../typings';

const i18n = I18n.keyset('connections.form');
Expand All @@ -17,6 +17,9 @@ export const getErrorMessage = (type?: ValidationErrorType) => {
case ValidationErrorType.Length: {
return i18n('label_error-length-field');
}
case ValidationErrorType.DuplicatedKey: {
return i18n10647['label_duplicated-keys'];
}
default: {
return '';
}
Expand Down

0 comments on commit e4b2734

Please sign in to comment.