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
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

View File

@ -655,6 +655,7 @@ export interface ListField<EF extends BaseField = UnknownField> extends BaseFiel
add_to_top?: boolean;
types?: ObjectField<EF>[];
type_key?: string;
delimiter?: string;
}
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 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<ObjectValue>;
field: ListField;
@ -125,6 +126,7 @@ const SortableItem: FC<SortableItemProps> = ({
export enum ListValueType {
MULTIPLE,
MIXED,
DELIMITED,
}
function getFieldsDefault(
@ -175,23 +177,25 @@ function getFieldsDefault(
}, initialValue as ObjectValue);
}
const ListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListField>> = ({
entry,
field,
fieldsErrors,
submitted,
disabled,
duplicate,
hidden,
locale,
path,
value,
i18n,
errors,
forSingleList,
onChange,
t,
}) => {
const ListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListField>> = 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<WidgetControlProps<ValueOrNestedValue[], ListField>> = ({
} else if ('types' in field) {
return ListValueType.MIXED;
} else {
return null;
return ListValueType.DELIMITED;
}
}, [field]);
@ -295,16 +299,16 @@ const ListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListField>> = ({
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 <DelimitedListControl {...props} />;
}
return (
<div key="list-widget">
<ListFieldWrapper

View File

@ -1,25 +1,58 @@
import React from 'react';
import type { ListField, ValueOrNestedValue, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
import { isNotNullish } from '@staticcms/core/lib/util/null.util';
const ListPreview: FC<WidgetPreviewProps<ValueOrNestedValue[], ListField>> = ({ 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 (
<div>
<label>
<strong>{field.name}:</strong>
</label>
<ul style={{ marginTop: 0 }}>
{value?.map(item => (
<li key={String(item)}>{String(item)}</li>
))}
</ul>
</div>
<ul style={{ marginTop: 0 }}>
{value.map((item, index) => (
<li key={index}>{renderNestedList(item)}</li>
))}
</ul>
);
}
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;

View File

@ -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'] });

View File

@ -75,7 +75,7 @@ interface ListItemProps
| 'value'
| 'i18n'
> {
valueType: ListValueType;
valueType: ListValueType.MIXED | ListValueType.MULTIPLE;
index: number;
id: string;
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 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<WidgetControlProps<string, StringOrTextField>> = ({
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<HTMLInputElement | null>(null);
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
setInternalValue(event.target.value);
onChange(event.target.value);
},
[onChange],
);
const handleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setInternalValue(event.target.value);
}, []);
useEffect(() => {
if (rawValue === debouncedInternalValue) {
return;
}
onChange(debouncedInternalValue);
}, [debouncedInternalValue, onChange, rawValue]);
return (
<Field

View File

@ -2,8 +2,8 @@
* @jest-environment jsdom
*/
import '@testing-library/jest-dom';
import { act, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { act } from '@testing-library/react';
import { mockStringField } from '@staticcms/test/data/fields.mock';
import { createWidgetControlHarness } from '@staticcms/test/harnesses/widget.harness';
@ -84,7 +84,12 @@ describe(StringControl.name, () => {
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 () => {

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 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<WidgetControlProps<string, StringOrTextField>> = ({
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<HTMLInputElement | null>(null);
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
setInternalValue(event.target.value);
onChange(event.target.value);
},
[onChange],
);
const handleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setInternalValue(event.target.value);
}, []);
useEffect(() => {
if (rawValue === debouncedInternalValue) {
return;
}
onChange(debouncedInternalValue);
}, [debouncedInternalValue, onChange, rawValue]);
return (
<Field

View File

@ -3,7 +3,7 @@
*/
import '@testing-library/jest-dom';
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 { createWidgetControlHarness } from '@staticcms/test/harnesses/widget.harness';
@ -84,7 +84,12 @@ describe(TextControl.name, () => {
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 () => {

View File

@ -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

View File

@ -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 | `[ <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 |
| 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_. <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> |
| 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 | `[ <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. 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_. <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`. |
| 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: [
</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)
<CodeTabs>