mirror of
https://github.com/strapi/strapi.git
synced 2025-11-09 22:59:14 +00:00
Display relations and medias
Signed-off-by: soupette <cyril@strapi.io>
This commit is contained in:
parent
39abc06a21
commit
5a7dbe742a
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
115
examples/getstarted/api/temp/content-types/temp/schema.json
Normal file
115
examples/getstarted/api/temp/content-types/temp/schema.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
examples/getstarted/api/temp/controllers/temp.js
Normal file
15
examples/getstarted/api/temp/controllers/temp.js
Normal 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;
|
||||
// }
|
||||
// }
|
||||
};
|
||||
44
examples/getstarted/api/temp/routes/temp.js
Normal file
44
examples/getstarted/api/temp/routes/temp.js
Normal 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: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user