feat: key value widget (#865)
This commit is contained in:
parent
6bcf451a18
commit
dbf007a586
@ -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
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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,
|
||||
|
@ -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(),
|
||||
|
@ -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[];
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
) {
|
||||
|
@ -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: {
|
||||
|
@ -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';
|
||||
|
167
packages/core/src/widgets/keyvalue/KeyValueControl.tsx
Normal file
167
packages/core/src/widgets/keyvalue/KeyValueControl.tsx
Normal 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;
|
19
packages/core/src/widgets/keyvalue/KeyValuePreview.tsx
Normal file
19
packages/core/src/widgets/keyvalue/KeyValuePreview.tsx
Normal 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;
|
@ -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();
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
311
packages/core/src/widgets/keyvalue/__tests__/validator.spec.ts
Normal file
311
packages/core/src/widgets/keyvalue/__tests__/validator.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
23
packages/core/src/widgets/keyvalue/converters.ts
Normal file
23
packages/core/src/widgets/keyvalue/converters.ts
Normal 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;
|
31
packages/core/src/widgets/keyvalue/index.ts
Normal file
31
packages/core/src/widgets/keyvalue/index.ts
Normal 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;
|
10
packages/core/src/widgets/keyvalue/schema.ts
Normal file
10
packages/core/src/widgets/keyvalue/schema.ts
Normal 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' },
|
||||
},
|
||||
};
|
4
packages/core/src/widgets/keyvalue/types.ts
Normal file
4
packages/core/src/widgets/keyvalue/types.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface Pair {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
4
packages/core/src/widgets/keyvalue/util.ts
Normal file
4
packages/core/src/widgets/keyvalue/util.ts
Normal file
@ -0,0 +1,4 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import type { Pair } from './types';
|
||||
|
||||
export const createEmptyPair: () => Pair = () => ({ key: '', value: '' });
|
73
packages/core/src/widgets/keyvalue/validator.ts
Normal file
73
packages/core/src/widgets/keyvalue/validator.ts
Normal 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;
|
@ -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) {
|
||||
|
@ -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',
|
||||
|
@ -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'
|
||||
|
@ -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(
|
||||
<>
|
||||
|
Loading…
x
Reference in New Issue
Block a user