Display relations and medias

Signed-off-by: soupette <cyril@strapi.io>
This commit is contained in:
soupette 2021-09-13 16:11:18 +02:00
parent 39abc06a21
commit 5a7dbe742a
14 changed files with 549 additions and 9 deletions

View File

@ -115,6 +115,30 @@
"component": "blog.test-como",
"required": false,
"min": 2
},
"temp_one_to_one": {
"type": "relation",
"relation": "oneToOne",
"target": "api::temp.temp",
"inversedBy": "one_to_one"
},
"temp": {
"type": "relation",
"relation": "manyToOne",
"target": "api::temp.temp",
"inversedBy": "one_to_many"
},
"temps": {
"type": "relation",
"relation": "oneToMany",
"target": "api::temp.temp",
"mappedBy": "many_to_one"
},
"many_to_many_temp": {
"type": "relation",
"relation": "manyToMany",
"target": "api::temp.temp",
"inversedBy": "many_to_many"
}
}
}

View File

@ -5,15 +5,26 @@
"displayName": "Category",
"singularName": "category",
"pluralName": "categories",
"description": ""
"description": "",
"name": "Category"
},
"options": {
"draftAndPublish": true,
"comment": ""
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"name": {
"type": "string"
"type": "string",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"addresses": {
"type": "relation",

View File

@ -0,0 +1,115 @@
{
"kind": "collectionType",
"collectionName": "temps",
"info": {
"singularName": "temp",
"pluralName": "temps",
"displayName": "temp",
"name": "temp",
"description": ""
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {},
"attributes": {
"short": {
"type": "string"
},
"long": {
"type": "string"
},
"email": {
"type": "email"
},
"rich": {
"type": "richtext"
},
"password": {
"type": "password"
},
"number": {
"type": "integer"
},
"enum": {
"type": "enumeration",
"enum": [
"one",
"two",
"three"
]
},
"date": {
"type": "date"
},
"datetime": {
"type": "datetime"
},
"time": {
"type": "time"
},
"single": {
"type": "media",
"multiple": false,
"required": false,
"allowedTypes": [
"images",
"files",
"videos"
]
},
"multiple": {
"type": "media",
"multiple": true,
"required": false,
"allowedTypes": [
"images",
"files",
"videos"
]
},
"bool": {
"type": "boolean"
},
"json": {
"type": "json"
},
"uid": {
"type": "uid"
},
"one_way": {
"type": "relation",
"relation": "oneToOne",
"target": "api::address.address"
},
"one_to_one": {
"type": "relation",
"relation": "oneToOne",
"target": "api::address.address",
"inversedBy": "temp_one_to_one"
},
"one_to_many": {
"type": "relation",
"relation": "oneToMany",
"target": "api::address.address",
"mappedBy": "temp"
},
"many_to_one": {
"type": "relation",
"relation": "manyToOne",
"target": "api::address.address",
"inversedBy": "temps"
},
"many_to_many": {
"type": "relation",
"relation": "manyToMany",
"target": "api::address.address",
"inversedBy": "many_to_many_temp"
},
"many_way": {
"type": "relation",
"relation": "oneToMany",
"target": "api::address.address"
}
}
}

View File

@ -0,0 +1,15 @@
'use strict';
/**
* A set of functions called "actions" for `temp`
*/
module.exports = {
// exampleAction: async (ctx, next) => {
// try {
// ctx.body = 'ok';
// } catch (err) {
// ctx.body = err;
// }
// }
};

View File

@ -0,0 +1,44 @@
module.exports = {
routes: [
{
method: 'GET',
path: '/temps',
handler: 'temp.find',
config: {
policies: [],
},
},
{
method: 'GET',
path: '/temps/:id',
handler: 'temp.findOne',
config: {
policies: [],
},
},
{
method: 'POST',
path: '/temps',
handler: 'temp.create',
config: {
policies: [],
},
},
{
method: 'PUT',
path: '/temps/:id',
handler: 'temp.update',
config: {
policies: [],
},
},
{
method: 'DELETE',
path: '/temps/:id',
handler: 'temp.delete',
config: {
policies: [],
},
},
],
};

View File

@ -0,0 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Avatar } from '@strapi/parts/Avatar';
import { Text } from '@strapi/parts/Text';
import { getFileExtension, prefixFileUrlWithBackendUrl } from '@strapi/helper-plugin';
const Media = ({ url, mime, alternativeText, name, ext, formats }) => {
const fileURL = prefixFileUrlWithBackendUrl(url);
if (mime.includes('image')) {
const thumbnail = formats?.thumbnail?.url || null;
const mediaURL = prefixFileUrlWithBackendUrl(thumbnail) || fileURL;
return <Avatar src={mediaURL} alt={alternativeText || name} preview />;
}
const fileExtension = getFileExtension(ext);
return <Text textColor="neutral800">{fileExtension}</Text>;
};
Media.defaultProps = {
alternativeText: null,
formats: null,
};
Media.propTypes = {
alternativeText: PropTypes.string,
ext: PropTypes.string.isRequired,
formats: PropTypes.object,
mime: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
};
export default Media;

View File

@ -0,0 +1,8 @@
import React from 'react';
import { Text } from '@strapi/parts/Text';
const MultipleMedia = () => {
return <Text textColor="neutral800">TODO</Text>;
};
export default MultipleMedia;

View File

@ -0,0 +1,78 @@
import React from 'react';
import { useQuery } from 'react-query';
import { useIntl } from 'react-intl';
import PropTypes from 'prop-types';
import { Box } from '@strapi/parts/Box';
import { Text } from '@strapi/parts/Text';
import { Loader } from '@strapi/parts/Loader';
import { useNotifyAT } from '@strapi/parts/LiveRegions';
import { axiosInstance } from '../../../../../core/utils';
import { getRequestUrl } from '../../../../utils';
import formatDisplayedValue from '../utils/formatDisplayedValue';
const fetchRelation = async (endPoint, notifyStatus) => {
const {
data: { results, pagination },
} = await axiosInstance.get(endPoint);
notifyStatus('The relations has been loaded');
return { results, pagination };
};
const PopoverContent = ({ fieldSchema, name, rowId, targetModel, queryInfos }) => {
const requestURL = getRequestUrl(`${queryInfos.endPoint}/${rowId}/${name}`);
const { formatDate, formatTime, formatNumber, formatMessage } = useIntl();
const { notifyStatus } = useNotifyAT();
const { data, status } = useQuery([targetModel, rowId], () =>
fetchRelation(requestURL, notifyStatus)
);
if (status !== 'success') {
return (
<Box>
<Loader>Loading content</Loader>
</Box>
);
}
return (
<ul>
{data?.results.map(entry => {
const displayedValue = entry[fieldSchema.name]
? formatDisplayedValue(entry[fieldSchema.name], fieldSchema.type, {
formatDate,
formatTime,
formatNumber,
formatMessage,
})
: '-';
return (
<Box as="li" key={entry.id} padding={3}>
<Text>{displayedValue}</Text>
</Box>
);
})}
{data?.pagination.total > 10 && (
<Box as="li" padding={3}>
<Text>[...]</Text>
</Box>
)}
</ul>
);
};
PopoverContent.propTypes = {
fieldSchema: PropTypes.shape({ name: PropTypes.string, type: PropTypes.string }).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

@ -0,0 +1,114 @@
import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import toString from 'lodash/toString';
import { IconButton } from '@strapi/parts/IconButton';
import { Text } from '@strapi/parts/Text';
import { Box } from '@strapi/parts/Box';
import { Badge } from '@strapi/parts/Badge';
import { Row } from '@strapi/parts/Row';
import { Popover } from '@strapi/parts/Popover';
import { SortIcon } from '@strapi/helper-plugin';
import styled from 'styled-components';
import formatDisplayedValue from '../utils/formatDisplayedValue';
import PopoverContent from './PopoverContent';
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 { formatDate, formatTime, formatNumber, formatMessage } = useIntl();
const [visible, setVisible] = useState(false);
const buttonRef = useRef();
if (SINGLE_RELATIONS.includes(fieldSchema.relation)) {
const formattedValue = formatDisplayedValue(
value[metadatas.mainField.name],
metadatas.mainField.schema.type,
{
formatDate,
formatTime,
formatNumber,
}
);
return <Text textColor="neutral800">{toString(formattedValue)}</Text>;
}
const handleTogglePopover = () => setVisible(prev => !prev);
return (
<Row>
<RelationCountBadge>{value.count}</RelationCountBadge>
<Box paddingLeft={2}>
<Text textColor="neutral800">
{formatMessage(
{
id: 'content-manager.containers.ListPage.items',
defaultMessage: '{number, plural, =0 {items} one {item} other {items}}',
},
{ number: value.count }
)}
</Text>
</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} spacingTop={4} centered>
<PopoverContent
queryInfos={queryInfos}
name={name}
fieldSchema={metadatas.mainField}
targetModel={fieldSchema.targetModel}
rowId={rowId}
count={value.count}
/>
</Popover>
)}
</ActionWrapper>
)}
</Row>
);
};
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,63 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Text } from '@strapi/parts/Text';
import { useIntl } from 'react-intl';
import toString from 'lodash/toString';
import formatDisplayedValue from './utils/formatDisplayedValue';
import Media from './Media';
import MultipleMedias from './MultipleMedias';
import Relation from './Relation';
const CellContent = ({ content, fieldSchema, metadatas, name, queryInfos, rowId }) => {
const { formatDate, formatTime, formatNumber } = useIntl();
if (content === null || content === undefined) {
return <Text textColor="neutral800">-</Text>;
}
let formattedContent = formatDisplayedValue(content, fieldSchema.type, {
formatDate,
formatTime,
formatNumber,
});
if (fieldSchema.type === 'media' && !fieldSchema.multiple) {
return <Media {...content} />;
}
if (fieldSchema.type === 'media' && fieldSchema.multiple) {
return <MultipleMedias {...content} />;
}
if (fieldSchema.type === 'relation') {
return (
<Relation
fieldSchema={fieldSchema}
queryInfos={queryInfos}
metadatas={metadatas}
value={content}
name={name}
rowId={rowId}
/>
);
}
return <Text textColor="neutral800">{toString(formattedContent)}</Text>;
};
CellContent.defaultProps = {
content: undefined,
queryInfos: undefined,
};
CellContent.propTypes = {
content: PropTypes.any,
fieldSchema: PropTypes.shape({ multiple: PropTypes.bool, type: PropTypes.string.isRequired })
.isRequired,
metadatas: PropTypes.object.isRequired,
name: PropTypes.string.isRequired,
rowId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
queryInfos: PropTypes.shape({ endPoint: PropTypes.string.isRequired }),
};
export default CellContent;

