feat: singleton array list widget (#336)
This commit is contained in:
committed by
GitHub
parent
a60d53b4ec
commit
c5e94ed16d
@ -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}
|
||||
|
@ -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" />
|
||||
|
@ -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;
|
||||
|
220
packages/core/src/widgets/list/__tests__/ListControl.spec.tsx
Normal file
220
packages/core/src/widgets/list/__tests__/ListControl.spec.tsx
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
@ -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,
|
||||
|
@ -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">
|
||||
|
@ -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;
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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 {
|
||||
|
@ -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}
|
||||
|
Reference in New Issue
Block a user