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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1059 additions and 51 deletions

View File

@ -367,6 +367,34 @@ collections:
widget: image
media_library:
folder_support: true
- name: keyvalue
label: Key Value
file: _widgets/keyvalue.yml
description: Key Value widget
fields:
- name: keyvalue
label: Required
widget: keyvalue
- name: with_default
label: Required With Default
widget: keyvalue
default:
key1: value1
key2: value2
key3: value3
- name: with_min
label: Required With Min (2)
widget: keyvalue
min: 2
- name: with_max
label: Required With Max (4)
widget: keyvalue
max: 4
- name: with_min_and_max
label: Required With Min (2) and Max (4)
widget: keyvalue
min: 2
max: 4
- name: list
label: List
file: _widgets/list.yml

View File

@ -16,6 +16,7 @@ import type {
ListField,
RenderedField,
ValueOrNestedValue,
Widget,
WidgetPreviewComponent,
} from '@staticcms/core/interface';
import type { ReactFragment, ReactNode } from 'react';
@ -237,13 +238,23 @@ function getWidget(
return null;
}
const widget = resolveWidget(field.widget);
const widget = resolveWidget(field.widget) as Widget<ValueOrNestedValue, Field>;
const key = idx ? field.name + '_' + idx : field.name;
if (field.widget === 'hidden' || !widget.preview) {
return null;
}
const finalValue =
isJsxElement(value) || isReactFragment(value)
? value
: widget.converters.deserialize(
value && typeof value === 'object' && !Array.isArray(value) && field.name in value
? (value as Record<string, ValueOrNestedValue>)[field.name]
: value,
field,
);
/**
* Use an HOC to provide conditional updates for all previews.
*/
@ -254,16 +265,7 @@ function getWidget(
field={field as RenderedField}
config={config}
collection={collection}
value={
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
field.name in value &&
!isJsxElement(value) &&
!isReactFragment(value)
? (value as Record<string, unknown>)[field.name]
: value
}
value={finalValue}
entry={entry}
theme={theme}
/>

View File

@ -52,7 +52,7 @@ const EditorControl = ({
parentPath,
query,
t,
value,
value: storageValue,
forList = false,
listItemPath,
forSingleList = false,
@ -67,7 +67,7 @@ const EditorControl = ({
const id = useUUID();
const widgetName = field.widget;
const widget = resolveWidget(widgetName) as Widget<ValueOrNestedValue>;
const widget = resolveWidget(widgetName) as Widget<ValueOrNestedValue, Field>;
const theme = useAppSelector(selectTheme);
@ -77,7 +77,15 @@ const EditorControl = ({
[field.name, fieldName, parentPath],
);
const [dirty, setDirty] = useState(!isEmpty(widget.getValidValue(value, field as UnknownField)));
const finalStorageValue = useMemoCompare(storageValue, isEqual);
const [internalValue, setInternalValue] = useState(
widget.converters.deserialize(finalStorageValue, field),
);
const [dirty, setDirty] = useState(
!isEmpty(widget.getValidValue(internalValue, field as UnknownField)),
);
const fieldErrorsSelector = useMemo(
() => selectFieldErrors(path, i18n, isMeta),
@ -108,7 +116,7 @@ const EditorControl = ({
}
const validateValue = async () => {
const errors = await validate(field, value, widget, t);
const errors = await validate(field, internalValue, widget, t);
dispatch(changeDraftFieldValidation(path, errors, i18n, isMeta));
};
@ -122,7 +130,7 @@ const EditorControl = ({
path,
submitted,
t,
value,
internalValue,
widget,
disabled,
isMeta,
@ -139,7 +147,15 @@ const EditorControl = ({
oldDirty => oldDirty || !isEmpty(widget.getValidValue(value, field as UnknownField)),
);
changeDraftField({ path, field, value, i18n, isMeta });
setInternalValue(value);
changeDraftField({
path,
field,
value: widget.converters.serialize(value, field),
i18n,
isMeta,
});
},
[changeDraftField, field, i18n, isMeta, path, widget],
);
@ -148,11 +164,9 @@ const EditorControl = ({
const config = useMemo(() => configState.config, [configState.config]);
const finalValue = useMemoCompare(value, isEqual);
const [version, setVersion] = useState(0);
useEffect(() => {
if (isNotNullish(finalValue)) {
if (isNotNullish(internalValue)) {
return;
}
@ -174,7 +188,7 @@ const EditorControl = ({
);
setVersion(version => version + 1);
}
}, [field, finalValue, handleDebouncedChangeDraftField, widget]);
}, [field, internalValue, handleDebouncedChangeDraftField, widget]);
return useMemo(() => {
if (!collection || !entry || !config || field.widget === 'hidden') {
@ -201,7 +215,7 @@ const EditorControl = ({
path,
query,
t,
value: finalValue,
value: internalValue,
forList,
listItemPath,
forSingleList,
@ -231,7 +245,7 @@ const EditorControl = ({
handleDebouncedChangeDraftField,
path,
query,
finalValue,
internalValue,
forList,
listItemPath,
forSingleList,

View File

@ -16,6 +16,7 @@ import {
DateTimeWidget,
FileWidget,
ImageWidget,
KeyValueWidget,
ListWidget,
MapWidget,
MarkdownWidget,
@ -45,6 +46,7 @@ export default function addExtensions() {
DateTimeWidget(),
FileWidget(),
ImageWidget(),
KeyValueWidget(),
ListWidget(),
MapWidget(),
MarkdownWidget(),

View File

@ -164,6 +164,15 @@ export type FieldValidationMethod<T = unknown, F extends BaseField = UnknownFiel
props: FieldValidationMethodProps<T, F>,
) => false | FieldError | Promise<false | FieldError>;
export interface FieldStorageConverters<
T = unknown,
F extends BaseField = UnknownField,
S = ValueOrNestedValue,
> {
deserialize(storageValue: S | null | undefined, field: F): T | null | undefined;
serialize(cmsValue: T | null | undefined, field: F): S | null | undefined;
}
export interface EntryDraft {
entry: Entry;
fieldsErrors: FieldsErrors;
@ -400,17 +409,23 @@ export type FieldPreviewComponent<T = unknown, F extends BaseField = UnknownFiel
FieldPreviewProps<T, F>
>;
export interface WidgetOptions<T = unknown, F extends BaseField = UnknownField> {
export interface WidgetOptions<
T = unknown,
F extends BaseField = UnknownField,
S = ValueOrNestedValue,
> {
validator?: Widget<T, F>['validator'];
getValidValue?: Widget<T, F>['getValidValue'];
converters?: Widget<T, F, S>['converters'];
getDefaultValue?: Widget<T, F>['getDefaultValue'];
schema?: Widget<T, F>['schema'];
}
export interface Widget<T = unknown, F extends BaseField = UnknownField> {
export interface Widget<T = unknown, F extends BaseField = UnknownField, S = ValueOrNestedValue> {
control: ComponentType<WidgetControlProps<T, F>>;
preview?: WidgetPreviewComponent<T, F>;
validator: FieldValidationMethod<T, F>;
converters: FieldStorageConverters<T, F, S>;
getValidValue: FieldGetValidValueMethod<T, F>;
getDefaultValue?: FieldGetDefaultMethod<T, F>;
schema?: PropertiesSchema<unknown>;
@ -669,6 +684,18 @@ export interface ObjectField<EF extends BaseField = UnknownField> extends BaseFi
fields: Field<EF>[];
}
export interface KeyValueField extends BaseField {
widget: 'keyvalue';
default?: Record<string, string>;
label_singular?: string;
key_label?: string;
value_label?: string;
min?: number;
max?: number;
}
export interface ListField<EF extends BaseField = UnknownField> extends BaseField {
widget: 'list';
default?: ValueOrNestedValue[];

View File

@ -30,6 +30,7 @@ import type {
TemplatePreviewCardComponent,
TemplatePreviewComponent,
UnknownField,
ValueOrNestedValue,
Widget,
WidgetOptions,
WidgetParam,
@ -240,6 +241,10 @@ export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
{
schema,
validator = () => false,
converters = {
deserialize: (value: ValueOrNestedValue) => value as T | null | undefined,
serialize: (value: T | null | undefined) => value as ValueOrNestedValue,
},
getValidValue = (value: T | null | undefined) => value,
getDefaultValue,
}: WidgetOptions<T, F> = {},
@ -263,6 +268,7 @@ export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
control: newControl,
preview: preview as Widget['preview'],
validator: validator as Widget['validator'],
converters: converters as Widget['converters'],
getValidValue: getValidValue as Widget['getValidValue'],
getDefaultValue: getDefaultValue as Widget['getDefaultValue'],
schema,
@ -275,6 +281,10 @@ export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
previewComponent: preview,
options: {
validator = () => false,
converters = {
deserialize: (value: ValueOrNestedValue) => value as T | null | undefined,
serialize: (value: T | null | undefined) => value as ValueOrNestedValue,
},
getValidValue = (value: T | undefined | null) => value,
getDefaultValue,
schema,
@ -293,6 +303,7 @@ export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
control,
preview,
validator,
converters,
getValidValue,
getDefaultValue,
schema,

View File

@ -1,10 +1,10 @@
/* eslint-disable import/prefer-default-export */
import ValidationErrorTypes from '@staticcms/core/constants/validationErrorTypes';
export function validateMinMax(
export function validateMinMax<T = string | number>(
t: (key: string, options: unknown) => string,
fieldLabel: string,
value?: string | number | (string | number)[] | undefined | null,
value?: string | number | T[] | undefined | null,
min?: number,
max?: number,
) {

View File

@ -91,8 +91,8 @@ const en: LocalePhrasesRoot = {
max: '%{fieldLabel} must be %{maxValue} or less.',
rangeCount: '%{fieldLabel} must have between %{minCount} and %{maxCount} item(s).',
rangeCountExact: '%{fieldLabel} must have exactly %{count} item(s).',
rangeMin: '%{fieldLabel} must be at least %{minCount} item(s).',
rangeMax: '%{fieldLabel} must be %{maxCount} or less item(s).',
rangeMin: '%{fieldLabel} must have at least %{minCount} item(s).',
rangeMax: '%{fieldLabel} must have %{maxCount} or less item(s).',
invalidPath: `'%{path}' is not a valid path.`,
pathExists: `Path '%{path}' already exists.`,
invalidColor: `Color '%{color}' is invalid.`,
@ -201,6 +201,11 @@ const en: LocalePhrasesRoot = {
add: 'Add %{item}',
addType: 'Add %{item}',
},
keyvalue: {
key: 'Key',
value: 'Value',
uniqueKeys: '%{keyLabel} must be unique',
},
},
},
mediaLibrary: {

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) {

View File

@ -3,6 +3,7 @@ import type {
ColorField,
DateTimeField,
FileOrImageField,
KeyValueField,
MarkdownField,
NumberField,
RelationField,
@ -55,6 +56,12 @@ export const mockImageField: FileOrImageField = {
widget: 'image',
};
export const mockKeyValueField: KeyValueField = {
label: 'Key Value',
name: 'mock_key_value',
widget: 'keyvalue',
};
export const mockMarkdownField: MarkdownField = {
label: 'Body',
name: 'body',

View File

@ -3,19 +3,11 @@ import { createMockCollection } from './collections.mock';
import { createMockConfig } from './config.mock';
import { createMockEntry } from './entry.mock';
import type {
BaseField,
UnknownField,
ValueOrNestedValue,
WidgetControlProps,
} from '@staticcms/core';
import type { BaseField, UnknownField, WidgetControlProps } from '@staticcms/core';
jest.mock('@staticcms/core/backend');
export const createMockWidgetControlProps = <
T extends ValueOrNestedValue,
F extends BaseField = UnknownField,
>(
export const createMockWidgetControlProps = <T, F extends BaseField = UnknownField>(
options: Omit<
Partial<WidgetControlProps<T, F>>,
| 'field'

View File

@ -7,12 +7,7 @@ import { store } from '@staticcms/core/store';
import { createMockWidgetControlProps } from '@staticcms/test/data/widgets.mock';
import { renderWithProviders } from '@staticcms/test/test-utils';
import type {
BaseField,
UnknownField,
ValueOrNestedValue,
WidgetControlProps,
} from '@staticcms/core/interface';
import type { BaseField, UnknownField, WidgetControlProps } from '@staticcms/core/interface';
import type { FC } from 'react';
export interface WidgetControlHarnessOptions {
@ -20,10 +15,7 @@ export interface WidgetControlHarnessOptions {
withMediaLibrary?: boolean;
}
export const createWidgetControlHarness = <
T extends ValueOrNestedValue,
F extends BaseField = UnknownField,
>(
export const createWidgetControlHarness = <T, F extends BaseField = UnknownField>(
Component: FC<WidgetControlProps<T, F>>,
defaults: Omit<Partial<WidgetControlProps<T, F>>, 'field'> &
Pick<WidgetControlProps<T, F>, 'field'>,
@ -61,7 +53,7 @@ export const createWidgetControlHarness = <
const finalRerenderProps = {
...props,
...rerenderProps,
};
} as WidgetControlProps<T, F>;
result.rerender(
<>