Skip to content
This repository has been archived by the owner on Sep 9, 2024. It is now read-only.

Commit

Permalink
feat: key value widget
Browse files Browse the repository at this point in the history
  • Loading branch information
KaneFreeman committed Sep 6, 2023
1 parent 6bcf451 commit 898e894
Show file tree
Hide file tree
Showing 24 changed files with 1,059 additions and 51 deletions.
28 changes: 28 additions & 0 deletions packages/core/dev-test/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 13 additions & 11 deletions packages/core/src/components/common/widget/widgetFor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
ListField,
RenderedField,
ValueOrNestedValue,
Widget,
WidgetPreviewComponent,
} from '@staticcms/core/interface';
import type { ReactFragment, ReactNode } from 'react';
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const EditorControl = ({
parentPath,
query,
t,
value,
value: storageValue,
forList = false,
listItemPath,
forSingleList = false,
Expand All @@ -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);

Expand All @@ -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),
Expand Down Expand Up @@ -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));
};

Expand All @@ -122,7 +130,7 @@ const EditorControl = ({
path,
submitted,
t,
value,
internalValue,
widget,
disabled,
isMeta,
Expand All @@ -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],
);
Expand All @@ -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;
}

Expand All @@ -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') {
Expand All @@ -201,7 +215,7 @@ const EditorControl = ({
path,
query,
t,
value: finalValue,
value: internalValue,
forList,
listItemPath,
forSingleList,
Expand Down Expand Up @@ -231,7 +245,7 @@ const EditorControl = ({
handleDebouncedChangeDraftField,
path,
query,
finalValue,
internalValue,
forList,
listItemPath,
forSingleList,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
DateTimeWidget,
FileWidget,
ImageWidget,
KeyValueWidget,
ListWidget,
MapWidget,
MarkdownWidget,
Expand Down Expand Up @@ -45,6 +46,7 @@ export default function addExtensions() {
DateTimeWidget(),
FileWidget(),
ImageWidget(),
KeyValueWidget(),
ListWidget(),
MapWidget(),
MarkdownWidget(),
Expand Down
31 changes: 29 additions & 2 deletions packages/core/src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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>;
Expand Down Expand Up @@ -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[];
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type {
TemplatePreviewCardComponent,
TemplatePreviewComponent,
UnknownField,
ValueOrNestedValue,
Widget,
WidgetOptions,
WidgetParam,
Expand Down Expand Up @@ -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> = {},
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -293,6 +303,7 @@ export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
control,
preview,
validator,
converters,
getValidValue,
getDefaultValue,
schema,
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/lib/widgets/validations.ts
Original file line number Diff line number Diff line change
@@ -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,
) {
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/locales/en/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
Expand Down Expand Up @@ -201,6 +201,11 @@ const en: LocalePhrasesRoot = {
add: 'Add %{item}',
addType: 'Add %{item}',
},
keyvalue: {
key: 'Key',
value: 'Value',
uniqueKeys: '%{keyLabel} must be unique',
},
},
},
mediaLibrary: {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading

0 comments on commit 898e894

Please sign in to comment.