feat: singleton array list widget (#336)

This commit is contained in:
Daniel Lautzenheiser
2023-01-12 14:15:41 -05:00
committed by GitHub
parent a60d53b4ec
commit c5e94ed16d
64 changed files with 1353 additions and 575 deletions

View File

@ -60,7 +60,7 @@ const StyledSortableList = styled(
interface SortableItemProps {
id: string;
item: ObjectValue;
item: ValueOrNestedValue;
index: number;
valueType: ListValueType;
handleRemove: (index: number, event: MouseEvent) => void;
@ -106,7 +106,7 @@ const SortableItem: FC<SortableItemProps> = ({
}
return (
<div ref={setNodeRef} style={style} {...attributes}>
<div ref={setNodeRef} data-testid={`object-control-${index}`} style={style} {...attributes}>
<ListItem
index={index}
id={id}
@ -122,7 +122,7 @@ const SortableItem: FC<SortableItemProps> = ({
isFieldHidden={isFieldHidden}
locale={locale}
path={path}
value={item as Record<string, ObjectValue>}
value={item}
i18n={i18n}
listeners={listeners}
/>
@ -135,7 +135,28 @@ export enum ListValueType {
MIXED,
}
function getFieldsDefault(fields: Field[], initialValue: ObjectValue = {}): ObjectValue {
function getFieldsDefault(
fields: Field[],
initialValue: ValueOrNestedValue = {},
): ValueOrNestedValue {
if (fields.length === 1) {
if ('default' in fields[0] && fields[0].default) {
return fields[0].default;
}
switch (fields[0].widget) {
case 'string':
case 'text':
return '';
case 'boolean':
return false;
case 'number':
return 0;
}
return null;
}
return fields.reduce((acc, item) => {
const subfields = 'fields' in item && item.fields;
const name = item.name;
@ -159,10 +180,10 @@ function getFieldsDefault(fields: Field[], initialValue: ObjectValue = {}): Obje
}
return acc;
}, initialValue);
}, initialValue as ObjectValue);
}
const ListControl: FC<WidgetControlProps<ObjectValue[], ListField>> = ({
const ListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListField>> = ({
entry,
field,
fieldsErrors,
@ -196,7 +217,7 @@ const ListControl: FC<WidgetControlProps<ObjectValue[], ListField>> = ({
}, []);
const mixedDefault = useCallback(
(typeKey: string, type: string): ObjectValue => {
(typeKey: string, type: string): ValueOrNestedValue => {
const selectedType = 'types' in field && field.types?.find(f => f.name === type);
if (!selectedType) {
return {};
@ -208,7 +229,7 @@ const ListControl: FC<WidgetControlProps<ObjectValue[], ListField>> = ({
);
const addItem = useCallback(
(parsedValue: ObjectValue) => {
(parsedValue: ValueOrNestedValue) => {
const addToTop = field.add_to_top ?? false;
const newKeys = [...keys];
@ -221,7 +242,7 @@ const ListControl: FC<WidgetControlProps<ObjectValue[], ListField>> = ({
newValue.push(parsedValue);
}
setKeys(newKeys);
onChange(newValue);
onChange(newValue as string[] | ObjectValue[]);
setCollapsed(false);
},
[field.add_to_top, onChange, internalValue, keys],
@ -230,7 +251,8 @@ const ListControl: FC<WidgetControlProps<ObjectValue[], ListField>> = ({
const handleAdd = useCallback(
(e: MouseEvent) => {
e.preventDefault();
addItem('fields' in field && field.fields ? multipleDefault(field.fields) : {});
const parsedValue = multipleDefault(field.fields ?? []);
addItem(parsedValue);
},
[addItem, field, multipleDefault],
);
@ -254,7 +276,7 @@ const ListControl: FC<WidgetControlProps<ObjectValue[], ListField>> = ({
newValue.splice(index, 1);
setKeys(newKeys);
onChange(newValue);
onChange(newValue as string[] | ObjectValue[]);
},
[onChange, internalValue, keys],
);
@ -278,7 +300,11 @@ const ListControl: FC<WidgetControlProps<ObjectValue[], ListField>> = ({
// Update value
setKeys(arrayMoveImmutable(keys, oldIndex, newIndex));
onChange(arrayMoveImmutable(internalValue, oldIndex, newIndex));
onChange(
arrayMoveImmutable<ValueOrNestedValue>(internalValue, oldIndex, newIndex) as
| string[]
| ObjectValue[],
);
},
[onChange, internalValue, keys],
);
@ -306,6 +332,7 @@ const ListControl: FC<WidgetControlProps<ObjectValue[], ListField>> = ({
collapsed={collapsed}
hasError={hasErrors}
t={t}
testId="list-header"
/>
{internalValue.length > 0 ? (
<DndContext key="dnd-context" onDragEnd={handleDragEnd}>
@ -325,7 +352,6 @@ const ListControl: FC<WidgetControlProps<ObjectValue[], ListField>> = ({
item={item}
valueType={valueType}
handleRemove={handleRemove}
data-testid={`object-control-${index}`}
entry={entry}
field={field}
fieldsErrors={fieldsErrors}

View File

@ -6,7 +6,7 @@ import EditorControl from '@staticcms/core/components/Editor/EditorControlPane/E
import ListItemTopBar from '@staticcms/core/components/UI/ListItemTopBar';
import Outline from '@staticcms/core/components/UI/Outline';
import { colors } from '@staticcms/core/components/UI/styles';
import { transientOptions } from '@staticcms/core/lib';
import transientOptions from '@staticcms/core/lib/util/transientOptions';
import {
addFileTemplateFields,
compileStringTemplate,
@ -21,6 +21,7 @@ import type {
ListField,
ObjectField,
ObjectValue,
ValueOrNestedValue,
WidgetControlProps,
} from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react';
@ -55,19 +56,27 @@ const StyledObjectFieldWrapper = styled(
`,
);
function handleSummary(summary: string, entry: Entry, label: string, item: ObjectValue) {
const labeledItem: EntryData = {
...item,
fields: {
label,
},
};
const data = addFileTemplateFields(entry.path, labeledItem);
return compileStringTemplate(summary, null, '', data);
function handleSummary(summary: string, entry: Entry, label: string, item: ValueOrNestedValue) {
if (typeof item === 'object' && !Array.isArray(item)) {
const labeledItem: EntryData = {
...item,
fields: {
label,
},
};
const data = addFileTemplateFields(entry.path, labeledItem);
return compileStringTemplate(summary, null, '', data);
}
return item;
}
function validateItem(field: ListField, item: ObjectValue) {
if (!(typeof item === 'object')) {
function validateItem(field: ListField, item: ValueOrNestedValue) {
if (field.fields && field.fields.length === 1) {
return true;
}
if (typeof item !== 'object') {
console.warn(
`'${field.name}' field item value value should be an object but is a '${typeof item}'`,
);
@ -79,7 +88,7 @@ function validateItem(field: ListField, item: ObjectValue) {
interface ListItemProps
extends Pick<
WidgetControlProps<ObjectValue, ListField>,
WidgetControlProps<ValueOrNestedValue, ListField>,
| 'entry'
| 'field'
| 'fieldsErrors'
@ -137,7 +146,9 @@ const ListItem: FC<ListItemProps> = ({
return [base, childObjectField];
}
const itemType = getTypedFieldForValue(field, objectValue, index);
const mixedObjectValue = objectValue as ObjectValue;
const itemType = getTypedFieldForValue(field, mixedObjectValue, index);
if (!itemType) {
return [base, childObjectField];
}
@ -146,7 +157,7 @@ const ListItem: FC<ListItemProps> = ({
// each type can have its own summary, but default to the list summary if exists
const summary = ('summary' in itemType && itemType.summary) ?? field.summary;
const labelReturn = summary
? `${label} - ${handleSummary(summary, entry, label, objectValue)}`
? `${label} - ${handleSummary(summary, entry, label, mixedObjectValue)}`
: label;
return [labelReturn, itemType];
}
@ -163,7 +174,10 @@ const ListItem: FC<ListItemProps> = ({
return [base, childObjectField];
}
const labelFieldValue = objectValue[labelField.name];
const labelFieldValue =
typeof objectValue === 'object' && !Array.isArray(objectValue)
? objectValue[labelField.name]
: objectValue;
const summary = field.summary;
const labelReturn = summary
@ -186,6 +200,16 @@ const ListItem: FC<ListItemProps> = ({
const isDuplicate = isFieldDuplicate && isFieldDuplicate(field);
const isHidden = isFieldHidden && isFieldHidden(field);
const finalValue = useMemo(() => {
if (field.fields && field.fields.length === 1) {
return {
[field.fields[0].name]: value,
};
}
return value;
}, [field.fields, value]);
return (
<StyledListItem key="sortable-list-item">
<>
@ -194,7 +218,7 @@ const ListItem: FC<ListItemProps> = ({
collapsed={collapsed}
onCollapseToggle={handleCollapseToggle}
onRemove={partial(handleRemove, index)}
data-testid={`styled-list-item-top-bar-${id}`}
data-testid={`list-item-top-bar-${id}`}
title={objectLabel}
isVariableTypesList={valueType === ListValueType.MIXED}
listeners={listeners}
@ -203,7 +227,7 @@ const ListItem: FC<ListItemProps> = ({
<EditorControl
key={`control-${id}`}
field={objectField}
value={value}
value={finalValue}
fieldsErrors={fieldsErrors}
submitted={submitted}
parentPath={path}
@ -213,7 +237,7 @@ const ListItem: FC<ListItemProps> = ({
isFieldHidden={isFieldHidden}
locale={locale}
i18n={i18n}
forList
forList={true}
/>
</StyledObjectFieldWrapper>
<Outline key="outline" />

View File

@ -2,11 +2,26 @@ import React from 'react';
import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer';
import type { ListField, ObjectValue, WidgetPreviewProps } from '@staticcms/core/interface';
import type { ListField, ValueOrNestedValue, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
const ObjectPreview: FC<WidgetPreviewProps<ObjectValue[], ListField>> = ({ field }) => {
return <WidgetPreviewContainer>{field.fields ?? null}</WidgetPreviewContainer>;
const ListPreview: FC<WidgetPreviewProps<ValueOrNestedValue[], ListField>> = ({ field, value }) => {
if (field.fields && field.fields.length === 1) {
return (
<WidgetPreviewContainer>
<label>
<strong>{field.name}:</strong>
</label>
<ul style={{ marginTop: 0 }}>
{value?.map(item => (
<li key={String(item)}>{String(item)}</li>
))}
</ul>
</WidgetPreviewContainer>
);
}
return <WidgetPreviewContainer>{field.renderedFields ?? null}</WidgetPreviewContainer>;
};
export default ObjectPreview;
export default ListPreview;

View File

@ -0,0 +1,220 @@
/**
* @jest-environment jsdom
*/
import '@testing-library/jest-dom';
import { getByTestId, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import createControlWrapper from '@staticcms/core/lib/test-utils/ControlWrapper';
import ListControl from '../ListControl';
import type { ListField } from '@staticcms/core/interface';
const singletonListField: ListField = {
widget: 'list',
name: 'singleton',
fields: [
{
widget: 'string',
name: 'stringInput',
default: 'string default',
},
],
};
const multipleFieldsListField: ListField = {
widget: 'list',
name: 'multipleFields',
fields: [
{
widget: 'string',
name: 'stringInput',
default: 'string default',
},
{
widget: 'text',
name: 'textInput',
default: 'text default',
},
],
};
const multipleFieldsValue = [
{ stringInput: 'String Value 1', textInput: 'Text Value 1' },
{ stringInput: 'String Value 2', textInput: 'Text Value 2' },
];
const ListControlWrapper = createControlWrapper({
defaultField: singletonListField,
control: ListControl,
label: 'List Control',
path: 'list',
});
jest.mock('@staticcms/core/components/Editor/EditorControlPane/EditorControl', () => {
return jest.fn(props => {
const { parentPath, field, value } = props;
return (
<div data-testid="editor-control">
<div data-testid="parentPath">{parentPath}</div>
<div data-testid="fieldName">{field.name}</div>
<div data-testid="value">{JSON.stringify(value)}</div>
</div>
);
});
});
describe(ListControl.name, () => {
describe('multiple field list', () => {
it('renders empty div by default', () => {
render(<ListControlWrapper field={multipleFieldsListField} value={multipleFieldsValue} />);
expect(screen.getByTestId('object-control-0')).not.toBeVisible();
expect(screen.getByTestId('object-control-1')).not.toBeVisible();
});
it('renders values when opened', async () => {
render(<ListControlWrapper field={multipleFieldsListField} value={multipleFieldsValue} />);
const headerBar = screen.getByTestId('list-header');
await userEvent.click(getByTestId(headerBar, 'expand-button'));
const itemOne = screen.getByTestId('object-control-0');
expect(itemOne).toBeVisible();
expect(getByTestId(itemOne, 'list-item-title').textContent).toBe('String Value 1');
expect(getByTestId(itemOne, 'parentPath').textContent).toBe('list');
expect(getByTestId(itemOne, 'fieldName').textContent).toBe('0');
expect(JSON.parse(getByTestId(itemOne, 'value').textContent ?? '')).toEqual({
stringInput: 'String Value 1',
textInput: 'Text Value 1',
});
const itemTwo = screen.getByTestId('object-control-1');
expect(itemTwo).toBeVisible();
expect(getByTestId(itemTwo, 'list-item-title').textContent).toBe('String Value 2');
expect(getByTestId(itemTwo, 'parentPath').textContent).toBe('list');
expect(getByTestId(itemTwo, 'fieldName').textContent).toBe('1');
expect(JSON.parse(getByTestId(itemTwo, 'value').textContent ?? '')).toEqual({
stringInput: 'String Value 2',
textInput: 'Text Value 2',
});
});
it('outputs value as object array when adding new value', async () => {
const onChange = jest.fn();
render(
<ListControlWrapper
field={multipleFieldsListField}
value={multipleFieldsValue}
onChange={onChange}
/>,
);
const headerBar = screen.getByTestId('list-header');
await userEvent.click(getByTestId(headerBar, 'expand-button'));
await userEvent.click(getByTestId(headerBar, 'add-button'));
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith([
...multipleFieldsValue,
{
stringInput: 'string default',
textInput: 'text default',
},
]);
});
it('outputs value as object array when removing existing value', async () => {
const onChange = jest.fn();
render(
<ListControlWrapper
field={multipleFieldsListField}
value={multipleFieldsValue}
onChange={onChange}
/>,
);
const headerBar = screen.getByTestId('list-header');
await userEvent.click(getByTestId(headerBar, 'expand-button'));
const itemOne = screen.getByTestId('object-control-0');
await userEvent.click(getByTestId(itemOne, 'remove-button'));
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith([
{ stringInput: 'String Value 2', textInput: 'Text Value 2' },
]);
});
});
describe('singleton list', () => {
it('renders empty div by default', () => {
render(<ListControlWrapper value={['Value 1', 'Value 2']} />);
expect(screen.getByTestId('object-control-0')).not.toBeVisible();
expect(screen.getByTestId('object-control-1')).not.toBeVisible();
});
it('renders values when opened', async () => {
render(<ListControlWrapper value={['Value 1', 'Value 2']} />);
const headerBar = screen.getByTestId('list-header');
await userEvent.click(getByTestId(headerBar, 'expand-button'));
const itemOne = screen.getByTestId('object-control-0');
expect(itemOne).toBeVisible();
expect(getByTestId(itemOne, 'list-item-title').textContent).toBe('Value 1');
expect(getByTestId(itemOne, 'parentPath').textContent).toBe('list');
expect(getByTestId(itemOne, 'fieldName').textContent).toBe('0');
expect(JSON.parse(getByTestId(itemOne, 'value').textContent ?? '')).toEqual({
stringInput: 'Value 1',
});
const itemTwo = screen.getByTestId('object-control-1');
expect(itemTwo).toBeVisible();
expect(getByTestId(itemTwo, 'list-item-title').textContent).toBe('Value 2');
expect(getByTestId(itemTwo, 'parentPath').textContent).toBe('list');
expect(getByTestId(itemTwo, 'fieldName').textContent).toBe('1');
expect(JSON.parse(getByTestId(itemTwo, 'value').textContent ?? '')).toEqual({
stringInput: 'Value 2',
});
});
it('outputs value as singleton array when adding new value', async () => {
const onChange = jest.fn();
render(<ListControlWrapper value={['Value 1', 'Value 2']} onChange={onChange} />);
const headerBar = screen.getByTestId('list-header');
await userEvent.click(getByTestId(headerBar, 'expand-button'));
await userEvent.click(getByTestId(headerBar, 'add-button'));
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(['Value 1', 'Value 2', 'string default']);
});
it('outputs value as singleton array when removing existing value', async () => {
const onChange = jest.fn();
render(<ListControlWrapper value={['Value 1', 'Value 2']} onChange={onChange} />);
const headerBar = screen.getByTestId('list-header');
await userEvent.click(getByTestId(headerBar, 'expand-button'));
const itemOne = screen.getByTestId('object-control-0');
await userEvent.click(getByTestId(itemOne, 'remove-button'));
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(['Value 2']);
});
});
});

View File

@ -2,9 +2,9 @@ import controlComponent from './ListControl';
import previewComponent from './ListPreview';
import schema from './schema';
import type { ListField, ObjectValue, WidgetParam } from '@staticcms/core/interface';
import type { ListField, ValueOrNestedValue, WidgetParam } from '@staticcms/core/interface';
const ListWidget = (): WidgetParam<ObjectValue[], ListField> => {
const ListWidget = (): WidgetParam<ValueOrNestedValue[], ListField> => {
return {
name: 'list',
controlComponent,

View File

@ -78,9 +78,16 @@ const ObjectControl: FC<WidgetControlProps<ObjectValue, ObjectField>> = ({
const renderedField = useMemo(() => {
return (
multiFields?.map((field, index) => {
const fieldName = field.name;
let fieldName = field.name;
let parentPath = path;
const fieldValue = value && value[fieldName];
if (forList && multiFields.length === 1) {
const splitPath = path.split('.');
fieldName = splitPath.pop() ?? field.name;
parentPath = splitPath.join('.');
}
const isDuplicate = isFieldDuplicate && isFieldDuplicate(field);
const isHidden = isFieldHidden && isFieldHidden(field);
@ -88,10 +95,11 @@ const ObjectControl: FC<WidgetControlProps<ObjectValue, ObjectField>> = ({
<EditorControl
key={index}
field={field}
fieldName={fieldName}
value={fieldValue}
fieldsErrors={fieldsErrors}
submitted={submitted}
parentPath={path}
parentPath={parentPath}
isDisabled={isDuplicate}
isHidden={isHidden}
isFieldDuplicate={isFieldDuplicate}
@ -104,6 +112,7 @@ const ObjectControl: FC<WidgetControlProps<ObjectValue, ObjectField>> = ({
);
}, [
fieldsErrors,
forList,
i18n,
isFieldDuplicate,
isFieldHidden,
@ -125,6 +134,7 @@ const ObjectControl: FC<WidgetControlProps<ObjectValue, ObjectField>> = ({
heading={objectLabel}
hasError={hasErrors}
t={t}
testId="object-title"
/>
)}
<StyledFieldsBox $collapsed={collapsed} key="object-control-fields">

View File

@ -6,7 +6,7 @@ import type { ObjectField, ObjectValue, WidgetPreviewProps } from '@staticcms/co
import type { FC } from 'react';
const ObjectPreview: FC<WidgetPreviewProps<ObjectValue, ObjectField>> = ({ field }) => {
return <WidgetPreviewContainer>{field.fields ?? null}</WidgetPreviewContainer>;
return <WidgetPreviewContainer>{field.renderedFields ?? null}</WidgetPreviewContainer>;
};
export default ObjectPreview;

View File

@ -0,0 +1,98 @@
/**
* @jest-environment jsdom
*/
import '@testing-library/jest-dom';
import { getByTestId, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import createControlWrapper from '@staticcms/core/lib/test-utils/ControlWrapper';
import ObjectControl from '../ObjectControl';
import type { ObjectField } from '@staticcms/core/interface';
const singleFieldObjectField: ObjectField = {
widget: 'object',
name: 'object_field',
label: 'Object Field',
fields: [
{
widget: 'string',
name: 'stringInput',
default: 'string default',
},
],
};
const singleFieldObjectValue = {
stringInput: 'String Value',
};
const ObjectControlWrapper = createControlWrapper({
defaultField: singleFieldObjectField,
control: ObjectControl,
label: 'Object Control',
path: 'object',
});
jest.mock('@staticcms/core/components/Editor/EditorControlPane/EditorControl', () => {
return jest.fn(props => {
const { parentPath, fieldName, field } = props;
return (
<div data-testid="editor-control">
<div data-testid="parentPath">{parentPath}</div>
<div data-testid="fieldName">{fieldName ?? field.name}</div>
</div>
);
});
});
describe(ObjectControl.name, () => {
it('renders all fields visible by default', () => {
render(<ObjectControlWrapper field={singleFieldObjectField} value={singleFieldObjectValue} />);
expect(screen.getByTestId('object-title').textContent).toBe('Object Field');
const fields = screen.getAllByTestId('editor-control');
expect(fields.length).toBe(1);
const fieldOne = fields[0];
expect(fieldOne).toBeVisible();
expect(getByTestId(fieldOne, 'parentPath').textContent).toBe('object');
expect(getByTestId(fieldOne, 'fieldName').textContent).toBe('stringInput');
});
it('does not render fields when closed', async () => {
render(<ObjectControlWrapper field={singleFieldObjectField} value={singleFieldObjectValue} />);
await userEvent.click(screen.getByTestId('expand-button'));
const fields = screen.getAllByTestId('editor-control');
expect(fields.length).toBe(1);
const fieldOne = fields[0];
expect(fieldOne).not.toBeVisible();
});
describe('for list', () => {
it('should pass down parent path and field name to child if for list and single field', () => {
render(
<ObjectControlWrapper
field={singleFieldObjectField}
value={singleFieldObjectValue}
path="list.0"
forList={true}
/>,
);
expect(screen.queryByTestId('object-title')).not.toBeInTheDocument();
const fields = screen.getAllByTestId('editor-control');
expect(fields.length).toBe(1);
const fieldOne = fields[0];
expect(getByTestId(fieldOne, 'parentPath').textContent).toBe('list');
expect(getByTestId(fieldOne, 'fieldName').textContent).toBe('0');
});
});
});

View File

@ -1,7 +1,7 @@
import * as fuzzy from 'fuzzy';
import Autocomplete from '@mui/material/Autocomplete';
import CircularProgress from '@mui/material/CircularProgress';
import TextField from '@mui/material/TextField';
import * as fuzzy from 'fuzzy';
import find from 'lodash/find';
import get from 'lodash/get';
import uniqBy from 'lodash/uniqBy';
@ -21,8 +21,8 @@ import {
expandPath,
extractTemplateVars,
} from '@staticcms/core/lib/widgets/stringTemplate';
import { selectCollection } from '@staticcms/core/reducers/selectors/collections';
import { useAppSelector } from '@staticcms/core/store/hooks';
import { selectCollection } from '@staticcms/core/reducers/collections';
import type { FilterOptionsState } from '@mui/material/useAutocomplete';
import type {

View File

@ -22,7 +22,10 @@ const StringControl: FC<WidgetControlProps<string, StringOrTextField>> = ({
return (
<TextField
key="string-control-input"
key="string-widget-control-input"
inputProps={{
'data-testid': 'string-widget-control-input',
}}
label={label}
variant="outlined"
value={internalValue}