View File

@ -0,0 +1,32 @@
const formatDisplayedValue = (value, type, formatters) => {
let formattedValue = value;
if (type === 'date') {
formattedValue = formatters.formatDate(value, { dateStyle: 'full' });
}
if (type === 'datetime') {
formattedValue = formatters.formatDate(value, { dateStyle: 'full', timeStyle: 'short' });
}
if (type === 'time') {
const [hour, minute, second] = value.split(':');
const date = new Date();
date.setHours(hour);
date.setMinutes(minute);
date.setSeconds(second);
formattedValue = formatters.formatTime(date, {
numeric: 'auto',
style: 'short',
});
}
if (['float', 'integer', 'biginteger', 'decimal'].includes(type)) {
formattedValue = formatters.formatNumber(value);
}
return formattedValue;
};
export default formatDisplayedValue;

View File

@ -1,13 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import { BaseCheckbox, Box, IconButton, Tbody, Td, Text, Tr, Row } from '@strapi/parts';
import { BaseCheckbox, Box, IconButton, Tbody, Td, Tr, Row } from '@strapi/parts';
import { EditIcon, DeleteIcon, Duplicate } from '@strapi/icons';
import { useTracking } from '@strapi/helper-plugin';
import { useHistory } from 'react-router-dom';
import { useIntl } from 'react-intl';
import { usePluginsQueryParams } from '../../../hooks';
// import { Tooltip } from '@strapi/parts/Tooltip';
// import toString from 'lodash/toString';
import CellContent from '../CellContent';
const TableRows = ({
canCreate,
@ -25,6 +24,7 @@ const TableRows = ({
location: { pathname },
} = useHistory();
const { formatMessage } = useIntl();
const { trackUsage } = useTracking();
const pluginsQueryParams = usePluginsQueryParams();
@ -65,7 +65,7 @@ const TableRows = ({
{typeof cellFormatter === 'function' ? (
cellFormatter(data, { key, name, ...rest })
) : (
<Text textColor="neutral800">{data[name] || '-'}</Text>
<CellContent content={data[name]} name={name} {...rest} rowId={data.id} />
)}
</Td>
);

View File

@ -169,7 +169,7 @@ function ListView({
'{number, plural, =1 {# entry has} other {# entries have}} successfully been loaded',
},
// Using the plural form
{ number: 2 }
{ number: pagination.count }
)
);

View File

@ -480,6 +480,7 @@
"content-manager.containers.Edit.returnList": "Return to list",
"content-manager.containers.Edit.seeDetails": "Details",
"content-manager.containers.Edit.submit": "Save",
"content-manager.popover.display-relations.label": "Display relations",
"content-manager.containers.EditSettingsView.modal-form.edit-field": "Edit the field",
"content-manager.containers.EditView.add.new": "ADD NEW ENTRY",
"content-manager.containers.EditView.components.missing.plural": "There is {count} missing components",
@ -495,8 +496,7 @@
"content-manager.pages.ListView.header-subtitle": "{number, plural, =0 {# entries} one {# entry} other {# entries}} found",
"content-manager.containers.List.published": "Published",
"content-manager.containers.ListPage.displayedFields": "Displayed Fields",
"content-manager.containers.ListPage.items.plural": "items",
"content-manager.containers.ListPage.items.singular": "item",
"content-manager.containers.ListPage.items": "{number, plural, =0 {items} one {item} other {items}}",
"content-manager.containers.ListPage.table-headers.published_at": "State",
"content-manager.containers.ListSettingsView.modal-form.edit-label": "Edit the label",
"content-manager.containers.SettingPage.add.field": "Insert another field",