From 85db6b4f8df57a82b53bdc9e46c7a46a16309814 Mon Sep 17 00:00:00 2001 From: Daniel Lautzenheiser Date: Wed, 24 May 2023 14:06:41 -0400 Subject: [PATCH] feat: delimiter separated lists (#819) --- packages/core/dev-test/config.yml | 10 +++ packages/core/src/interface.ts | 1 + .../src/widgets/list/DelimitedListControl.tsx | 69 ++++++++++++++++ .../core/src/widgets/list/ListControl.tsx | 50 ++++++------ .../core/src/widgets/list/ListPreview.tsx | 63 +++++++++++---- .../list/__tests__/ListControl.spec.tsx | 79 +++++++++++++++++++ .../src/widgets/list/components/ListItem.tsx | 2 +- .../core/src/widgets/string/StringControl.tsx | 30 ++++--- .../string/__tests__/StringControl.spec.ts | 9 ++- .../core/src/widgets/text/TextControl.tsx | 30 ++++--- .../text/__tests__/TextControl.spec.ts | 9 ++- packages/demo/public/config.yml | 11 +++ packages/docs/content/docs/widget-list.mdx | 55 +++++++++---- 13 files changed, 339 insertions(+), 79 deletions(-) create mode 100644 packages/core/src/widgets/list/DelimitedListControl.tsx diff --git a/packages/core/dev-test/config.yml b/packages/core/dev-test/config.yml index 4f5a0a85..7a04fe64 100644 --- a/packages/core/dev-test/config.yml +++ b/packages/core/dev-test/config.yml @@ -410,6 +410,16 @@ collections: - label: Description name: description widget: text + - name: comma_separated_list + label: Comma Separated List + widget: list + - name: delimiter_separated_list + label: Custom Delimiter (Semicolon) Separated List + widget: list + delimiter: ';' + default: + - 'tag-1' + - 'tag-2' - name: string_list label: String List widget: list diff --git a/packages/core/src/interface.ts b/packages/core/src/interface.ts index 7879640e..88587601 100644 --- a/packages/core/src/interface.ts +++ b/packages/core/src/interface.ts @@ -655,6 +655,7 @@ export interface ListField extends BaseFiel add_to_top?: boolean; types?: ObjectField[]; type_key?: string; + delimiter?: string; } export interface MapField extends BaseField { diff --git a/packages/core/src/widgets/list/DelimitedListControl.tsx b/packages/core/src/widgets/list/DelimitedListControl.tsx new file mode 100644 index 00000000..f8ab2406 --- /dev/null +++ b/packages/core/src/widgets/list/DelimitedListControl.tsx @@ -0,0 +1,69 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import Field from '@staticcms/core/components/common/field/Field'; +import TextField from '@staticcms/core/components/common/text-field/TextField'; +import useDebounce from '@staticcms/core/lib/hooks/useDebounce'; + +import type { ListField, ValueOrNestedValue, WidgetControlProps } from '@staticcms/core/interface'; +import type { ChangeEvent, FC } from 'react'; + +const DelimitedListControl: FC> = ({ + field, + label, + disabled, + duplicate, + value, + errors, + forSingleList, + controlled, + onChange, +}) => { + const delimiter = useMemo(() => field.delimiter ?? ',', [field.delimiter]); + + const rawValue = useMemo(() => (value ?? []).join(delimiter), [delimiter, value]); + + const [internalRawValue, setInternalValue] = useState(rawValue); + const internalValue = useMemo( + () => (controlled || duplicate ? rawValue : internalRawValue), + [controlled, duplicate, rawValue, internalRawValue], + ); + const debouncedInternalValue = useDebounce(internalValue, 200); + + const ref = useRef(null); + + const handleChange = useCallback((event: ChangeEvent) => { + const newRawValue = event.target.value; + setInternalValue(newRawValue); + }, []); + + useEffect(() => { + if (rawValue === debouncedInternalValue) { + return; + } + + const newValue = debouncedInternalValue.split(delimiter).map(v => v.trim()); + onChange(newValue); + }, [debouncedInternalValue, delimiter, onChange, rawValue]); + + return ( + + + + ); +}; + +export default DelimitedListControl; diff --git a/packages/core/src/widgets/list/ListControl.tsx b/packages/core/src/widgets/list/ListControl.tsx index 90cc0fe3..6fc31c32 100644 --- a/packages/core/src/widgets/list/ListControl.tsx +++ b/packages/core/src/widgets/list/ListControl.tsx @@ -13,6 +13,7 @@ import useHasChildErrors from '@staticcms/core/lib/hooks/useHasChildErrors'; import classNames from '@staticcms/core/lib/util/classNames.util'; import ListFieldWrapper from './components/ListFieldWrapper'; import ListItem from './components/ListItem'; +import DelimitedListControl from './DelimitedListControl'; import { resolveFieldKeyType, TYPES_KEY } from './typedListHelpers'; import type { DragEndEvent } from '@dnd-kit/core'; @@ -40,7 +41,7 @@ interface SortableItemProps { id: string; item: ValueOrNestedValue; index: number; - valueType: ListValueType; + valueType: ListValueType.MIXED | ListValueType.MULTIPLE; handleRemove: (index: number, event: MouseEvent) => void; entry: Entry; field: ListField; @@ -125,6 +126,7 @@ const SortableItem: FC = ({ export enum ListValueType { MULTIPLE, MIXED, + DELIMITED, } function getFieldsDefault( @@ -175,23 +177,25 @@ function getFieldsDefault( }, initialValue as ObjectValue); } -const ListControl: FC> = ({ - entry, - field, - fieldsErrors, - submitted, - disabled, - duplicate, - hidden, - locale, - path, - value, - i18n, - errors, - forSingleList, - onChange, - t, -}) => { +const ListControl: FC> = props => { + const { + entry, + field, + fieldsErrors, + submitted, + disabled, + duplicate, + hidden, + locale, + path, + value, + i18n, + errors, + forSingleList, + onChange, + t, + } = props; + const internalValue = useMemo(() => value ?? [], [value]); const [keys, setKeys] = useState(Array.from({ length: internalValue.length }, () => uuid())); @@ -201,7 +205,7 @@ const ListControl: FC> = ({ } else if ('types' in field) { return ListValueType.MIXED; } else { - return null; + return ListValueType.DELIMITED; } }, [field]); @@ -295,16 +299,16 @@ const ListControl: FC> = ({ const hasChildErrors = useHasChildErrors(path, fieldsErrors, i18n, false); - if (valueType === null) { - return null; - } - const label = field.label ?? field.name; const labelSingular = field.label_singular ? field.label_singular : field.label ?? field.name; const listLabel = internalValue.length === 1 ? labelSingular : label; const types = field[TYPES_KEY]; + if (valueType === ListValueType.DELIMITED) { + return ; + } + return (
> = ({ field, value }) => { - if (field.fields && field.fields.length === 1) { +import type { ListField, ValueOrNestedValue, WidgetPreviewProps } from '@staticcms/core/interface'; +import type { FC, ReactNode } from 'react'; + +function renderNestedList( + value: ValueOrNestedValue[] | ValueOrNestedValue | null | undefined, +): ReactNode { + if (Array.isArray(value)) { return ( -
- -
    - {value?.map(item => ( -
  • {String(item)}
  • - ))} -
-
+
    + {value.map((item, index) => ( +
  • {renderNestedList(item)}
  • + ))} +
); } - return
{field.renderedFields ?? null}
; + if (isNotNullish(value) && typeof value === 'object') { + if (value instanceof Date) { + return value.toISOString(); + } + + return Object.keys(value).map((key, index) => ( +
+ {key}: {renderNestedList(value[key])} +
+ )); + } + + return value; +} + +const ListPreview: FC> = ({ field, value }) => { + return ( +
+ + {(field.fields && + field.fields.length === 1 && + !['object', 'list'].includes(field.fields[0].widget)) || + (!field.fields && !field.types) ? ( +
    + {value?.map((item, index) => ( +
  • {String(item)}
  • + ))} +
+ ) : ( + renderNestedList(value) + )} +
+ ); }; export default ListPreview; diff --git a/packages/core/src/widgets/list/__tests__/ListControl.spec.tsx b/packages/core/src/widgets/list/__tests__/ListControl.spec.tsx index 68b72243..ec9c5424 100644 --- a/packages/core/src/widgets/list/__tests__/ListControl.spec.tsx +++ b/packages/core/src/widgets/list/__tests__/ListControl.spec.tsx @@ -15,6 +15,11 @@ import type { ListField, ValueOrNestedValue } from '@staticcms/core/interface'; jest.unmock('uuid'); +const delimitedListField: ListField = { + widget: 'list', + name: 'delimited', +}; + const singletonListField: ListField = { widget: 'list', name: 'singleton', @@ -441,6 +446,80 @@ describe(ListControl.name, () => { }); }); + describe('delimited list', () => { + it('renders text input instead of normal list setup', async () => { + const { queryByTestId, getByTestId } = renderControl({ + field: delimitedListField, + value: ['Value 1', 'Value 2'], + }); + + expect(queryByTestId('list-widget-children')).not.toBeInTheDocument(); + expect(queryByTestId('list-expand-button')).not.toBeInTheDocument(); + expect(queryByTestId('list-add')).not.toBeInTheDocument(); + + const itemOne = queryByTestId('object-control-0'); + expect(itemOne).not.toBeInTheDocument(); + const itemTwo = queryByTestId('object-control-1'); + expect(itemTwo).not.toBeInTheDocument(); + + const inputWrapper = getByTestId('text-input'); + const input = inputWrapper.getElementsByTagName('input')[0]; + expect(input).toBeVisible(); + expect(input).toHaveValue('Value 1,Value 2'); + }); + + it('renders empty', async () => { + const { getByTestId } = await renderControl({ + field: delimitedListField, + value: [], + }); + + const inputWrapper = getByTestId('text-input'); + const input = inputWrapper.getElementsByTagName('input')[0]; + expect(input).toBeVisible(); + expect(input).toHaveValue(''); + }); + + it('ignores collapsed value', async () => { + const { getByTestId } = await renderControl({ + field: { ...delimitedListField, collapsed: true }, + value: ['Value 1', 'Value 2'], + }); + + const inputWrapper = getByTestId('text-input'); + const input = inputWrapper.getElementsByTagName('input')[0]; + expect(input).toBeVisible(); + }); + + it('trims input before outputting new value', async () => { + const { + getByTestId, + props: { onChange }, + } = await renderControl({ field: delimitedListField, value: ['Value 1', 'Value 2'] }); + + const inputWrapper = getByTestId('text-input'); + const input = inputWrapper.getElementsByTagName('input')[0]; + + await act(async () => { + await userEvent.type(input, ', I am a new value with extra whitespace! '); + }); + + expect(onChange).toHaveBeenCalledTimes(0); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith([ + 'Value 1', + 'Value 2', + 'I am a new value with extra whitespace!', + ]); + expect(input).toHaveValue( + 'Value 1,Value 2, I am a new value with extra whitespace! ', + ); + }); + }); + }); + describe('singleton list', () => { it('renders open by default', async () => { await renderListControl({ value: ['Value 1', 'Value 2'] }); diff --git a/packages/core/src/widgets/list/components/ListItem.tsx b/packages/core/src/widgets/list/components/ListItem.tsx index 7887caa1..2c6d8174 100644 --- a/packages/core/src/widgets/list/components/ListItem.tsx +++ b/packages/core/src/widgets/list/components/ListItem.tsx @@ -75,7 +75,7 @@ interface ListItemProps | 'value' | 'i18n' > { - valueType: ListValueType; + valueType: ListValueType.MIXED | ListValueType.MULTIPLE; index: number; id: string; listeners: SyntheticListenerMap | undefined; diff --git a/packages/core/src/widgets/string/StringControl.tsx b/packages/core/src/widgets/string/StringControl.tsx index 8b818ade..98e73572 100644 --- a/packages/core/src/widgets/string/StringControl.tsx +++ b/packages/core/src/widgets/string/StringControl.tsx @@ -1,7 +1,8 @@ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Field from '@staticcms/core/components/common/field/Field'; import TextField from '@staticcms/core/components/common/text-field/TextField'; +import useDebounce from '@staticcms/core/lib/hooks/useDebounce'; import type { StringOrTextField, WidgetControlProps } from '@staticcms/core/interface'; import type { ChangeEvent, FC } from 'react'; @@ -17,20 +18,27 @@ const StringControl: FC> = ({ controlled, onChange, }) => { - const [internalRawValue, setInternalValue] = useState(value ?? ''); + const rawValue = useMemo(() => value ?? '', [value]); + const [internalRawValue, setInternalValue] = useState(rawValue); const internalValue = useMemo( - () => (controlled || duplicate ? value ?? '' : internalRawValue), - [controlled, duplicate, value, internalRawValue], + () => (controlled || duplicate ? rawValue : internalRawValue), + [controlled, duplicate, rawValue, internalRawValue], ); + const debouncedInternalValue = useDebounce(internalValue, 200); + const ref = useRef(null); - const handleChange = useCallback( - (event: ChangeEvent) => { - setInternalValue(event.target.value); - onChange(event.target.value); - }, - [onChange], - ); + const handleChange = useCallback((event: ChangeEvent) => { + setInternalValue(event.target.value); + }, []); + + useEffect(() => { + if (rawValue === debouncedInternalValue) { + return; + } + + onChange(debouncedInternalValue); + }, [debouncedInternalValue, onChange, rawValue]); return ( { await userEvent.type(input, 'I am some text'); }); - expect(onChange).toHaveBeenLastCalledWith('I am some text'); + expect(onChange).toHaveBeenCalledTimes(0); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenLastCalledWith('I am some text'); + }); }); it('should show error', async () => { diff --git a/packages/core/src/widgets/text/TextControl.tsx b/packages/core/src/widgets/text/TextControl.tsx index 3dce4f29..1a77d8d3 100644 --- a/packages/core/src/widgets/text/TextControl.tsx +++ b/packages/core/src/widgets/text/TextControl.tsx @@ -1,7 +1,8 @@ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Field from '@staticcms/core/components/common/field/Field'; import TextArea from '@staticcms/core/components/common/text-field/TextArea'; +import useDebounce from '@staticcms/core/lib/hooks/useDebounce'; import type { StringOrTextField, WidgetControlProps } from '@staticcms/core/interface'; import type { ChangeEvent, FC } from 'react'; @@ -17,20 +18,27 @@ const TextControl: FC> = ({ forSingleList, onChange, }) => { - const [internalRawValue, setInternalValue] = useState(value ?? ''); + const rawValue = useMemo(() => value ?? '', [value]); + const [internalRawValue, setInternalValue] = useState(rawValue); const internalValue = useMemo( - () => (duplicate ? value ?? '' : internalRawValue), - [internalRawValue, duplicate, value], + () => (duplicate ? rawValue : internalRawValue), + [internalRawValue, duplicate, rawValue], ); + const debouncedInternalValue = useDebounce(internalValue, 200); + const ref = useRef(null); - const handleChange = useCallback( - (event: ChangeEvent) => { - setInternalValue(event.target.value); - onChange(event.target.value); - }, - [onChange], - ); + const handleChange = useCallback((event: ChangeEvent) => { + setInternalValue(event.target.value); + }, []); + + useEffect(() => { + if (rawValue === debouncedInternalValue) { + return; + } + + onChange(debouncedInternalValue); + }, [debouncedInternalValue, onChange, rawValue]); return ( { await userEvent.type(input, 'I am some text'); }); - expect(onChange).toHaveBeenLastCalledWith('I am some text'); + expect(onChange).toHaveBeenCalledTimes(0); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenLastCalledWith('I am some text'); + }); }); it('should show error', async () => { diff --git a/packages/demo/public/config.yml b/packages/demo/public/config.yml index ee5e9312..95b9f182 100644 --- a/packages/demo/public/config.yml +++ b/packages/demo/public/config.yml @@ -82,6 +82,7 @@ collections: create: true editor: frame: false + size: half fields: - label: Question name: title @@ -409,6 +410,16 @@ collections: - label: Description name: description widget: text + - name: comma_separated_list + label: Comma Separated List + widget: list + - name: delimiter_separated_list + label: Custom Delimiter (Semicolon) Separated List + widget: list + delimiter: ';' + default: + - 'tag-1' + - 'tag-2' - name: string_list label: String List widget: list diff --git a/packages/docs/content/docs/widget-list.mdx b/packages/docs/content/docs/widget-list.mdx index 825f73ae..d35fb226 100644 --- a/packages/docs/content/docs/widget-list.mdx +++ b/packages/docs/content/docs/widget-list.mdx @@ -5,7 +5,7 @@ weight: 17 --- - **Name:** `list` -- **UI:** The list widget contains a repeatable child widget, with controls for adding, deleting, and re-ordering the repeated widgets. +- **UI:** The list widget contains a repeatable child widget, with controls for adding, deleting, and re-ordering the repeated widgets. If no 'fields' or 'types' are provided, the list widget defaults to a text input for entering comma-separated values. - **Data type:** List of widget values The list widget allows you to create a repeatable item in the UI which saves as a list of widget values. You can choose any widget as a child of a list widget—even other lists. @@ -14,19 +14,20 @@ The list widget allows you to create a repeatable item in the UI which saves as For common options, see [Common widget options](/docs/widgets#common-widget-options). -| Name | Type | Default | Description | -| -------------- | ---------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| default | string | `[ ]` | _Optional_. The default values for fields. Also accepts an array of items | -| allow_add | boolean | `true` | _Optional_. `false` - Hides the button to add additional items | -| collapsed | boolean | `true` | _Optional_. `true` - The list and entries collapse by default. | -| summary | string | | _Optional_. The label displayed on collapsed entries. _Ignored for single field lists._ | -| label_singular | string | `label` | _Optional_. The text to show on the add button | -| fields | list of widgets | [] | _Optional_. A nested list of multiple widget fields to be included in each repeatable iteration | -| min | number | | _Optional_. Minimum number of items in the list | -| max | number | | _Optional_. Maximum number of items in the list | -| add_to_top | boolean | `false` | _Optional_.
  • `true` - New entries will be added to the top of the list
  • `false` - New entries will be added to the bottom of the list
| -| types | list of object widgets | | _Optional_. A nested list of object widgets representing the available types for items in the list. Takes priority over `fields`. | -| type_key | string | `'type'` | _Optional_. The name of the individual field that will be added to every item in list representing the name of the object widget that item belongs to. Ignored if `types` is not defined | +| Name | Type | Default | Description | +| -------------- | ---------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| default | string | `[ ]` | _Optional_. The default values for fields. Also accepts an array of items | +| allow_add | boolean | `true` | _Optional_. `false` - Hides the button to add additional items. Ignored if both `fields` and `types` are not defined | +| collapsed | boolean | `true` | _Optional_. `true` - The list and entries collapse by default. Ignored if both `fields` and `types` are not defined | +| summary | string | | _Optional_. The label displayed on collapsed entries. _Ignored for single field lists._ | +| label_singular | string | `label` | _Optional_. The text to show on the add button | +| fields | list of widgets | [] | _Optional_. A nested list of multiple widget fields to be included in each repeatable iteration | +| min | number | | _Optional_. Minimum number of items in the list | +| max | number | | _Optional_. Maximum number of items in the list | +| add_to_top | boolean | `false` | _Optional_.
  • `true` - New entries will be added to the top of the list
  • `false` - New entries will be added to the bottom of the list
Ignored if both `fields` and `types` are not defined | +| types | list of object widgets | | _Optional_. A nested list of object widgets representing the available types for items in the list. Takes priority over `fields`. | +| type_key | string | `'type'` | _Optional_. The name of the individual field that will be added to every item in list representing the name of the object widget that item belongs to. Ignored if `types` is not defined | +| delimiter | string | `','` | _Optional_. The delimiter to use when entering a delimiter separated list. Ignored if `fields` or `types` are defined | ## Examples @@ -406,6 +407,32 @@ fields: [ +### Delimiter Separated List + + +```yaml +name: tags +label: Tags +widget: list +delimiter: ';' # Default: ',' +default: + - 'tag-1' + - 'tag-2' +``` + +```js +name: 'tags', +label: 'Tags', +widget: 'list', +delimiter: ';', // Default: ',' +default: [ + 'tag-1', + 'tag-2' +] +``` + + + ### Singleton List (List of Strings)