feat: key value widget (#865)

This commit is contained in:
Daniel Lautzenheiser
2023-09-06 16:30:51 -04:00
committed by GitHub
parent 6bcf451a18
commit dbf007a586
24 changed files with 1059 additions and 51 deletions

View File

@ -10,6 +10,8 @@ export * from './file';
export { default as FileWidget } from './file';
export * from './image';
export { default as ImageWidget } from './image';
export * from './keyvalue';
export { default as KeyValueWidget } from './keyvalue';
export * from './list';
export { default as ListWidget } from './list';
export * from './map';

View File

@ -0,0 +1,167 @@
import { Close as CloseIcon } from '@styled-icons/material/Close';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import Button from '@staticcms/core/components/common/button/Button';
import IconButton from '@staticcms/core/components/common/button/IconButton';
import Field from '@staticcms/core/components/common/field/Field';
import TextField from '@staticcms/core/components/common/text-field/TextField';
import useDebouncedCallback from '@staticcms/core/lib/hooks/useDebouncedCallback';
import { createEmptyPair } from './util';
import type { KeyValueField, WidgetControlProps } from '@staticcms/core/interface';
import type { ChangeEvent, FC, MouseEvent } from 'react';
import type { Pair } from './types';
const StringControl: FC<WidgetControlProps<Pair[], KeyValueField>> = ({
value,
label,
errors,
disabled,
field,
forSingleList,
duplicate,
controlled,
onChange,
t,
}) => {
const labelSingular = useMemo(
() => (field.label_singular ? field.label_singular : field.label ?? field.name),
[field.label, field.label_singular, field.name],
);
const keyLabel = useMemo(
() => field.key_label ?? t('editor.editorWidgets.keyvalue.key'),
[field.key_label, t],
);
const valueLabel = useMemo(
() => field.value_label ?? t('editor.editorWidgets.keyvalue.value'),
[field.value_label, t],
);
const rawValue: Pair[] = useMemo(() => (value ? value : [createEmptyPair()]), [value]);
const [internalRawValue, setInternalValue] = useState(rawValue);
const internalValue = useMemo(
() => (controlled || duplicate ? rawValue : internalRawValue),
[controlled, duplicate, rawValue, internalRawValue],
);
const ref = useRef<HTMLInputElement | null>(null);
const debouncedOnChanged = useDebouncedCallback(onChange, 250);
const updateInternalValue = useCallback(
(newInternalValue: Pair[]) => {
setInternalValue(newInternalValue);
debouncedOnChanged(newInternalValue);
},
[debouncedOnChanged],
);
const handleChange = useCallback(
(index: number, target: 'key' | 'value') => (event: ChangeEvent<HTMLInputElement>) => {
const newInternalValue = [...internalValue];
newInternalValue[index] = {
...newInternalValue[index],
[target]: event.target.value,
};
updateInternalValue(newInternalValue);
},
[internalValue, updateInternalValue],
);
const handleAdd = useCallback(() => {
const newInternalValue = [...internalValue];
newInternalValue.push(createEmptyPair());
updateInternalValue(newInternalValue);
}, [internalValue, updateInternalValue]);
const handleRemove = useCallback(
(index: number) => () => {
const newInternalValue = [...internalValue];
newInternalValue.splice(index, 1);
updateInternalValue(newInternalValue);
},
[internalValue, updateInternalValue],
);
const clickNoOp = useCallback((event: MouseEvent) => {
event.stopPropagation();
}, []);
return (
<Field
inputRef={ref}
label={label}
errors={errors}
hint={field.hint}
forSingleList={forSingleList}
cursor="text"
disabled={disabled}
>
<div className="flex gap-2 px-3 mt-2 w-full">
<div className="w-full text-sm">{keyLabel}</div>
<div className="w-full text-sm">{valueLabel}</div>
<div className="flex">
<div className="w-[24px]"></div>
</div>
</div>
{internalValue.map((pair, index) => (
<div key={`keyvalue-${index}`} className="flex gap-2 px-3 mt-2 w-full items-center">
<TextField
type="text"
data-testid={`key-${index}`}
inputRef={index === 0 ? ref : undefined}
value={pair.key}
disabled={disabled}
onChange={handleChange(index, 'key')}
onClick={clickNoOp}
variant="contained"
/>
<TextField
type="text"
data-testid={`value-${index}`}
value={pair.value}
disabled={disabled}
onChange={handleChange(index, 'value')}
onClick={clickNoOp}
variant="contained"
/>
<IconButton
data-testid={`remove-button-${index}`}
size="small"
variant="text"
onClick={handleRemove(index)}
disabled={disabled}
className="
h-6
w-6
"
>
<CloseIcon
className="
h-5
w-5
"
/>
</IconButton>
</div>
))}
<div className="px-3 mt-3">
<Button
variant="outlined"
onClick={handleAdd}
className="w-full"
data-testid="key-value-add"
disabled={disabled}
>
{t('editor.editorWidgets.list.add', { item: labelSingular })}
</Button>
</div>
</Field>
);
};
export default StringControl;

View File

@ -0,0 +1,19 @@
import React from 'react';
import type { KeyValueField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
import type { Pair } from './types';
const StringPreview: FC<WidgetPreviewProps<Pair[], KeyValueField>> = ({ value }) => {
return (
<ul>
{(value ?? []).map((pair, index) => (
<li key={`preview-keyvalue-${index}`}>
<b>{pair.key ?? ''}</b> - {pair.value ?? ''}
</li>
))}
</ul>
);
};
export default StringPreview;

View File

@ -0,0 +1,250 @@
/**
* @jest-environment jsdom
*/
import '@testing-library/jest-dom';
import { act, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { mockKeyValueField } from '@staticcms/test/data/fields.mock';
import { createWidgetControlHarness } from '@staticcms/test/harnesses/widget.harness';
import KeyValueControl from '../KeyValueControl';
describe(KeyValueControl.name, () => {
const renderControl = createWidgetControlHarness(KeyValueControl, { field: mockKeyValueField });
it('should render', () => {
const { getByTestId } = renderControl({ label: 'I am a label' });
expect(getByTestId('key-0')).toBeInTheDocument();
expect(getByTestId('value-0')).toBeInTheDocument();
const label = getByTestId('label');
expect(label.textContent).toBe('I am a label');
expect(label).toHaveClass('text-slate-500');
const field = getByTestId('field');
expect(field).toHaveClass('group/active');
const fieldWrapper = getByTestId('field-wrapper');
expect(fieldWrapper).not.toHaveClass('mr-14');
// Key Value Widget uses text cursor
expect(label).toHaveClass('cursor-text');
expect(field).toHaveClass('cursor-text');
// Key Value Widget uses default label layout, with bottom padding on field
expect(label).toHaveClass('px-3', 'pt-3');
expect(field).toHaveClass('pb-3');
});
it('should render as single list item', () => {
const { getByTestId } = renderControl({ label: 'I am a label', forSingleList: true });
expect(getByTestId('key-0')).toBeInTheDocument();
expect(getByTestId('value-0')).toBeInTheDocument();
const fieldWrapper = getByTestId('field-wrapper');
expect(fieldWrapper).toHaveClass('mr-14');
});
it('should only use prop value as initial value', async () => {
const { rerender, getByTestId } = renderControl({
value: [{ key: 'Key 1', value: 'Value 1' }],
});
const keyInputWrapper = getByTestId('key-0');
const keyInput = keyInputWrapper.getElementsByTagName('input')[0];
expect(keyInput).toHaveValue('Key 1');
const valueInputWrapper = getByTestId('value-0');
const valueInput = valueInputWrapper.getElementsByTagName('input')[0];
expect(valueInput).toHaveValue('Value 1');
rerender({ value: [{ key: 'Key 1 Updated', value: 'Value 1 Updated' }] });
expect(keyInput).toHaveValue('Key 1');
expect(valueInput).toHaveValue('Value 1');
});
it('should use prop value exclusively if field is i18n duplicate', async () => {
const { rerender, getByTestId } = renderControl({
field: { ...mockKeyValueField, i18n: 'duplicate' },
duplicate: true,
value: [{ key: 'Key 1', value: 'Value 1' }],
});
const keyInputWrapper = getByTestId('key-0');
const keyInput = keyInputWrapper.getElementsByTagName('input')[0];
expect(keyInput).toHaveValue('Key 1');
const valueInputWrapper = getByTestId('value-0');
const valueInput = valueInputWrapper.getElementsByTagName('input')[0];
expect(valueInput).toHaveValue('Value 1');
rerender({ value: [{ key: 'Key 1 Updated', value: 'Value 1 Updated' }] });
expect(keyInput).toHaveValue('Key 1 Updated');
expect(valueInput).toHaveValue('Value 1 Updated');
});
it('should call onChange when text input changes', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl();
const keyInputWrapper = getByTestId('key-0');
const keyInput = keyInputWrapper.getElementsByTagName('input')[0];
await act(async () => {
await userEvent.type(keyInput, 'New Key');
});
expect(onChange).toHaveBeenCalledTimes(0);
await waitFor(() => {
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenLastCalledWith([{ key: 'New Key', value: '' }]);
});
const valueInputWrapper = getByTestId('value-0');
const valueInput = valueInputWrapper.getElementsByTagName('input')[0];
await act(async () => {
await userEvent.type(valueInput, 'New Value');
});
expect(onChange).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenLastCalledWith([{ key: 'New Key', value: 'New Value' }]);
});
});
it('should add new key value pair', async () => {
const {
getByTestId,
queryByTestId,
props: { onChange },
} = renderControl({
value: [{ key: 'Key 1', value: 'Value 1' }],
});
expect(getByTestId('key-0')).toBeInTheDocument();
expect(getByTestId('value-0')).toBeInTheDocument();
expect(queryByTestId('key-1')).not.toBeInTheDocument();
expect(queryByTestId('value-1')).not.toBeInTheDocument();
const addButton = getByTestId('key-value-add');
await act(async () => {
await userEvent.click(addButton);
});
expect(queryByTestId('key-1')).toBeInTheDocument();
expect(queryByTestId('value-1')).toBeInTheDocument();
const keyInputWrapper = getByTestId('key-1');
const keyInput = keyInputWrapper.getElementsByTagName('input')[0];
await act(async () => {
await userEvent.type(keyInput, 'New Key 2');
});
expect(onChange).toHaveBeenCalledTimes(0);
await waitFor(() => {
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenLastCalledWith([
{ key: 'Key 1', value: 'Value 1' },
{ key: 'New Key 2', value: '' },
]);
});
const valueInputWrapper = getByTestId('value-1');
const valueInput = valueInputWrapper.getElementsByTagName('input')[0];
await act(async () => {
await userEvent.type(valueInput, 'New Value 2');
});
expect(onChange).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenLastCalledWith([
{ key: 'Key 1', value: 'Value 1' },
{ key: 'New Key 2', value: 'New Value 2' },
]);
});
});
it('should remove existing key value pair', async () => {
const {
getByTestId,
props: { onChange },
} = renderControl({
value: [
{ key: 'Key 1', value: 'Value 1' },
{ key: 'Key 2', value: 'Value 2' },
{ key: 'Key 3', value: 'Value 3' },
],
});
const removeButton = getByTestId('remove-button-1');
await act(async () => {
await userEvent.click(removeButton);
});
await waitFor(() => {
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenLastCalledWith([
{ key: 'Key 1', value: 'Value 1' },
{ key: 'Key 3', value: 'Value 3' },
]);
});
});
it('should show error', async () => {
const { getByTestId } = renderControl({
errors: [{ type: 'error-type', message: 'i am an error' }],
});
const error = getByTestId('error');
expect(error.textContent).toBe('i am an error');
const field = getByTestId('field');
expect(field).not.toHaveClass('group/active');
const label = getByTestId('label');
expect(label).toHaveClass('text-red-500');
});
it('should focus input on field click', async () => {
const { getByTestId } = renderControl();
const keyInputWrapper = getByTestId('key-0');
const keyInput = keyInputWrapper.getElementsByTagName('input')[0];
expect(keyInput).not.toHaveFocus();
await act(async () => {
const field = getByTestId('field');
await userEvent.click(field);
});
expect(keyInput).toHaveFocus();
});
it('should disable input if disabled', async () => {
const { getByTestId } = renderControl({ disabled: true });
const keyInputWrapper = getByTestId('key-0');
const keyInput = keyInputWrapper.getElementsByTagName('input')[0];
expect(keyInput).toBeDisabled();
const valueInputWrapper = getByTestId('value-0');
const valueInput = valueInputWrapper.getElementsByTagName('input')[0];
expect(valueInput).toBeDisabled();
});
});

