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
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 () => {