diff --git a/packages/core/dev-test/config.yml b/packages/core/dev-test/config.yml index 5172e588..b1112c6b 100644 --- a/packages/core/dev-test/config.yml +++ b/packages/core/dev-test/config.yml @@ -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 diff --git a/packages/core/src/components/common/widget/widgetFor.tsx b/packages/core/src/components/common/widget/widgetFor.tsx index 47783de3..155a638c 100644 --- a/packages/core/src/components/common/widget/widgetFor.tsx +++ b/packages/core/src/components/common/widget/widgetFor.tsx @@ -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; 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)[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)[field.name] - : value - } + value={finalValue} entry={entry} theme={theme} /> diff --git a/packages/core/src/components/entry-editor/editor-control-pane/EditorControl.tsx b/packages/core/src/components/entry-editor/editor-control-pane/EditorControl.tsx index df7163b0..7a96821d 100644 --- a/packages/core/src/components/entry-editor/editor-control-pane/EditorControl.tsx +++ b/packages/core/src/components/entry-editor/editor-control-pane/EditorControl.tsx @@ -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; + const widget = resolveWidget(widgetName) as Widget; 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, diff --git a/packages/core/src/extensions.ts b/packages/core/src/extensions.ts index 0b5c3d82..d633092f 100644 --- a/packages/core/src/extensions.ts +++ b/packages/core/src/extensions.ts @@ -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(), diff --git a/packages/core/src/interface.ts b/packages/core/src/interface.ts index ac10ae33..00f81f86 100644 --- a/packages/core/src/interface.ts +++ b/packages/core/src/interface.ts @@ -164,6 +164,15 @@ export type FieldValidationMethod, ) => false | FieldError | Promise; +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 >; -export interface WidgetOptions { +export interface WidgetOptions< + T = unknown, + F extends BaseField = UnknownField, + S = ValueOrNestedValue, +> { validator?: Widget['validator']; getValidValue?: Widget['getValidValue']; + converters?: Widget['converters']; getDefaultValue?: Widget['getDefaultValue']; schema?: Widget['schema']; } -export interface Widget { +export interface Widget { control: ComponentType>; preview?: WidgetPreviewComponent; validator: FieldValidationMethod; + converters: FieldStorageConverters; getValidValue: FieldGetValidValueMethod; getDefaultValue?: FieldGetDefaultMethod; schema?: PropertiesSchema; @@ -669,6 +684,18 @@ export interface ObjectField extends BaseFi fields: Field[]; } +export interface KeyValueField extends BaseField { + widget: 'keyvalue'; + default?: Record; + + label_singular?: string; + key_label?: string; + value_label?: string; + + min?: number; + max?: number; +} + export interface ListField extends BaseField { widget: 'list'; default?: ValueOrNestedValue[]; diff --git a/packages/core/src/lib/registry.ts b/packages/core/src/lib/registry.ts index a48ea94d..17f24f47 100644 --- a/packages/core/src/lib/registry.ts +++ b/packages/core/src/lib/registry.ts @@ -30,6 +30,7 @@ import type { TemplatePreviewCardComponent, TemplatePreviewComponent, UnknownField, + ValueOrNestedValue, Widget, WidgetOptions, WidgetParam, @@ -240,6 +241,10 @@ export function registerWidget( { 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 = {}, @@ -263,6 +268,7 @@ export function registerWidget( 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( 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( control, preview, validator, + converters, getValidValue, getDefaultValue, schema, diff --git a/packages/core/src/lib/widgets/validations.ts b/packages/core/src/lib/widgets/validations.ts index 99bdab10..8247aefd 100644 --- a/packages/core/src/lib/widgets/validations.ts +++ b/packages/core/src/lib/widgets/validations.ts @@ -1,10 +1,10 @@ /* eslint-disable import/prefer-default-export */ import ValidationErrorTypes from '@staticcms/core/constants/validationErrorTypes'; -export function validateMinMax( +export function validateMinMax( 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, ) { diff --git a/packages/core/src/locales/en/index.ts b/packages/core/src/locales/en/index.ts index e30b8f73..0332cd76 100644 --- a/packages/core/src/locales/en/index.ts +++ b/packages/core/src/locales/en/index.ts @@ -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: { diff --git a/packages/core/src/widgets/index.ts b/packages/core/src/widgets/index.ts index 277541cc..7b60dc80 100644 --- a/packages/core/src/widgets/index.ts +++ b/packages/core/src/widgets/index.ts @@ -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'; diff --git a/packages/core/src/widgets/keyvalue/KeyValueControl.tsx b/packages/core/src/widgets/keyvalue/KeyValueControl.tsx new file mode 100644 index 00000000..2d8ea8f2 --- /dev/null +++ b/packages/core/src/widgets/keyvalue/KeyValueControl.tsx @@ -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> = ({ + 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(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) => { + 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 ( + +
+
{keyLabel}
+
{valueLabel}
+
+
+
+
+ {internalValue.map((pair, index) => ( +
+ + + + + +
+ ))} +
+ +
+
+ ); +}; + +export default StringControl; diff --git a/packages/core/src/widgets/keyvalue/KeyValuePreview.tsx b/packages/core/src/widgets/keyvalue/KeyValuePreview.tsx new file mode 100644 index 00000000..6ba61a39 --- /dev/null +++ b/packages/core/src/widgets/keyvalue/KeyValuePreview.tsx @@ -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> = ({ value }) => { + return ( +
    + {(value ?? []).map((pair, index) => ( +
  • + {pair.key ?? ''} - {pair.value ?? ''} +
  • + ))} +
+ ); +}; + +export default StringPreview; diff --git a/packages/core/src/widgets/keyvalue/__tests__/KeyValueControl.spec.ts b/packages/core/src/widgets/keyvalue/__tests__/KeyValueControl.spec.ts new file mode 100644 index 00000000..81033c27 --- /dev/null +++ b/packages/core/src/widgets/keyvalue/__tests__/KeyValueControl.spec.ts @@ -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(); + }); +}); diff --git a/packages/core/src/widgets/keyvalue/__tests__/converters.spec.ts b/packages/core/src/widgets/keyvalue/__tests__/converters.spec.ts new file mode 100644 index 00000000..9534468b --- /dev/null +++ b/packages/core/src/widgets/keyvalue/__tests__/converters.spec.ts @@ -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); + }); +}); diff --git a/packages/core/src/widgets/keyvalue/__tests__/validator.spec.ts b/packages/core/src/widgets/keyvalue/__tests__/validator.spec.ts new file mode 100644 index 00000000..51efe991 --- /dev/null +++ b/packages/core/src/widgets/keyvalue/__tests__/validator.spec.ts @@ -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, + }); + }); + }); +}); diff --git a/packages/core/src/widgets/keyvalue/converters.ts b/packages/core/src/widgets/keyvalue/converters.ts new file mode 100644 index 00000000..7d09b061 --- /dev/null +++ b/packages/core/src/widgets/keyvalue/converters.ts @@ -0,0 +1,23 @@ +import { createEmptyPair } from './util'; + +import type { FieldStorageConverters, KeyValueField } from '@staticcms/core/interface'; +import type { Pair } from './types'; + +const converters: FieldStorageConverters> = { + 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); + }, +}; + +export default converters; diff --git a/packages/core/src/widgets/keyvalue/index.ts b/packages/core/src/widgets/keyvalue/index.ts new file mode 100644 index 00000000..0124096b --- /dev/null +++ b/packages/core/src/widgets/keyvalue/index.ts @@ -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 => { + 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; diff --git a/packages/core/src/widgets/keyvalue/schema.ts b/packages/core/src/widgets/keyvalue/schema.ts new file mode 100644 index 00000000..96b6aec7 --- /dev/null +++ b/packages/core/src/widgets/keyvalue/schema.ts @@ -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' }, + }, +}; diff --git a/packages/core/src/widgets/keyvalue/types.ts b/packages/core/src/widgets/keyvalue/types.ts new file mode 100644 index 00000000..4ebb68d6 --- /dev/null +++ b/packages/core/src/widgets/keyvalue/types.ts @@ -0,0 +1,4 @@ +export interface Pair { + key: string; + value: string; +} diff --git a/packages/core/src/widgets/keyvalue/util.ts b/packages/core/src/widgets/keyvalue/util.ts new file mode 100644 index 00000000..ad92a28c --- /dev/null +++ b/packages/core/src/widgets/keyvalue/util.ts @@ -0,0 +1,4 @@ +/* eslint-disable import/prefer-default-export */ +import type { Pair } from './types'; + +export const createEmptyPair: () => Pair = () => ({ key: '', value: '' }); diff --git a/packages/core/src/widgets/keyvalue/validator.ts b/packages/core/src/widgets/keyvalue/validator.ts new file mode 100644 index 00000000..a86f8091 --- /dev/null +++ b/packages/core/src/widgets/keyvalue/validator.ts @@ -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 = ({ 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; diff --git a/packages/core/src/widgets/list/ListControl.tsx b/packages/core/src/widgets/list/ListControl.tsx index 32079540..848ae70c 100644 --- a/packages/core/src/widgets/list/ListControl.tsx +++ b/packages/core/src/widgets/list/ListControl.tsx @@ -324,7 +324,7 @@ const ListControl: FC> = pro {internalValue.length > 0 ? ( -
+
{internalValue.map((item, index) => { const key = keys[index]; if (!key) { diff --git a/packages/core/test/data/fields.mock.ts b/packages/core/test/data/fields.mock.ts index 209d7db6..ce21dcd6 100644 --- a/packages/core/test/data/fields.mock.ts +++ b/packages/core/test/data/fields.mock.ts @@ -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', diff --git a/packages/core/test/data/widgets.mock.ts b/packages/core/test/data/widgets.mock.ts index 94424f8f..fd6f42d9 100644 --- a/packages/core/test/data/widgets.mock.ts +++ b/packages/core/test/data/widgets.mock.ts @@ -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 = ( options: Omit< Partial>, | 'field' diff --git a/packages/core/test/harnesses/widget.harness.tsx b/packages/core/test/harnesses/widget.harness.tsx index fbe2dd5c..a6791e50 100644 --- a/packages/core/test/harnesses/widget.harness.tsx +++ b/packages/core/test/harnesses/widget.harness.tsx @@ -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 = ( Component: FC>, defaults: Omit>, 'field'> & Pick, 'field'>, @@ -61,7 +53,7 @@ export const createWidgetControlHarness = < const finalRerenderProps = { ...props, ...rerenderProps, - }; + } as WidgetControlProps; result.rerender( <>