View File

@ -0,0 +1,34 @@
/**
* @jest-environment jsdom
*/
import '@testing-library/jest-dom';
import converters from '../converters';
import type { KeyValueField } from '@staticcms/core/interface';
describe('converters key value', () => {
const keyValueField: KeyValueField = {
label: 'Key Value',
name: 'mock_key_value',
widget: 'keyvalue',
};
const storageValue = {
'Key 1': 'Value 1',
'Key 2': 'Value 2',
};
const internalValue = [
{ key: 'Key 1', value: 'Value 1' },
{ key: 'Key 2', value: 'Value 2' },
];
it('should deserialize', () => {
expect(converters.deserialize(storageValue, keyValueField)).toEqual(internalValue);
});
it('should serialize', () => {
expect(converters.serialize(internalValue, keyValueField)).toEqual(storageValue);
});
});

View File

@ -0,0 +1,311 @@
/**
* @jest-environment jsdom
*/
import '@testing-library/jest-dom';
import validator from '../validator';
import ValidationErrorTypes from '@staticcms/core/constants/validationErrorTypes';
import type { KeyValueField } from '@staticcms/core/interface';
describe('validator key value', () => {
const t = jest.fn();
const minMaxKeyValueField: KeyValueField = {
label: 'Key Value',
name: 'mock_key_value',
widget: 'keyvalue',
min: 2,
max: 5,
};
beforeEach(() => {
t.mockReset();
t.mockReturnValue('mock translated text');
});
describe('range', () => {
it('should not throw error if number of values is in range', () => {
expect(
validator({
field: minMaxKeyValueField,
value: [
{ key: 'Key 1', value: 'Value 1' },
{ key: 'Key 2', value: 'Value 2' },
],
t,
}),
).toBeFalsy();
expect(t).toBeCalledTimes(2);
expect(t).toHaveBeenNthCalledWith(1, 'editor.editorWidgets.keyvalue.key');
expect(t).toHaveBeenNthCalledWith(2, 'editor.editorWidgets.keyvalue.value');
});
it('should throw error if number of values is less than min', () => {
expect(
validator({
field: minMaxKeyValueField,
value: [{ key: 'Key 1', value: 'Value 1' }],
t,
}),
).toEqual({
type: ValidationErrorTypes.RANGE,
message: 'mock translated text',
});
expect(t).toBeCalledTimes(3);
expect(t).toHaveBeenNthCalledWith(1, 'editor.editorWidgets.keyvalue.key');
expect(t).toHaveBeenNthCalledWith(2, 'editor.editorWidgets.keyvalue.value');
expect(t).toHaveBeenNthCalledWith(3, 'editor.editorControlPane.widget.rangeCount', {
fieldLabel: 'Key Value',
minCount: 2,
maxCount: 5,
count: 1,
});
});
it('should throw error if number of values is greater than max', () => {
expect(
validator({
field: minMaxKeyValueField,
value: [
{ key: 'Key 1', value: 'Value 1' },
{ key: 'Key 2', value: 'Value 2' },
{ key: 'Key 3', value: 'Value 3' },
{ key: 'Key 4', value: 'Value 4' },
{ key: 'Key 5', value: 'Value 5' },
{ key: 'Key 6', value: 'Value 6' },
],
t,
}),
).toEqual({
type: ValidationErrorTypes.RANGE,
message: 'mock translated text',
});
expect(t).toHaveBeenCalledWith('editor.editorControlPane.widget.rangeCount', {
fieldLabel: 'Key Value',
minCount: 2,
maxCount: 5,
count: 6,
});
});
});
describe('range exact', () => {
const mockRangeExactKeyValueField: KeyValueField = {
...minMaxKeyValueField,
max: 2,
};
it('should not throw error if number of values is in range', () => {
expect(
validator({
field: mockRangeExactKeyValueField,
value: [
{ key: 'Key 2', value: 'Value 2' },
{ key: 'Key 3', value: 'Value 3' },
],
t,
}),
).toBeFalsy();
expect(t).toBeCalledTimes(2);
expect(t).toHaveBeenNthCalledWith(1, 'editor.editorWidgets.keyvalue.key');
expect(t).toHaveBeenNthCalledWith(2, 'editor.editorWidgets.keyvalue.value');
});
it('should throw error if number of values is less than min', () => {
expect(
validator({
field: mockRangeExactKeyValueField,
value: [{ key: 'Key 2', value: 'Value 2' }],
t,
}),
).toEqual({
type: ValidationErrorTypes.RANGE,
message: 'mock translated text',
});
expect(t).toBeCalledTimes(3);
expect(t).toHaveBeenNthCalledWith(1, 'editor.editorWidgets.keyvalue.key');
expect(t).toHaveBeenNthCalledWith(2, 'editor.editorWidgets.keyvalue.value');
expect(t).toHaveBeenNthCalledWith(3, 'editor.editorControlPane.widget.rangeCountExact', {
fieldLabel: 'Key Value',
minCount: 2,
maxCount: 2,
count: 1,
});
});
it('should throw error if number of values is greater than max', () => {
expect(
validator({
field: mockRangeExactKeyValueField,
value: [
{ key: 'Key 1', value: 'Value 1' },
{ key: 'Key 2', value: 'Value 2' },
{ key: 'Key 3', value: 'Value 3' },
{ key: 'Key 4', value: 'Value 4' },
{ key: 'Key 5', value: 'Value 5' },
{ key: 'Key 6', value: 'Value 6' },
],
t,
}),
).toEqual({
type: ValidationErrorTypes.RANGE,
message: 'mock translated text',
});
expect(t).toBeCalledTimes(3);
expect(t).toHaveBeenNthCalledWith(1, 'editor.editorWidgets.keyvalue.key');
expect(t).toHaveBeenNthCalledWith(2, 'editor.editorWidgets.keyvalue.value');
expect(t).toHaveBeenNthCalledWith(3, 'editor.editorControlPane.widget.rangeCountExact', {
fieldLabel: 'Key Value',
minCount: 2,
maxCount: 2,
count: 6,
});
});
});
describe('min', () => {
const mockMinKeyValueField: KeyValueField = {
...minMaxKeyValueField,
max: undefined,
};
it('should not throw error if number of values is greater than or equal to min', () => {
expect(
validator({
field: mockMinKeyValueField,
value: [
{ key: 'Key 2', value: 'Value 2' },
{ key: 'Key 3', value: 'Value 3' },
],
t,
}),
).toBeFalsy();
expect(t).toBeCalledTimes(2);
expect(t).toHaveBeenNthCalledWith(1, 'editor.editorWidgets.keyvalue.key');
expect(t).toHaveBeenNthCalledWith(2, 'editor.editorWidgets.keyvalue.value');
t.mockReset();
expect(
validator({
field: mockMinKeyValueField,
value: [
{ key: 'Key 2', value: 'Value 2' },
{ key: 'Key 3', value: 'Value 3' },
{ key: 'Key 4', value: 'Value 4' },
],
t,
}),
).toBeFalsy();
expect(t).toBeCalledTimes(2);
expect(t).toHaveBeenNthCalledWith(1, 'editor.editorWidgets.keyvalue.key');
expect(t).toHaveBeenNthCalledWith(2, 'editor.editorWidgets.keyvalue.value');
});
it('should throw error if number of values is less than min', () => {
expect(
validator({
field: mockMinKeyValueField,
value: [{ key: 'Key 2', value: 'Value 2' }],
t,
}),
).toEqual({
type: ValidationErrorTypes.RANGE,
message: 'mock translated text',
});
expect(t).toBeCalledTimes(3);
expect(t).toHaveBeenNthCalledWith(1, 'editor.editorWidgets.keyvalue.key');
expect(t).toHaveBeenNthCalledWith(2, 'editor.editorWidgets.keyvalue.value');
expect(t).toHaveBeenNthCalledWith(3, 'editor.editorControlPane.widget.rangeMin', {
fieldLabel: 'Key Value',
minCount: 2,
maxCount: undefined,
count: 1,
});
});
});
describe('max', () => {
const mockMaxKeyValueField: KeyValueField = {
...minMaxKeyValueField,
min: undefined,
};
it('should not throw error if number of values is less than or equal to max', () => {
expect(
validator({
field: mockMaxKeyValueField,
value: [
{ key: 'Key 1', value: 'Value 1' },
{ key: 'Key 2', value: 'Value 2' },
{ key: 'Key 3', value: 'Value 3' },
{ key: 'Key 4', value: 'Value 4' },
{ key: 'Key 5', value: 'Value 5' },
],
t,
}),
).toBeFalsy();
expect(t).toBeCalledTimes(2);
expect(t).toHaveBeenNthCalledWith(1, 'editor.editorWidgets.keyvalue.key');
expect(t).toHaveBeenNthCalledWith(2, 'editor.editorWidgets.keyvalue.value');
t.mockReset();
expect(
validator({
field: mockMaxKeyValueField,
value: [
{ key: 'Key 1', value: 'Value 1' },
{ key: 'Key 2', value: 'Value 2' },
{ key: 'Key 3', value: 'Value 3' },
],
t,
}),
).toBeFalsy();
expect(t).toBeCalledTimes(2);
expect(t).toHaveBeenNthCalledWith(1, 'editor.editorWidgets.keyvalue.key');
expect(t).toHaveBeenNthCalledWith(2, 'editor.editorWidgets.keyvalue.value');
});
it('should throw error if number of values is greater than max', () => {
expect(
validator({
field: mockMaxKeyValueField,
value: [
{ key: 'Key 1', value: 'Value 1' },
{ key: 'Key 2', value: 'Value 2' },
{ key: 'Key 3', value: 'Value 3' },
{ key: 'Key 4', value: 'Value 4' },
{ key: 'Key 5', value: 'Value 5' },
{ key: 'Key 6', value: 'Value 6' },
],
t,
}),
).toEqual({
type: ValidationErrorTypes.RANGE,
message: 'mock translated text',
});
expect(t).toBeCalledTimes(3);
expect(t).toHaveBeenNthCalledWith(1, 'editor.editorWidgets.keyvalue.key');
expect(t).toHaveBeenNthCalledWith(2, 'editor.editorWidgets.keyvalue.value');
expect(t).toHaveBeenNthCalledWith(3, 'editor.editorControlPane.widget.rangeMax', {
fieldLabel: 'Key Value',
minCount: undefined,
maxCount: 5,
count: 6,
});
});
});
});

