feat: delimiter separated lists (#819)

This commit is contained in:
Daniel Lautzenheiser 2023-05-24 14:06:41 -04:00 committed by GitHub
parent 66b0d7992d
commit 85db6b4f8d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 339 additions and 79 deletions

View File

@ -410,6 +410,16 @@ collections:
- label: Description - label: Description
name: description name: description
widget: text 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 - name: string_list
label: String List label: String List
widget: list widget: list

View File

@ -655,6 +655,7 @@ export interface ListField<EF extends BaseField = UnknownField> extends BaseFiel
add_to_top?: boolean; add_to_top?: boolean;
types?: ObjectField<EF>[]; types?: ObjectField<EF>[];
type_key?: string; type_key?: string;
delimiter?: string;
} }
export interface MapField extends BaseField { export interface MapField extends BaseField {

View File

@ -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<WidgetControlProps<ValueOrNestedValue[], ListField>> = ({
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<HTMLInputElement | null>(null);
const handleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
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 (
<Field
inputRef={ref}
label={label}
errors={errors}
hint={field.hint}
forSingleList={forSingleList}
cursor="text"
disabled={disabled}
>
<TextField
type="text"
inputRef={ref}
value={internalValue}
disabled={disabled}
onChange={handleChange}
/>
</Field>
);
};
export default DelimitedListControl;

View File

@ -13,6 +13,7 @@ import useHasChildErrors from '@staticcms/core/lib/hooks/useHasChildErrors';
import classNames from '@staticcms/core/lib/util/classNames.util'; import classNames from '@staticcms/core/lib/util/classNames.util';
import ListFieldWrapper from './components/ListFieldWrapper'; import ListFieldWrapper from './components/ListFieldWrapper';
import ListItem from './components/ListItem'; import ListItem from './components/ListItem';
import DelimitedListControl from './DelimitedListControl';
import { resolveFieldKeyType, TYPES_KEY } from './typedListHelpers'; import { resolveFieldKeyType, TYPES_KEY } from './typedListHelpers';
import type { DragEndEvent } from '@dnd-kit/core'; import type { DragEndEvent } from '@dnd-kit/core';
@ -40,7 +41,7 @@ interface SortableItemProps {
id: string; id: string;
item: ValueOrNestedValue; item: ValueOrNestedValue;
index: number; index: number;
valueType: ListValueType; valueType: ListValueType.MIXED | ListValueType.MULTIPLE;
handleRemove: (index: number, event: MouseEvent) => void; handleRemove: (index: number, event: MouseEvent) => void;
entry: Entry<ObjectValue>; entry: Entry<ObjectValue>;
field: ListField; field: ListField;
@ -125,6 +126,7 @@ const SortableItem: FC<SortableItemProps> = ({
export enum ListValueType { export enum ListValueType {
MULTIPLE, MULTIPLE,
MIXED, MIXED,
DELIMITED,
} }
function getFieldsDefault( function getFieldsDefault(
@ -175,7 +177,8 @@ function getFieldsDefault(
}, initialValue as ObjectValue); }, initialValue as ObjectValue);
} }
const ListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListField>> = ({ const ListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListField>> = props => {
const {
entry, entry,
field, field,
fieldsErrors, fieldsErrors,
@ -191,7 +194,8 @@ const ListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListField>> = ({
forSingleList, forSingleList,
onChange, onChange,
t, t,
}) => { } = props;
const internalValue = useMemo(() => value ?? [], [value]); const internalValue = useMemo(() => value ?? [], [value]);
const [keys, setKeys] = useState(Array.from({ length: internalValue.length }, () => uuid())); const [keys, setKeys] = useState(Array.from({ length: internalValue.length }, () => uuid()));
@ -201,7 +205,7 @@ const ListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListField>> = ({
} else if ('types' in field) { } else if ('types' in field) {
return ListValueType.MIXED; return ListValueType.MIXED;
} else { } else {
return null; return ListValueType.DELIMITED;
} }
}, [field]); }, [field]);
@ -295,16 +299,16 @@ const ListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListField>> = ({
const hasChildErrors = useHasChildErrors(path, fieldsErrors, i18n, false); const hasChildErrors = useHasChildErrors(path, fieldsErrors, i18n, false);
if (valueType === null) {
return null;
}
const label = field.label ?? field.name; const label = field.label ?? field.name;
const labelSingular = field.label_singular ? field.label_singular : field.label ?? field.name; const labelSingular = field.label_singular ? field.label_singular : field.label ?? field.name;
const listLabel = internalValue.length === 1 ? labelSingular : label; const listLabel = internalValue.length === 1 ? labelSingular : label;
const types = field[TYPES_KEY]; const types = field[TYPES_KEY];
if (valueType === ListValueType.DELIMITED) {
return <DelimitedListControl {...props} />;
}
return ( return (
<div key="list-widget"> <div key="list-widget">
<ListFieldWrapper <ListFieldWrapper

View File

@ -1,25 +1,58 @@
import React from 'react'; import React from 'react';
import type { ListField, ValueOrNestedValue, WidgetPreviewProps } from '@staticcms/core/interface'; import { isNotNullish } from '@staticcms/core/lib/util/null.util';
import type { FC } from 'react';
const ListPreview: FC<WidgetPreviewProps<ValueOrNestedValue[], ListField>> = ({ field, value }) => { import type { ListField, ValueOrNestedValue, WidgetPreviewProps } from '@staticcms/core/interface';
if (field.fields && field.fields.length === 1) { import type { FC, ReactNode } from 'react';
function renderNestedList(
value: ValueOrNestedValue[] | ValueOrNestedValue | null | undefined,
): ReactNode {
if (Array.isArray(value)) {
return ( return (
<div>
<label>
<strong>{field.name}:</strong>
</label>
<ul style={{ marginTop: 0 }}> <ul style={{ marginTop: 0 }}>
{value?.map(item => ( {value.map((item, index) => (
<li key={String(item)}>{String(item)}</li> <li key={index}>{renderNestedList(item)}</li>
))} ))}
</ul> </ul>
</div>
); );
} }
return <div>{field.renderedFields ?? null}</div>; if (isNotNullish(value) && typeof value === 'object') {
if (value instanceof Date) {
return value.toISOString();
}
return Object.keys(value).map((key, index) => (
<div key={index}>
<strong>{key}:</strong> {renderNestedList(value[key])}
</div>
));
}
return value;
}
const ListPreview: FC<WidgetPreviewProps<ValueOrNestedValue[], ListField>> = ({ field, value }) => {
return (
<div style={{ marginTop: '12px' }}>
<label>
<strong>{field.label ?? field.name}:</strong>
</label>
{(field.fields &&
field.fields.length === 1 &&
!['object', 'list'].includes(field.fields[0].widget)) ||
(!field.fields && !field.types) ? (
<ul style={{ marginTop: 0 }}>
{value?.map((item, index) => (
<li key={index}>{String(item)}</li>
))}
</ul>
) : (
renderNestedList(value)
)}
</div>
);
}; };
export default ListPreview; export default ListPreview;

View File

@ -15,6 +15,11 @@ import type { ListField, ValueOrNestedValue } from '@staticcms/core/interface';
jest.unmock('uuid'); jest.unmock('uuid');
const delimitedListField: ListField = {
widget: 'list',
name: 'delimited',
};
const singletonListField: ListField = { const singletonListField: ListField = {
widget: 'list', widget: 'list',
name: 'singleton', 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', () => { describe('singleton list', () => {
it('renders open by default', async () => { it('renders open by default', async () => {
await renderListControl({ value: ['Value 1', 'Value 2'] }); await renderListControl({ value: ['Value 1', 'Value 2'] });

View File

@ -75,7 +75,7 @@ interface ListItemProps
| 'value' | 'value'
| 'i18n' | 'i18n'
> { > {
valueType: ListValueType; valueType: ListValueType.MIXED | ListValueType.MULTIPLE;
index: number; index: number;
id: string; id: string;
listeners: SyntheticListenerMap | undefined; listeners: SyntheticListenerMap | undefined;

View File

@ -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 Field from '@staticcms/core/components/common/field/Field';
import TextField from '@staticcms/core/components/common/text-field/TextField'; 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 { StringOrTextField, WidgetControlProps } from '@staticcms/core/interface';
import type { ChangeEvent, FC } from 'react'; import type { ChangeEvent, FC } from 'react';
@ -17,20 +18,27 @@ const StringControl: FC<WidgetControlProps<string, StringOrTextField>> = ({
controlled, controlled,
onChange, onChange,
}) => { }) => {
const [internalRawValue, setInternalValue] = useState(value ?? ''); const rawValue = useMemo(() => value ?? '', [value]);
const [internalRawValue, setInternalValue] = useState(rawValue);
const internalValue = useMemo( const internalValue = useMemo(
() => (controlled || duplicate ? value ?? '' : internalRawValue), () => (controlled || duplicate ? rawValue : internalRawValue),
[controlled, duplicate, value, internalRawValue], [controlled, duplicate, rawValue, internalRawValue],
); );
const debouncedInternalValue = useDebounce(internalValue, 200);
const ref = useRef<HTMLInputElement | null>(null); const ref = useRef<HTMLInputElement | null>(null);
const handleChange = useCallback( const handleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
(event: ChangeEvent<HTMLInputElement>) => {
setInternalValue(event.target.value); setInternalValue(event.target.value);
onChange(event.target.value); }, []);
},
[onChange], useEffect(() => {
); if (rawValue === debouncedInternalValue) {
return;
}
onChange(debouncedInternalValue);
}, [debouncedInternalValue, onChange, rawValue]);
return ( return (
<Field <Field

View File

@ -2,8 +2,8 @@
* @jest-environment jsdom * @jest-environment jsdom
*/ */
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { act, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { act } from '@testing-library/react';
import { mockStringField } from '@staticcms/test/data/fields.mock'; import { mockStringField } from '@staticcms/test/data/fields.mock';
import { createWidgetControlHarness } from '@staticcms/test/harnesses/widget.harness'; import { createWidgetControlHarness } from '@staticcms/test/harnesses/widget.harness';
@ -84,8 +84,13 @@ describe(StringControl.name, () => {
await userEvent.type(input, 'I am some text'); await userEvent.type(input, 'I am some text');
}); });
expect(onChange).toHaveBeenCalledTimes(0);
await waitFor(() => {
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenLastCalledWith('I am some text'); expect(onChange).toHaveBeenLastCalledWith('I am some text');
}); });
});
it('should show error', async () => { it('should show error', async () => {
const { getByTestId } = renderControl({ const { getByTestId } = renderControl({

View File

@ -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 Field from '@staticcms/core/components/common/field/Field';
import TextArea from '@staticcms/core/components/common/text-field/TextArea'; 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 { StringOrTextField, WidgetControlProps } from '@staticcms/core/interface';
import type { ChangeEvent, FC } from 'react'; import type { ChangeEvent, FC } from 'react';
@ -17,20 +18,27 @@ const TextControl: FC<WidgetControlProps<string, StringOrTextField>> = ({
forSingleList, forSingleList,
onChange, onChange,
}) => { }) => {
const [internalRawValue, setInternalValue] = useState(value ?? ''); const rawValue = useMemo(() => value ?? '', [value]);
const [internalRawValue, setInternalValue] = useState(rawValue);
const internalValue = useMemo( const internalValue = useMemo(
() => (duplicate ? value ?? '' : internalRawValue), () => (duplicate ? rawValue : internalRawValue),
[internalRawValue, duplicate, value], [internalRawValue, duplicate, rawValue],
); );
const debouncedInternalValue = useDebounce(internalValue, 200);
const ref = useRef<HTMLInputElement | null>(null); const ref = useRef<HTMLInputElement | null>(null);
const handleChange = useCallback( const handleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
(event: ChangeEvent<HTMLInputElement>) => {
setInternalValue(event.target.value); setInternalValue(event.target.value);
onChange(event.target.value); }, []);
},
[onChange], useEffect(() => {
); if (rawValue === debouncedInternalValue) {
return;
}
onChange(debouncedInternalValue);
}, [debouncedInternalValue, onChange, rawValue]);
return ( return (
<Field <Field

View File

@ -3,7 +3,7 @@
*/ */
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { act } from '@testing-library/react'; import { act, waitFor } from '@testing-library/react';
import { mockTextField } from '@staticcms/test/data/fields.mock'; import { mockTextField } from '@staticcms/test/data/fields.mock';
import { createWidgetControlHarness } from '@staticcms/test/harnesses/widget.harness'; import { createWidgetControlHarness } from '@staticcms/test/harnesses/widget.harness';
@ -84,8 +84,13 @@ describe(TextControl.name, () => {
await userEvent.type(input, 'I am some text'); await userEvent.type(input, 'I am some text');
}); });
expect(onChange).toHaveBeenCalledTimes(0);
await waitFor(() => {
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenLastCalledWith('I am some text'); expect(onChange).toHaveBeenLastCalledWith('I am some text');
}); });
});
it('should show error', async () => { it('should show error', async () => {
const { getByTestId } = renderControl({ const { getByTestId } = renderControl({

View File

@ -82,6 +82,7 @@ collections:
create: true create: true
editor: editor:
frame: false frame: false
size: half
fields: fields:
- label: Question - label: Question
name: title name: title
@ -409,6 +410,16 @@ collections:
- label: Description - label: Description
name: description name: description
widget: text 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 - name: string_list
label: String List label: String List
widget: list widget: list

View File

@ -5,7 +5,7 @@ weight: 17
--- ---
- **Name:** `list` - **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 - **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. 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.
@ -15,18 +15,19 @@ 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). For common options, see [Common widget options](/docs/widgets#common-widget-options).
| Name | Type | Default | Description | | Name | Type | Default | Description |
| -------------- | ---------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------- | ---------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| default | string | `[ <default from the child fields> ]` | _Optional_. The default values for fields. Also accepts an array of items | | default | string | `[ <default from the child fields> ]` | _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 | | 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. | | 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._ | | 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 | | 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 | | 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 | | min | number | | _Optional_. Minimum number of items in the list |
| max | number | | _Optional_. Maximum number of items in the list | | max | number | | _Optional_. Maximum number of items in the list |
| add_to_top | boolean | `false` | _Optional_. <ul><li>`true` - New entries will be added to the top of the list</li><li>`false` - New entries will be added to the bottom of the list</li></ul> | | add_to_top | boolean | `false` | _Optional_. <ul><li>`true` - New entries will be added to the top of the list</li><li>`false` - New entries will be added to the bottom of the list</li></ul>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`. | | 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 | | 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 ## Examples
@ -406,6 +407,32 @@ fields: [
</CodeTabs> </CodeTabs>
### Delimiter Separated List
<CodeTabs>
```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'
]
```
</CodeTabs>
### Singleton List (List of Strings) ### Singleton List (List of Strings)
<CodeTabs> <CodeTabs>