feat: delimiter separated lists (#819)
This commit is contained in:
parent
66b0d7992d
commit
85db6b4f8d
@ -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
|
||||||
|
@ -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 {
|
||||||
|
69
packages/core/src/widgets/list/DelimitedListControl.tsx
Normal file
69
packages/core/src/widgets/list/DelimitedListControl.tsx
Normal 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;
|
@ -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
|
||||||
|
@ -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;
|
||||||
|
@ -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'] });
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
|
@ -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({
|
||||||
|
@ -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
|
||||||
|
@ -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({
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user