View File

@ -0,0 +1,23 @@
import { createEmptyPair } from './util';
import type { FieldStorageConverters, KeyValueField } from '@staticcms/core/interface';
import type { Pair } from './types';
const converters: FieldStorageConverters<Pair[], KeyValueField, Record<string, string>> = {
deserialize(storageValue) {
return storageValue
? Object.keys(storageValue).map(key => ({
key,
value: storageValue[key] ?? '',
}))
: [createEmptyPair()];
},
serialize(cmsValue) {
return cmsValue?.reduce((acc, pair) => {
acc[pair.key] = pair.value;
return acc;
}, {} as Record<string, string>);
},
};
export default converters;

View File

@ -0,0 +1,31 @@
import controlComponent from './KeyValueControl';
import previewComponent from './KeyValuePreview';
import converters from './converters';
import schema from './schema';
import validator from './validator';
import type { KeyValueField, WidgetParam } from '@staticcms/core/interface';
import type { Pair } from './types';
const KeyValueWidget = (): WidgetParam<Pair[], KeyValueField> => {
return {
name: 'keyvalue',
controlComponent,
previewComponent,
options: {
converters,
validator,
schema,
},
};
};
export {
controlComponent as KeyValueControl,
previewComponent as KeyValuePreview,
converters as keyValueConverters,
schema as keyValueSchema,
validator as keyValueValidator,
};
export default KeyValueWidget;

