Merge pull request #12213 from strapi/cm/repeatable-components-list-view

CM: Allow displaying components in the list view
This commit is contained in:
Gustav Hansen 2022-03-24 14:07:19 +01:00 committed by GitHub
commit 92e3f45e69
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1084 additions and 250 deletions

View File

@ -14,6 +14,7 @@ const reducers = {
'content-manager_listView': jest.fn(() => ({
data: [],
isLoading: true,
components: [],
contentType: {},
initialDisplayedHeaders: [],
displayedHeaders: [],

View File

@ -1,87 +0,0 @@
import React from 'react';
import { useQuery } from 'react-query';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import { Box } from '@strapi/design-system/Box';
import { Typography } from '@strapi/design-system/Typography';
import { Loader } from '@strapi/design-system/Loader';
import { useNotifyAT } from '@strapi/design-system/LiveRegions';
import { axiosInstance } from '../../../../../core/utils';
import { getRequestUrl, getTrad } from '../../../../utils';
import CellValue from '../CellValue';
const fetchRelation = async (endPoint, notifyStatus) => {
const {
data: { results, pagination },
} = await axiosInstance.get(endPoint);
notifyStatus();
return { results, pagination };
};
const PopoverContent = ({ fieldSchema, name, rowId, targetModel, queryInfos }) => {
const requestURL = getRequestUrl(`${queryInfos.endPoint}/${rowId}/${name.split('.')[0]}`);
const { notifyStatus } = useNotifyAT();
const { formatMessage } = useIntl();
const notify = () => {
const message = formatMessage({
id: getTrad('DynamicTable.relation-loaded'),
defaultMessage: 'The relations have been loaded',
});
notifyStatus(message);
};
const { data, status } = useQuery([targetModel, rowId], () => fetchRelation(requestURL, notify), {
staleTime: 0,
});
if (status !== 'success') {
return (
<Box>
<Loader>Loading content</Loader>
</Box>
);
}
return (
<ul>
{data?.results.map(entry => {
const value = entry[fieldSchema.name];
return (
<Box as="li" key={entry.id} padding={3}>
<Typography>
{value ? (
<CellValue type={fieldSchema.schema.type} value={entry[fieldSchema.name]} />
) : (
'-'
)}
</Typography>
</Box>
);
})}
{data?.pagination.total > 10 && (
<Box as="li" padding={3}>
<Typography>[...]</Typography>
</Box>
)}
</ul>
);
};
PopoverContent.propTypes = {
fieldSchema: PropTypes.shape({
name: PropTypes.string,
schema: PropTypes.shape({ type: PropTypes.string }).isRequired,
}).isRequired,
name: PropTypes.string.isRequired,
rowId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
targetModel: PropTypes.string.isRequired,
queryInfos: PropTypes.shape({
endPoint: PropTypes.string,
}).isRequired,
};
export default PopoverContent;

View File

@ -1,107 +0,0 @@
import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import { IconButton } from '@strapi/design-system/IconButton';
import { Typography } from '@strapi/design-system/Typography';
import { Box } from '@strapi/design-system/Box';
import { Badge } from '@strapi/design-system/Badge';
import { Flex } from '@strapi/design-system/Flex';
import { Popover } from '@strapi/design-system/Popover';
import { SortIcon, stopPropagation } from '@strapi/helper-plugin';
import styled from 'styled-components';
import PopoverContent from './PopoverContent';
import CellValue from '../CellValue';
const SINGLE_RELATIONS = ['oneToOne', 'manyToOne'];
const ActionWrapper = styled.span`
svg {
height: ${4 / 16}rem;
}
`;
const RelationCountBadge = styled(Badge)`
display: flex;
align-items: center;
height: ${20 / 16}rem;
width: ${16 / 16}rem;
`;
const Relation = ({ fieldSchema, metadatas, queryInfos, name, rowId, value }) => {
const { formatMessage } = useIntl();
const [visible, setVisible] = useState(false);
const buttonRef = useRef();
if (SINGLE_RELATIONS.includes(fieldSchema.relation)) {
return (
<Typography textColor="neutral800">
<CellValue type={metadatas.mainField.schema.type} value={value[metadatas.mainField.name]} />
</Typography>
);
}
const handleTogglePopover = () => setVisible(prev => !prev);
return (
<Flex {...stopPropagation}>
<RelationCountBadge>{value.count}</RelationCountBadge>
<Box paddingLeft={2}>
<Typography textColor="neutral800">
{formatMessage(
{
id: 'content-manager.containers.ListPage.items',
defaultMessage: '{number, plural, =0 {items} one {item} other {items}}',
},
{ number: value.count }
)}
</Typography>
</Box>
{value.count > 0 && (
<ActionWrapper>
<IconButton
onClick={handleTogglePopover}
ref={buttonRef}
noBorder
label={formatMessage({
id: 'content-manager.popover.display-relations.label',
defaultMessage: 'Display relations',
})}
icon={<SortIcon isUp={visible} />}
/>
{visible && (
<Popover source={buttonRef} spacing={16} centered>
<PopoverContent
queryInfos={queryInfos}
name={name}
fieldSchema={metadatas.mainField}
targetModel={fieldSchema.targetModel}
rowId={rowId}
count={value.count}
/>
</Popover>
)}
</ActionWrapper>
)}
</Flex>
);
};
Relation.propTypes = {
fieldSchema: PropTypes.shape({
relation: PropTypes.string,
targetModel: PropTypes.string,
type: PropTypes.string.isRequired,
}).isRequired,
metadatas: PropTypes.shape({
mainField: PropTypes.shape({
name: PropTypes.string.isRequired,
schema: PropTypes.shape({ type: PropTypes.string.isRequired }).isRequired,
}),
}).isRequired,
name: PropTypes.string.isRequired,
rowId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
queryInfos: PropTypes.shape({ endPoint: PropTypes.string.isRequired }).isRequired,
value: PropTypes.object.isRequired,
};
export default Relation;

View File

@ -0,0 +1,135 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { useQuery } from 'react-query';
import { useIntl } from 'react-intl';
import { Typography } from '@strapi/design-system/Typography';
import { Box } from '@strapi/design-system/Box';
import { Badge } from '@strapi/design-system/Badge';
import { SimpleMenu, MenuItem } from '@strapi/design-system/SimpleMenu';
import { Loader } from '@strapi/design-system/Loader';
import styled from 'styled-components';
import { useNotifyAT } from '@strapi/design-system/LiveRegions';
import { stopPropagation } from '@strapi/helper-plugin';
import CellValue from '../CellValue';
import { axiosInstance } from '../../../../../core/utils';
import { getRequestUrl, getTrad } from '../../../../utils';
const TypographyMaxWidth = styled(Typography)`
max-width: 500px;
`;
const fetchRelation = async (endPoint, notifyStatus) => {
const {
data: { results, pagination },
} = await axiosInstance.get(endPoint);
notifyStatus();
return { results, pagination };
};
const RelationMultiple = ({ fieldSchema, metadatas, queryInfos, name, rowId, value }) => {
const { formatMessage } = useIntl();
const { notifyStatus } = useNotifyAT();
const requestURL = getRequestUrl(`${queryInfos.endPoint}/${rowId}/${name.split('.')[0]}`);
const [isOpen, setIsOpen] = useState(false);
const Label = (
<>
<Badge>{value.count}</Badge>{' '}
{formatMessage(
{
id: 'content-manager.containers.ListPage.items',
defaultMessage: '{number, plural, =0 {items} one {item} other {items}}',
},
{ number: value.count }
)}
</>
);
const notify = () => {
const message = formatMessage({
id: getTrad('DynamicTable.relation-loaded'),
defaultMessage: 'Relations have been loaded',
});
notifyStatus(message);
};
const { data, status } = useQuery(
[fieldSchema.targetModel, rowId],
() => fetchRelation(requestURL, notify),
{
enabled: isOpen,
staleTime: 0,
}
);
return (
<Box {...stopPropagation}>
<SimpleMenu
label={Label}
size="S"
onOpen={() => setIsOpen(true)}
onClose={() => setIsOpen(false)}
>
{status !== 'success' && (
<MenuItem aria-disabled>
<Loader small>
{formatMessage({
id: getTrad('DynamicTable.relation-loading'),
defaultMessage: 'Relations are loading',
})}
</Loader>
</MenuItem>
)}
{status === 'success' && (
<>
{data?.results.map(entry => (
<MenuItem key={entry.id} aria-disabled>
<TypographyMaxWidth ellipsis>
<CellValue
type={metadatas.mainField.schema.type}
value={entry[metadatas.mainField.name] || entry.id}
/>
</TypographyMaxWidth>
</MenuItem>
))}
{data?.pagination.total > 10 && (
<MenuItem
aria-disabled
aria-label={formatMessage({
id: getTrad('DynamicTable.relation-more'),
defaultMessage: 'This relation contains more entities than displayed',
})}
>
<Typography>...</Typography>
</MenuItem>
)}
</>
)}
</SimpleMenu>
</Box>
);
};
RelationMultiple.propTypes = {
fieldSchema: PropTypes.shape({
relation: PropTypes.string,
targetModel: PropTypes.string,
type: PropTypes.string.isRequired,
}).isRequired,
metadatas: PropTypes.shape({
mainField: PropTypes.shape({
name: PropTypes.string.isRequired,
schema: PropTypes.shape({ type: PropTypes.string.isRequired }).isRequired,
}),
}).isRequired,
name: PropTypes.string.isRequired,
rowId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
queryInfos: PropTypes.shape({ endPoint: PropTypes.string.isRequired }).isRequired,
value: PropTypes.object.isRequired,
};
export default RelationMultiple;

View File

@ -0,0 +1,291 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DynamicTabe / Cellcontent / RelationMultiple renders and matches the snapshot 1`] = `
.c11 {
border: 0;
-webkit-clip: rect(0 0 0 0);
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
.c5 {
background: #f6f6f9;
padding: 4px;
border-radius: 4px;
min-width: 20px;
}
.c6 {
display: -webkit-inline-box;
display: -webkit-inline-flex;
display: -ms-inline-flexbox;
display: inline-flex;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.c7 {
color: #666687;
font-weight: 600;
font-size: 0.6875rem;
line-height: 1.45;
text-transform: uppercase;
}
.c4 {
font-weight: 600;
color: #32324d;
font-size: 0.75rem;
line-height: 1.33;
}
.c9 {
padding-left: 8px;
}
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
cursor: pointer;
padding: 8px;
border-radius: 4px;
background: #ffffff;
border: 1px solid #dcdce4;
position: relative;
outline: none;
}
.c0 svg {
height: 12px;
width: 12px;
}
.c0 svg > g,
.c0 svg path {
fill: #ffffff;
}
.c0[aria-disabled='true'] {
pointer-events: none;
}
.c0:after {
-webkit-transition-property: all;
transition-property: all;
-webkit-transition-duration: 0.2s;
transition-duration: 0.2s;
border-radius: 8px;
content: '';
position: absolute;
top: -4px;
bottom: -4px;
left: -4px;
right: -4px;
border: 2px solid transparent;
}
.c0:focus-visible {
outline: none;
}
.c0:focus-visible:after {
border-radius: 8px;
content: '';
position: absolute;
top: -5px;
bottom: -5px;
left: -5px;
right: -5px;
border: 2px solid #4945ff;
}
.c1 {
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px 16px;
background: #4945ff;
border: none;
border: 1px solid transparent;
background: transparent;
}
.c1 .c8 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.c1 .c3 {
color: #ffffff;
}
.c1[aria-disabled='true'] {
border: 1px solid #dcdce4;
background: #eaeaef;
}
.c1[aria-disabled='true'] .c3 {
color: #666687;
}
.c1[aria-disabled='true'] svg > g,
.c1[aria-disabled='true'] svg path {
fill: #666687;
}
.c1[aria-disabled='true']:active {
border: 1px solid #dcdce4;
background: #eaeaef;
}
.c1[aria-disabled='true']:active .c3 {
color: #666687;
}
.c1[aria-disabled='true']:active svg > g,
.c1[aria-disabled='true']:active svg path {
fill: #666687;
}
.c1:hover {
background-color: #f6f6f9;
}
.c1:active {
border: 1px solid undefined;
background: undefined;
}
.c1 .c3 {
color: #32324d;
}
.c1 svg > g,
.c1 svg path {
fill: #8e8ea9;
}
.c10 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.c10 svg {
height: 4px;
width: 6px;
}
.c2 {
padding: 4px 12px;
}
<div>
<div
aria-hidden="true"
class=""
role="button"
>
<div>
<button
aria-controls="simplemenu-1"
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
class="c0 c1 c2"
type="button"
>
<span
class="c3 c4"
>
<div
class="c5 c6"
>
<span
class="c7"
>
1
</span>
</div>
item
</span>
<div
aria-hidden="true"
class="c8 c9"
>
<span
class="c10"
>
<svg
aria-hidden="true"
fill="none"
height="1em"
viewBox="0 0 14 8"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M14 .889a.86.86 0 01-.26.625L7.615 7.736A.834.834 0 017 8a.834.834 0 01-.615-.264L.26 1.514A.861.861 0 010 .889c0-.24.087-.45.26-.625A.834.834 0 01.875 0h12.25c.237 0 .442.088.615.264a.86.86 0 01.26.625z"
fill="#32324D"
fill-rule="evenodd"
/>
</svg>
</span>
</div>
</button>
</div>
</div>
<div
class="c11"
>
<p
aria-live="polite"
aria-relevant="all"
id="live-region-log"
role="log"
/>
<p
aria-live="polite"
aria-relevant="all"
id="live-region-status"
role="status"
/>
<p
aria-live="assertive"
aria-relevant="all"
id="live-region-alert"
role="alert"
/>
</div>
</div>
`;

View File

@ -0,0 +1,80 @@
import React from 'react';
import { render, fireEvent, screen, waitFor } from '@testing-library/react';
import { ThemeProvider, lightTheme } from '@strapi/design-system';
import { IntlProvider } from 'react-intl';
import { QueryClientProvider, QueryClient } from 'react-query';
import { axiosInstance } from '../../../../../../core/utils';
import RelationMultiple from '../index';
jest.spyOn(axiosInstance, 'get').mockResolvedValue({
data: {
results: [
{
id: 1,
name: 'Relation entity 1',
},
],
pagination: {
total: 1,
},
},
});
const DEFAULT_PROPS_FIXTURE = {
fieldSchema: {
type: 'relation',
relation: 'manyToMany',
target: 'api::category.category',
},
queryInfos: {
endPoint: 'collection-types/api::address.address',
},
metadatas: {
mainField: {
name: 'name',
schema: {
type: 'string',
},
},
},
value: {
count: 1,
},
name: 'categories.name',
rowId: 1,
};
const ComponentFixture = () => {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={{}} defaultLocale="en">
<RelationMultiple {...DEFAULT_PROPS_FIXTURE} />
</IntlProvider>
</ThemeProvider>
</QueryClientProvider>
);
};
describe('DynamicTabe / Cellcontent / RelationMultiple', () => {
it('renders and matches the snapshot', async () => {
const { container } = render(<ComponentFixture />);
expect(container).toMatchSnapshot();
expect(axiosInstance.get).toHaveBeenCalledTimes(0);
});
it('fetches relation entities once the menu is opened', async () => {
const { container } = render(<ComponentFixture />);
const button = container.querySelector('[type=button]');
fireEvent(button, new MouseEvent('mousedown', { bubbles: true }));
expect(screen.getByText('Relations are loading')).toBeInTheDocument();
expect(axiosInstance.get).toHaveBeenCalledTimes(1);
await waitFor(() => expect(screen.getByText('Relation entity 1')).toBeInTheDocument());
});
});

View File

@ -0,0 +1,32 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Typography } from '@strapi/design-system/Typography';
import styled from 'styled-components';
import CellValue from '../CellValue';
const TypographyMaxWidth = styled(Typography)`
max-width: 500px;
`;
const RelationSingle = ({ metadatas, value }) => {
return (
<TypographyMaxWidth textColor="neutral800" ellipsis>
<CellValue
type={metadatas.mainField.schema.type}
value={value[metadatas.mainField.name] || value.id}
/>
</TypographyMaxWidth>
);
};
RelationSingle.propTypes = {
metadatas: PropTypes.shape({
mainField: PropTypes.shape({
name: PropTypes.string.isRequired,
schema: PropTypes.shape({ type: PropTypes.string.isRequired }).isRequired,
}),
}).isRequired,
value: PropTypes.object.isRequired,
};
export default RelationSingle;

View File

@ -0,0 +1,59 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DynamicTabe / Cellcontent / RelationSingle renders and matches the snapshot 1`] = `
.c2 {
border: 0;
-webkit-clip: rect(0 0 0 0);
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
.c0 {
color: #32324d;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.875rem;
line-height: 1.43;
}
.c1 {
max-width: 500px;
}
<div>
<span
class="c0 c1"
>
</span>
<div
class="c2"
>
<p
aria-live="polite"
aria-relevant="all"
id="live-region-log"
role="log"
/>
<p
aria-live="polite"
aria-relevant="all"
id="live-region-status"
role="status"
/>
<p
aria-live="assertive"
aria-relevant="all"
id="live-region-alert"
role="alert"
/>
</div>
</div>
`;

View File

@ -0,0 +1,37 @@
import React from 'react';
import { render } from '@testing-library/react';
import { ThemeProvider, lightTheme } from '@strapi/design-system';
import { IntlProvider } from 'react-intl';
import RelationSingle from '../index';
const DEFAULT_PROPS_FIXTURE = {
metadatas: {
mainField: {
name: 'name',
schema: {
type: 'string',
},
},
},
value: {
count: 1,
},
};
const ComponentFixture = () => {
return (
<ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={{}} defaultLocale="en">
<RelationSingle {...DEFAULT_PROPS_FIXTURE} />
</IntlProvider>
</ThemeProvider>
);
};
describe('DynamicTabe / Cellcontent / RelationSingle', () => {
it('renders and matches the snapshot', async () => {
const { container } = render(<ComponentFixture />);
expect(container).toMatchSnapshot();
});
});

View File

@ -0,0 +1,62 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { useIntl } from 'react-intl';
import { Badge } from '@strapi/design-system/Badge';
import { Box } from '@strapi/design-system/Box';
import { Typography } from '@strapi/design-system/Typography';
import { SimpleMenu, MenuItem } from '@strapi/design-system/SimpleMenu';
import { stopPropagation } from '@strapi/helper-plugin';
import CellValue from '../CellValue';
const TypographyMaxWidth = styled(Typography)`
max-width: 500px;
`;
const RepeatableComponentCell = ({ value, metadatas }) => {
const { formatMessage } = useIntl();
const {
mainField: { type: mainFieldType, name: mainFieldName },
} = metadatas;
const Label = (
<>
<Badge>{value.length}</Badge>{' '}
{formatMessage(
{
id: 'content-manager.containers.ListPage.items',
defaultMessage: '{number, plural, =0 {items} one {item} other {items}}',
},
{ number: value.length }
)}
</>
);
return (
<Box {...stopPropagation}>
<SimpleMenu label={Label} size="S">
{value.map(item => (
<MenuItem key={item.id} aria-disabled>
<TypographyMaxWidth ellipsis>
<CellValue type={mainFieldType} value={item[mainFieldName] || item.id} />
</TypographyMaxWidth>
</MenuItem>
))}
</SimpleMenu>
</Box>
);
};
RepeatableComponentCell.propTypes = {
metadatas: PropTypes.shape({
mainField: PropTypes.shape({
name: PropTypes.string,
type: PropTypes.string,
value: PropTypes.string,
}),
}).isRequired,
value: PropTypes.array.isRequired,
};
export default RepeatableComponentCell;

View File

@ -0,0 +1,37 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { Tooltip } from '@strapi/design-system/Tooltip';
import { Typography } from '@strapi/design-system/Typography';
import CellValue from '../CellValue';
const TypographyMaxWidth = styled(Typography)`
max-width: 250px;
`;
const SingleComponentCell = ({ value, metadatas }) => {
const { mainField } = metadatas;
const content = value[mainField.name];
return (
<Tooltip label={content}>
<TypographyMaxWidth textColor="neutral800" ellipsis>
<CellValue type={mainField.type} value={content} />
</TypographyMaxWidth>
</Tooltip>
);
};
SingleComponentCell.propTypes = {
metadatas: PropTypes.shape({
mainField: PropTypes.shape({
name: PropTypes.string,
type: PropTypes.string,
value: PropTypes.string,
}),
}).isRequired,
value: PropTypes.object.isRequired,
};
export default SingleComponentCell;

View File

@ -4,44 +4,64 @@ import styled from 'styled-components';
import { Typography } from '@strapi/design-system/Typography';
import Media from './Media';
import MultipleMedias from './MultipleMedias';
import Relation from './Relation';
import RelationMultiple from './RelationMultiple';
import RelationSingle from './RelationSingle';
import RepeatableComponent from './RepeatableComponent';
import SingleComponent from './SingleComponent';
import CellValue from './CellValue';
import hasContent from './utils/hasContent';
import isSingleRelation from './utils/isSingleRelation';
const TypographyMaxWidth = styled(Typography)`
max-width: 300px;
`;
const CellContent = ({ content, fieldSchema, metadatas, name, queryInfos, rowId }) => {
if (content === null || content === undefined) {
const { type } = fieldSchema;
if (!hasContent(type, content, metadatas, fieldSchema)) {
return <Typography textColor="neutral800">-</Typography>;
}
if (fieldSchema.type === 'media' && !fieldSchema.multiple) {
return <Media {...content} />;
}
switch (type) {
case 'media':
if (!fieldSchema.multiple) {
return <Media {...content} />;
}
if (fieldSchema.type === 'media' && fieldSchema.multiple) {
return <MultipleMedias value={content} />;
}
return <MultipleMedias value={content} />;
if (fieldSchema.type === 'relation') {
return (
<Relation
fieldSchema={fieldSchema}
queryInfos={queryInfos}
metadatas={metadatas}
value={content}
name={name}
rowId={rowId}
/>
);
}
case 'relation': {
if (isSingleRelation(fieldSchema.relation)) {
return <RelationSingle metadatas={metadatas} value={content} />;
}
return (
<TypographyMaxWidth ellipsis textColor="neutral800">
<CellValue type={fieldSchema.type} value={content} />
</TypographyMaxWidth>
);
return (
<RelationMultiple
fieldSchema={fieldSchema}
queryInfos={queryInfos}
metadatas={metadatas}
value={content}
name={name}
rowId={rowId}
/>
);
}
case 'component':
if (fieldSchema.repeatable === true) {
return <RepeatableComponent value={content} metadatas={metadatas} />;
}
return <SingleComponent value={content} metadatas={metadatas} />;
default:
return (
<TypographyMaxWidth ellipsis textColor="neutral800">
<CellValue type={type} value={content} />
</TypographyMaxWidth>
);
}
};
CellContent.defaultProps = {
@ -51,8 +71,13 @@ CellContent.defaultProps = {
CellContent.propTypes = {
content: PropTypes.any,
fieldSchema: PropTypes.shape({ multiple: PropTypes.bool, type: PropTypes.string.isRequired })
.isRequired,
fieldSchema: PropTypes.shape({
component: PropTypes.string,
multiple: PropTypes.bool,
type: PropTypes.string.isRequired,
repeatable: PropTypes.bool,
relation: PropTypes.string,
}).isRequired,
metadatas: PropTypes.object.isRequired,
name: PropTypes.string.isRequired,
rowId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,

View File

@ -0,0 +1,29 @@
import isEmpty from 'lodash/isEmpty';
import isSingleRelation from './isSingleRelation';
export default function hasContent(type, content, metadatas, fieldSchema) {
if (type === 'component') {
const {
mainField: { name: mainFieldName },
} = metadatas;
// Repeatable fields show the ID as fallback, in case the mainField
// doesn't have any content
if (fieldSchema?.repeatable) {
return content.length > 0;
}
return !isEmpty(content[mainFieldName]);
}
if (type === 'relation') {
if (isSingleRelation(fieldSchema.relation)) {
return !isEmpty(content);
}
return content.count > 0;
}
return !isEmpty(content);
}

View File

@ -0,0 +1,3 @@
export default function isSingleRelation(type) {
return ['oneToOne', 'manyToOne', 'oneToOneMorph'].includes(type);
}

View File

@ -0,0 +1,125 @@
import hasContent from '../hasContent';
describe('hasContent', () => {
it('returns true for text content', () => {
const normalizedContent = hasContent('text', 'content');
expect(normalizedContent).toEqual(true);
});
it('returns false for empty text content', () => {
const normalizedContent = hasContent('text', '');
expect(normalizedContent).toEqual(false);
});
it('returns false for undefined text content', () => {
const normalizedContent = hasContent('text', undefined);
expect(normalizedContent).toEqual(false);
});
it('extracts content from single components with content', () => {
const normalizedContent = hasContent(
'component',
{ name: 'content', id: 1 },
{ mainField: { name: 'name' } }
);
expect(normalizedContent).toEqual(true);
});
it('extracts content from single components without content', () => {
const normalizedContent = hasContent(
'component',
{ name: '', id: 1 },
{ mainField: { name: 'name' } }
);
expect(normalizedContent).toEqual(false);
});
it('extracts content from repeatable components with content', () => {
const normalizedContent = hasContent(
'component',
[{ name: 'content_2', value: 'truthy', id: 1 }],
{ mainField: { name: 'content_2' } },
{ repeatable: true }
);
expect(normalizedContent).toEqual(true);
});
it('extracts content from repeatable components without content', () => {
const normalizedContent = hasContent(
'component',
[{ name: 'content_2', value: '', id: 1 }],
{ mainField: { name: 'content_2' } },
{ repeatable: true }
);
expect(normalizedContent).toEqual(true);
});
it('extracts content from repeatable components without content', () => {
const normalizedContent = hasContent(
'component',
[{ id: 1 }, { id: 2 }],
{ mainField: { name: 'content_2' } },
{ repeatable: true }
);
expect(normalizedContent).toEqual(true);
});
it('extracts content from repeatable components without content', () => {
const normalizedContent = hasContent(
'component',
[],
{ mainField: { name: 'content_2' } },
{ repeatable: true }
);
expect(normalizedContent).toEqual(false);
});
it('extracts content from multiple relations with content', () => {
const normalizedContent = hasContent('relation', { count: 1 }, undefined, {
relation: 'manyToMany',
});
expect(normalizedContent).toEqual(true);
});
it('extracts content from multiple relations without content', () => {
const normalizedContent = hasContent('relation', { count: 0 }, undefined, {
relation: 'manyToMany',
});
expect(normalizedContent).toEqual(false);
});
it('extracts content from single relations with content', () => {
const normalizedContent = hasContent('relation', { id: 1 }, undefined, {
relation: 'oneToOne',
});
expect(normalizedContent).toEqual(true);
});
it('extracts content from single relations without content', () => {
const normalizedContent = hasContent('relation', null, undefined, {
relation: 'oneToOne',
});
expect(normalizedContent).toEqual(false);
});
it('returns oneToManyMorph relations as false with content', () => {
const normalizedContent = hasContent('relation', { id: 1 }, undefined, {
relation: 'oneToManyMorph',
});
expect(normalizedContent).toEqual(false);
});
it('extracts content from oneToManyMorph relations with content', () => {
const normalizedContent = hasContent('relation', { id: 1 }, undefined, {
relation: 'oneToOneMorph',
});
expect(normalizedContent).toEqual(true);
});
it('extracts content from oneToManyMorph relations with content', () => {
const normalizedContent = hasContent('relation', null, undefined, {
relation: 'oneToOneMorph',
});
expect(normalizedContent).toEqual(false);
});
});

View File

@ -0,0 +1,13 @@
import isSingleRelation from '../isSingleRelation';
describe('isSingleRelation', () => {
['oneToOne', 'manyToOne', 'oneToOneMorph'].forEach(type => {
test(`is single relation: ${type}`, () => {
expect(isSingleRelation(type)).toBeTruthy();
});
});
test('is not single relation', () => {
expect(isSingleRelation('manyToMany')).toBeFalsy();
});
});

View File

@ -40,7 +40,7 @@ const formatLayouts = (initialData, models) => {
const formattedCTEditLayout = formatLayoutWithMetas(data.contentType, null, models);
const ctUid = data.contentType.uid;
const formattedEditRelationsLayout = formatEditRelationsLayoutWithMetas(data.contentType, models);
const formattedListLayout = formatListLayoutWithMetas(data.contentType, models);
const formattedListLayout = formatListLayoutWithMetas(data.contentType, data.components);
set(data, ['contentType', 'layouts', 'edit'], formattedCTEditLayout);
set(data, ['contentType', 'layouts', 'editRelations'], formattedEditRelationsLayout);
@ -146,7 +146,7 @@ const formatLayoutWithMetas = (contentTypeConfiguration, ctUid, models) => {
return formatted;
};
const formatListLayoutWithMetas = contentTypeConfiguration => {
const formatListLayoutWithMetas = (contentTypeConfiguration, components) => {
const formatted = contentTypeConfiguration.layouts.list.reduce((acc, current) => {
const fieldSchema = get(contentTypeConfiguration, ['attributes', current], {});
const metadatas = get(contentTypeConfiguration, ['metadatas', current, 'list'], {});
@ -164,6 +164,27 @@ const formatListLayoutWithMetas = contentTypeConfiguration => {
return acc;
}
if (type === 'component') {
const component = components[fieldSchema.component];
const mainFieldName = component.settings.mainField;
const mainFieldAttribute = component.attributes[mainFieldName];
acc.push({
key: `__${current}_key__`,
name: current,
fieldSchema,
metadatas: {
...metadatas,
mainField: {
...mainFieldAttribute,
name: mainFieldName,
},
},
});
return acc;
}
acc.push({ key: `__${current}_key__`, name: current, fieldSchema, metadatas });
return acc;

View File

@ -485,12 +485,22 @@ describe('Content Manager | hooks | useFetchContentTypeLayout | utils ', () => {
const data = {
uid: 'address',
layouts: {
list: ['test', 'categories'],
list: ['test', 'categories', 'component'],
},
metadatas: {
test: {
list: { ok: true },
},
component: {
list: {
mainField: {
name: 'name',
schema: {
type: 'string',
},
},
},
},
categories: {
list: {
ok: true,
@ -509,6 +519,22 @@ describe('Content Manager | hooks | useFetchContentTypeLayout | utils ', () => {
type: 'relation',
targetModel: 'category',
},
component: {
type: 'component',
component: 'some.component',
repeatable: false,
},
},
};
const components = {
'some.component': {
settings: {
mainField: 'name',
},
attributes: {
type: 'string',
},
},
};
const expected = [
@ -533,9 +559,23 @@ describe('Content Manager | hooks | useFetchContentTypeLayout | utils ', () => {
fieldSchema: { type: 'relation', targetModel: 'category' },
queryInfos: { defaultParams: {}, endPoint: 'collection-types/address' },
},
{
name: 'component',
key: '__component_key__',
metadatas: {
mainField: {
name: 'name',
},
},
fieldSchema: {
type: 'component',
component: 'some.component',
repeatable: false,
},
},
];
expect(formatListLayoutWithMetas(data)).toEqual(expected);
expect(formatListLayoutWithMetas(data, components)).toEqual(expected);
});
});

View File

@ -21,11 +21,12 @@ export function resetProps() {
return { type: RESET_PROPS };
}
export const setLayout = contentType => {
export const setLayout = ({ components, contentType }) => {
const { layouts } = contentType;
return {
contentType,
components,
displayedHeaders: layouts.list,
type: SET_LIST_LAYOUT,
};

View File

@ -4,6 +4,7 @@
*/
import produce from 'immer';
import get from 'lodash/get';
import {
GET_DATA,
GET_DATA_SUCCEEDED,
@ -17,6 +18,7 @@ export const initialState = {
data: [],
isLoading: true,
contentType: {},
components: [],
initialDisplayedHeaders: [],
displayedHeaders: [],
pagination: {
@ -26,21 +28,22 @@ export const initialState = {
const listViewReducer = (state = initialState, action) =>
// eslint-disable-next-line consistent-return
produce(state, drafState => {
produce(state, draftState => {
switch (action.type) {
case GET_DATA: {
return {
...initialState,
contentType: state.contentType,
components: state.components,
initialDisplayedHeaders: state.initialDisplayedHeaders,
displayedHeaders: state.displayedHeaders,
};
}
case GET_DATA_SUCCEEDED: {
drafState.pagination = action.pagination;
drafState.data = action.data;
drafState.isLoading = false;
draftState.pagination = action.pagination;
draftState.data = action.data;
draftState.isLoading = false;
break;
}
@ -59,19 +62,49 @@ const listViewReducer = (state = initialState, action) =>
key: `__${name}_key__`,
};
if (attributes[name].type === 'relation') {
drafState.displayedHeaders.push({
...header,
queryInfos: {
defaultParams: {},
endPoint: `collection-types/${uid}`,
},
});
} else {
drafState.displayedHeaders.push(header);
switch (attributes[name].type) {
case 'component': {
const componentName = attributes[name].component;
const mainFieldName = get(
state,
['components', componentName, 'settings', 'mainField'],
null
);
const mainFieldAttribute = get(state, [
'components',
componentName,
'attributes',
mainFieldName,
]);
draftState.displayedHeaders.push({
...header,
metadatas: {
...metas,
mainField: {
...mainFieldAttribute,
name: mainFieldName,
},
},
});
break;
}
case 'relation':
draftState.displayedHeaders.push({
...header,
queryInfos: {
defaultParams: {},
endPoint: `collection-types/${uid}`,
},
});
break;
default:
draftState.displayedHeaders.push(header);
}
} else {
drafState.displayedHeaders = state.displayedHeaders.filter(
draftState.displayedHeaders = state.displayedHeaders.filter(
header => header.name !== name
);
}
@ -79,23 +112,24 @@ const listViewReducer = (state = initialState, action) =>
break;
}
case ON_RESET_LIST_HEADERS: {
drafState.displayedHeaders = state.initialDisplayedHeaders;
draftState.displayedHeaders = state.initialDisplayedHeaders;
break;
}
case RESET_PROPS: {
return initialState;
}
case SET_LIST_LAYOUT: {
const { contentType, displayedHeaders } = action;
const { contentType, components, displayedHeaders } = action;
drafState.contentType = contentType;
drafState.displayedHeaders = displayedHeaders;
drafState.initialDisplayedHeaders = displayedHeaders;
draftState.contentType = contentType;
draftState.components = components;
draftState.displayedHeaders = displayedHeaders;
draftState.initialDisplayedHeaders = displayedHeaders;
break;
}
default:
return drafState;
return draftState;
}
});

View File

@ -10,6 +10,7 @@ describe('CONTENT MANAGER | CONTAINERS | ListView | reducer', () => {
state = {
data: [],
isLoading: true,
components: [],
contentType: {},
initialDisplayedHeaders: [],
displayedHeaders: [],

View File

@ -21,7 +21,7 @@ const ListViewLayout = ({ layout, ...props }) => {
}, [rawQuery, replace, redirectionLink]);
useEffect(() => {
dispatch(setLayout(layout.contentType));
dispatch(setLayout(layout));
}, [dispatch, layout]);
useEffect(() => {

View File

@ -7,7 +7,7 @@ const checkIfAttributeIsDisplayable = attribute => {
return !toLower(attribute.relationType).includes('morph');
}
return !['json', 'component', 'dynamiczone', 'richtext', 'password'].includes(type) && !!type;
return !['json', 'dynamiczone', 'richtext', 'password'].includes(type) && !!type;
};
export default checkIfAttributeIsDisplayable;

View File

@ -448,7 +448,9 @@
"components.popUpWarning.message": "Are you sure you want to delete this?",
"components.popUpWarning.title": "Please confirm",
"content-manager.App.schemas.data-loaded": "The schemas have been successfully loaded",
"content-manager.DynamicTable.relation-loaded": "The relations have been loaded",
"content-manager.DynamicTable.relation-loaded": "Relations have been loaded",
"content-manager.DynamicTable.relation-loading": "Relations are loading",
"content-manager.DynamicTable.relation-more": "This relation contains more entities than displayed",
"content-manager.EditRelations.title": "Relational data",
"content-manager.HeaderLayout.button.label-add-entry": "Create new entry",
"content-manager.api.id": "API ID",