View File

@ -0,0 +1,10 @@
export default {
properties: {
default: { type: 'object' },
label_singular: { type: 'string' },
key_label: { type: 'string' },
value_label: { type: 'string' },
max: { type: 'number' },
min: { type: 'number' },
},
};

View File

@ -0,0 +1,4 @@
export interface Pair {
key: string;
value: string;
}

View File

@ -0,0 +1,4 @@
/* eslint-disable import/prefer-default-export */
import type { Pair } from './types';
export const createEmptyPair: () => Pair = () => ({ key: '', value: '' });

View File

@ -0,0 +1,73 @@
import ValidationErrorTypes from '@staticcms/core/constants/validationErrorTypes';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import { validateMinMax } from '@staticcms/core/lib/widgets/validations';
import type { FieldError, FieldValidationMethod, KeyValueField } from '@staticcms/core/interface';
import type { Pair } from './types';
const validator: FieldValidationMethod<Pair[], KeyValueField> = ({ field, value, t }) => {
const min = field.min;
const max = field.max;
const keyLabel = field.key_label ?? t('editor.editorWidgets.keyvalue.key');
const valueLabel = field.value_label ?? t('editor.editorWidgets.keyvalue.value');
let error: false | FieldError = false;
const finalValue = value ?? [];
if (finalValue.length === 0 && field.required) {
error = {
type: ValidationErrorTypes.PRESENCE,
message: t(`editor.editorControlPane.widget.required`, {
fieldLabel: field.label ?? field.name,
}),
};
}
const foundKeys: string[] = [];
if (!error) {
for (const pair of finalValue) {
if (isEmpty(pair.key)) {
error = {
type: ValidationErrorTypes.PRESENCE,
message: t(`editor.editorControlPane.widget.required`, {
fieldLabel: keyLabel,
}),
};
break;
} else {
if (foundKeys.includes(pair.key)) {
error = {
type: ValidationErrorTypes.CUSTOM,
message: t(`editor.editorWidgets.keyvalue.uniqueKeys`, {
keyLabel,
}),
};
break;
} else {
foundKeys.push(pair.key);
}
}
if (isEmpty(pair.value)) {
error = {
type: ValidationErrorTypes.PRESENCE,
message: t(`editor.editorControlPane.widget.required`, {
fieldLabel: valueLabel,
}),
};
break;
}
}
}
if (!error) {
error = validateMinMax(t, field.label ?? field.name, finalValue, min, max);
}
return error;
};
export default validator;

View File

@ -324,7 +324,7 @@ const ListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListField>> = pro
{internalValue.length > 0 ? (
<DndContext key="dnd-context" id="dnd-context" onDragEnd={handleDragEnd}>
<SortableContext items={keys}>
<div data-testid="list-widget-children">
<div data-testid="list-widget-children" className="overflow-hidden">
{internalValue.map((item, index) => {
const key = keys[index];
if (!key) {