Merge branch 'main' into fix/issue-9308-File_upload_related_fields_returning_null

This commit is contained in:
Nathan Pichon 2023-01-18 17:44:55 +01:00 committed by GitHub
commit 14ba46950b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 335 additions and 192 deletions

View File

@ -116,7 +116,7 @@ filter out the relation from the array of relations. This is handled inside the
:::note :::note
Connecting relations adds the item to the end of the list, whilst loading more relations prepends to Connecting relations adds the item to the end of the list, whilst loading more relations prepends to
the beginning of the list. This is the expected behaviour. the beginning of the list. This is the expected behaviour, to keep the order of the list in the UI in sync with the API response.
::: :::
The `RelationInput` component takes the field in `modifiedData` as its source of truth. You could therefore consider this to The `RelationInput` component takes the field in `modifiedData` as its source of truth. You could therefore consider this to
@ -126,7 +126,7 @@ data for the api.
### Cleaning data to be posted to the API ### Cleaning data to be posted to the API
The API to update the enttiy expects relations to be categorised into two groups, a `connect` array and `disconnect` array. The API to update the entity expects relations to be categorised into two groups, a `connect` array and `disconnect` array.
You could do this as the user interacts with the input but we found this to be confusing and then involved us managing three You could do this as the user interacts with the input but we found this to be confusing and then involved us managing three
different arrays which makes the code more complex. Instead, because the browser doesn't really care about whats new and removed different arrays which makes the code more complex. Instead, because the browser doesn't really care about whats new and removed
and we have a copy of the slice of data we're mutating from the server we can run a small diff algorithm to determine which and we have a copy of the slice of data we're mutating from the server we can run a small diff algorithm to determine which
@ -142,3 +142,85 @@ relations have been connected and which have been disconnected. Returning an obj
``` ```
## Frontend component architecture ## Frontend component architecture
The input field for relation fields consist of two components:
### `RelationInputDataManager`
This container component handles data fetching and data normalization for the `RelationInput` component. This has been extracted from
the `RelationInput` so that Strapi is able to move the underlying component into the design-system if the community would need it
(most other input components can be consumed from there).
### `RelationInput`
This component is the presentational counterpart to the `RelationInputDataManager` component. It renders an input field based on the data passed from the data manager.
Under the hood it is using `react-window` to render a list of relations in a virtualized view. Some fields need to render thousands of relations, which
would otherwise have a negative impact on the overall performance of the content-manager.
## useRelation() hook
This hook takes care of data-fetching and normalizes results relations aswell as search-results.
```ts
const { relations: RelationResults, search: RelationResults, searchFor } = useRelation(reactQueryCacheKey: string, options: Options);
```
### `Options`
`option`s is a mandatory configuration and should implement the following shape:
```ts
type Options = {
name: string; // name of the relation field
relation: RelationConfiguration;
search: SearchConfiguration;
}
type RelationConfiguration = {
endpoint: string; // URL from where existing relations should be fetched
enabled: boolean; // defines whether relations should be fetched once the hook is called
pageParams: object; // additional query params which will be appended to `endpoint`
onLoad: (results: RelationResult[]) => void; // callback that will be fired after relations have been fetched (paginated)
normalizeArguments = {
mainFieldName: string; // name of the target model main field, determining which field to display (fallback: id)
shouldAddLink: boolean; // if the user is allowed to read the target model, the returned relations should include a link to the target
targetModel: object; // target content-type model
};
pageGoal: number; // the current page-count of the already loaded relations used to keep the redux store and query cache in sync.
}
type SearchConfiguration = {
endpoint: string; // URL from where new relations should be fetched
pageParams: object; // additional query params which will be appended to `endpoint`
}
```
### Return values
`relations` and `search` both return a consistent relation format:
```ts
type RelationResults = RelationResult[];
type RelationResult = {
id: number;
href?: string; // based on `shouldAddLink` and the `targetModel`
publicationState: 'draft' | 'published';
mainField: string; // will fallback to "id" if not set
}
```
#### `relations`
`relations` refers to a [inifinite-query return type](https://tanstack.com/query/v4/docs/react/guides/infinite-queries) from react-query. It exposes paginated relational data
aswell as methods to check if there are more pages or fetch more paginated results. Relations for a given field are fetched as soon as the hook is called.
#### `search`
`search` refers to a [inifinite-query return type](https://tanstack.com/query/v4/docs/react/guides/infinite-queries) from react-query. It exposes paginated search results
for a relational field. Search results are only fetched after `searchFor()` has been called.
#### `searchFor(string)`
`searchFor` is a method which can be used to search for entities which haven't been connected with the source entity yet. The method accepts a search-term: `searchFor("term")`.

View File

@ -14,7 +14,7 @@ The following example shows a basic way to use the `useFetchClient` hook to make
```jsx ```jsx
import {useState} from "react" import {useState} from "react"
import useFetchClient from '@strapi/admin/admin/src/hooks/useFetchClient'; import { useFetchClient } from '@strapi/helper-plugin';
const Component = () => { const Component = () => {
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
@ -57,7 +57,7 @@ The following information is the internal additions we've added to the axios ins
### Base URL ### Base URL
The default URL will be the one defined in the environment variable: `STRAPI_ADMIN_BACKEND_URL`. The default URL is the one defined in the _getFetchClient_ utility
### Interceptors ### Interceptors
@ -68,8 +68,6 @@ The request interceptor adds the following parameters to the header:
```js ```js
{ {
Authorization: `Bearer <AUTH_TOKEN>`, Authorization: `Bearer <AUTH_TOKEN>`,
Accept: 'application/json',
'Content-Type': 'application/json',
} }
``` ```

View File

@ -6,7 +6,7 @@ import { Link } from '@strapi/design-system/v2/Link';
const Notification = ({ dispatch, notification }) => { const Notification = ({ dispatch, notification }) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { message, link, type, id, onClose, timeout, blockTransition } = notification; const { message, link, type, id, onClose, timeout, blockTransition, title } = notification;
const formattedMessage = (msg) => const formattedMessage = (msg) =>
typeof msg === 'string' ? msg : formatMessage(msg, msg.values); typeof msg === 'string' ? msg : formatMessage(msg, msg.values);
@ -37,6 +37,7 @@ const Notification = ({ dispatch, notification }) => {
let variant; let variant;
let alertTitle; let alertTitle;
// TODO break out this logic into separate file
if (type === 'info') { if (type === 'info') {
variant = 'default'; variant = 'default';
alertTitle = formatMessage({ alertTitle = formatMessage({
@ -44,17 +45,29 @@ const Notification = ({ dispatch, notification }) => {
defaultMessage: 'Information:', defaultMessage: 'Information:',
}); });
} else if (type === 'warning') { } else if (type === 'warning') {
// type should be renamed to danger in the future, but it might introduce changes if done now
variant = 'danger';
alertTitle = formatMessage({
id: 'notification.warning.title',
defaultMessage: 'Warning:',
});
} else if (type === 'softWarning') {
// type should be renamed to just warning in the future
variant = 'warning';
alertTitle = formatMessage({ alertTitle = formatMessage({
id: 'notification.warning.title', id: 'notification.warning.title',
defaultMessage: 'Warning:', defaultMessage: 'Warning:',
}); });
variant = 'danger';
} else { } else {
variant = 'success';
alertTitle = formatMessage({ alertTitle = formatMessage({
id: 'notification.success.title', id: 'notification.success.title',
defaultMessage: 'Success:', defaultMessage: 'Success:',
}); });
variant = 'success'; }
if (title) {
alertTitle = typeof title === 'string' ? title : formatMessage(title);
} }
return ( return (
@ -125,6 +138,14 @@ Notification.propTypes = {
onClose: PropTypes.func, onClose: PropTypes.func,
timeout: PropTypes.number, timeout: PropTypes.number,
blockTransition: PropTypes.bool, blockTransition: PropTypes.bool,
title: PropTypes.oneOfType([
PropTypes.string,
PropTypes.shape({
id: PropTypes.string.isRequired,
defaultMessage: PropTypes.string,
values: PropTypes.object,
}),
]),
}), }),
}; };

View File

@ -23,6 +23,7 @@ const notificationReducer = (state = initialState, action) =>
timeout: get(action, ['config', 'timeout'], 2500), timeout: get(action, ['config', 'timeout'], 2500),
blockTransition: get(action, ['config', 'blockTransition'], false), blockTransition: get(action, ['config', 'blockTransition'], false),
onClose: get(action, ['config', 'onClose'], null), onClose: get(action, ['config', 'onClose'], null),
title: get(action, ['config', 'title'], null),
}); });
draftState.notifId = state.notifId + 1; draftState.notifId = state.notifId + 1;
break; break;

View File

@ -36,6 +36,7 @@ describe('ADMIN | COMPONENTS | NOTIFICATIONS | reducer', () => {
timeout: 2500, timeout: 2500,
blockTransition: false, blockTransition: false,
onClose: null, onClose: null,
title: null,
}, },
], ],
notifId: 1, notifId: 1,

View File

@ -10,4 +10,3 @@ export { default as usePermissionsDataManager } from './usePermissionsDataManage
export { default as useReleaseNotification } from './useReleaseNotification'; export { default as useReleaseNotification } from './useReleaseNotification';
export { default as useThemeToggle } from './useThemeToggle'; export { default as useThemeToggle } from './useThemeToggle';
export { default as useRegenerate } from './useRegenerate'; export { default as useRegenerate } from './useRegenerate';
export { default as useFetchClient } from './useFetchClient';

View File

@ -14,6 +14,7 @@ import {
TrackingProvider, TrackingProvider,
prefixFileUrlWithBackendUrl, prefixFileUrlWithBackendUrl,
useAppInfos, useAppInfos,
useFetchClient,
} from '@strapi/helper-plugin'; } from '@strapi/helper-plugin';
import axios from 'axios'; import axios from 'axios';
import { SkipToContent } from '@strapi/design-system/Main'; import { SkipToContent } from '@strapi/design-system/Main';
@ -25,7 +26,7 @@ import NotFoundPage from '../NotFoundPage';
import UseCasePage from '../UseCasePage'; import UseCasePage from '../UseCasePage';
import { getUID } from './utils'; import { getUID } from './utils';
import routes from './utils/routes'; import routes from './utils/routes';
import { useConfigurations, useFetchClient } from '../../hooks'; import { useConfigurations } from '../../hooks';
const AuthenticatedApp = lazy(() => const AuthenticatedApp = lazy(() =>
import(/* webpackChunkName: "Admin-authenticatedApp" */ '../../components/AuthenticatedApp') import(/* webpackChunkName: "Admin-authenticatedApp" */ '../../components/AuthenticatedApp')

View File

@ -3,3 +3,7 @@
## Description ## Description
Helper to develop Strapi plugins. Helper to develop Strapi plugins.
## Contributing
Please read our [Contributing Guide](../../../CONTRIBUTING.md) before submitting a Pull Request to the project.

View File

@ -1,5 +1,10 @@
/**
*
* useFetchClient
*
*/
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { getFetchClient } from '../../utils/getFetchClient'; import getFetchClient from '../../utils/getFetchClient';
const useFetchClient = () => { const useFetchClient = () => {
const controller = useRef(null); const controller = useRef(null);
@ -7,6 +12,7 @@ const useFetchClient = () => {
if (controller.current === null) { if (controller.current === null) {
controller.current = new AbortController(); controller.current = new AbortController();
} }
useEffect(() => { useEffect(() => {
return () => { return () => {
controller.current.abort(); controller.current.abort();

View File

@ -20,7 +20,7 @@ const HomePage = () => {
const handleClick = () => { const handleClick = () => {
toggleNotification({ toggleNotification({
// required // required
type: 'info|success|warning', type: 'info|success|warning|softWarning',
// required // required
message: { id: 'notification.version.update.message', defaultMessage: 'A new version is available' }, message: { id: 'notification.version.update.message', defaultMessage: 'A new version is available' },
// optional // optional
@ -35,6 +35,8 @@ const HomePage = () => {
blockTransition: true, blockTransition: true,
// optional // optional
onClose: () => localStorage.setItem('STRAPI_UPDATE_NOTIF', true), onClose: () => localStorage.setItem('STRAPI_UPDATE_NOTIF', true),
// optional
title: { id: 'notification.default.title, defaultMessage: 'Warning: '}
}); });
} }

View File

@ -29,6 +29,7 @@ export { default as useRBAC } from './hooks/useRBAC';
export { default as usePersistentState } from './hooks/usePersistentState'; export { default as usePersistentState } from './hooks/usePersistentState';
export { default as useFocusWhenNavigate } from './hooks/useFocusWhenNavigate'; export { default as useFocusWhenNavigate } from './hooks/useFocusWhenNavigate';
export { default as useLockScroll } from './hooks/useLockScroll'; export { default as useLockScroll } from './hooks/useLockScroll';
export { default as useFetchClient } from './hooks/useFetchClient';
// Providers // Providers
export { default as GuidedTourProvider } from './providers/GuidedTourProvider'; export { default as GuidedTourProvider } from './providers/GuidedTourProvider';
@ -100,3 +101,4 @@ export { default as wrapAxiosInstance } from './utils/wrapAxiosInstance';
export { default as request } from './utils/request'; export { default as request } from './utils/request';
export { default as getAPIInnerErrors } from './utils/getAPIInnerErrors'; export { default as getAPIInnerErrors } from './utils/getAPIInnerErrors';
export { default as getYupInnerErrors } from './utils/getYupInnerErrors'; export { default as getYupInnerErrors } from './utils/getYupInnerErrors';
export { default as getFetchClient } from './utils/getFetchClient';

View File

@ -1,11 +1,9 @@
import axios from 'axios'; import axios from 'axios';
import { auth } from '@strapi/helper-plugin'; import auth from '../auth';
export const reqInterceptor = async (config) => { export const reqInterceptor = async (config) => {
config.headers = { config.headers = {
Authorization: `Bearer ${auth.getToken()}`, Authorization: `Bearer ${auth.getToken()}`,
Accept: 'application/json',
'Content-Type': 'application/json',
}; };
return config; return config;
@ -33,13 +31,16 @@ export const addInterceptors = (instance) => {
instance.interceptors.response.use(resInterceptor, resErrorInterceptor); instance.interceptors.response.use(resInterceptor, resErrorInterceptor);
}; };
export const fetchClient = ({ baseURL }) => { export const fetchClient = () => {
const instance = axios.create({ const instance = axios.create({
baseURL, headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
}); });
addInterceptors(instance); addInterceptors(instance);
return instance; return instance;
}; };
export default fetchClient({ baseURL: process.env.STRAPI_ADMIN_BACKEND_URL }); export default fetchClient();

View File

@ -1,5 +1,5 @@
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { auth } from '@strapi/helper-plugin'; import auth from '../../auth';
import { import {
reqInterceptor, reqInterceptor,
reqErrorInterceptor, reqErrorInterceptor,
@ -7,13 +7,13 @@ import {
resErrorInterceptor, resErrorInterceptor,
fetchClient, fetchClient,
addInterceptors, addInterceptors,
} from '../fetchClient'; } from '../index';
const token = 'coolToken'; const token = 'coolToken';
auth.getToken = jest.fn().mockReturnValue(token); auth.getToken = jest.fn().mockReturnValue(token);
auth.clearAppStorage = jest.fn().mockReturnValue(token); auth.clearAppStorage = jest.fn().mockReturnValue(token);
describe('ADMIN | utils | fetchClient', () => { describe('HELPER-PLUGIN | utils | fetchClient', () => {
describe('Test the interceptors', () => { describe('Test the interceptors', () => {
it('API request should add authorization token to header', async () => { it('API request should add authorization token to header', async () => {
const apiInstance = fetchClient({ const apiInstance = fetchClient({
@ -21,7 +21,6 @@ describe('ADMIN | utils | fetchClient', () => {
}); });
const result = await apiInstance.interceptors.request.handlers[0].fulfilled({ headers: {} }); const result = await apiInstance.interceptors.request.handlers[0].fulfilled({ headers: {} });
expect(result.headers.Authorization).toContain(`Bearer ${token}`); expect(result.headers.Authorization).toContain(`Bearer ${token}`);
expect(result.headers.Accept).toBe('application/json');
expect(apiInstance.interceptors.response.handlers[0].fulfilled('foo')).toBe('foo'); expect(apiInstance.interceptors.response.handlers[0].fulfilled('foo')).toBe('foo');
}); });
describe('Test the addInterceptor function', () => { describe('Test the addInterceptor function', () => {

View File

@ -1,6 +1,7 @@
import instance from './fetchClient'; import instance from '../fetchClient';
export const getFetchClient = (defaultOptions = {}) => { const getFetchClient = (defaultOptions = {}) => {
instance.defaults.baseURL = window.strapi.backendURL;
return { return {
get: (url, config) => instance.get(url, { ...defaultOptions, ...config }), get: (url, config) => instance.get(url, { ...defaultOptions, ...config }),
put: (url, data, config) => instance.put(url, data, { ...defaultOptions, ...config }), put: (url, data, config) => instance.put(url, data, { ...defaultOptions, ...config }),
@ -8,3 +9,5 @@ export const getFetchClient = (defaultOptions = {}) => {
del: (url, config) => instance.delete(url, { ...defaultOptions, ...config }), del: (url, config) => instance.delete(url, { ...defaultOptions, ...config }),
}; };
}; };
export default getFetchClient;

View File

@ -1,10 +1,10 @@
import { auth } from '@strapi/helper-plugin'; import auth from '../../auth';
import { getFetchClient } from '../getFetchClient'; import getFetchClient from '../index';
const token = 'coolToken'; const token = 'coolToken';
auth.getToken = jest.fn().mockReturnValue(token); auth.getToken = jest.fn().mockReturnValue(token);
describe('ADMIN | utils | getFetchClient', () => { describe('HELPER-PLUGIN | utils | getFetchClient', () => {
it('should return the 4 HTTP methods to call GET, POST, PUT and DELETE apis', () => { it('should return the 4 HTTP methods to call GET, POST, PUT and DELETE apis', () => {
const response = getFetchClient(); const response = getFetchClient();
expect(response).toHaveProperty('get'); expect(response).toHaveProperty('get');
@ -19,7 +19,6 @@ describe('ADMIN | utils | getFetchClient', () => {
} catch (err) { } catch (err) {
const { headers } = err.config; const { headers } = err.config;
expect(headers.Authorization).toContain(`Bearer ${token}`); expect(headers.Authorization).toContain(`Bearer ${token}`);
expect(headers.Accept).toBe('application/json');
} }
}); });
}); });

View File

@ -5,7 +5,7 @@ function wrapAxiosInstance(instance) {
(methodName) => { (methodName) => {
wrapper[methodName] = (...args) => { wrapper[methodName] = (...args) => {
console.log( console.log(
'Deprecation warning: Usage of "axiosInstance" utility is deprecated and will be removed in the next major release. Instead, use the useFetchClient() hook, which is exported from the admin: { useFetchClient } from "@strapi/helper-plugin"' 'Deprecation warning: Usage of "axiosInstance" utility is deprecated and will be removed in the next major release. Instead, use the useFetchClient() hook, which is exported from the helper-plugin: { useFetchClient } from "@strapi/helper-plugin"'
); );
return instance[methodName](...args); return instance[methodName](...args);

View File

@ -8,35 +8,18 @@ import { Typography } from '@strapi/design-system/Typography';
import { PreviewCell } from './PreviewCell'; import { PreviewCell } from './PreviewCell';
import { formatBytes } from '../../utils'; import { formatBytes } from '../../utils';
export const CellContent = ({ export const CellContent = ({ cellType, contentType, content, name }) => {
alternativeText,
content,
cellType,
elementType,
mime,
fileExtension,
thumbnailURL,
url,
}) => {
const { formatDate, formatMessage } = useIntl(); const { formatDate, formatMessage } = useIntl();
switch (cellType) { switch (cellType) {
case 'image': case 'image':
return ( return <PreviewCell type={contentType} content={content} />;
<PreviewCell
alternativeText={alternativeText}
fileExtension={fileExtension}
mime={mime}
type={elementType}
thumbnailURL={thumbnailURL}
url={url}
/>
);
case 'date': case 'date':
return <Typography>{formatDate(parseISO(content), { dateStyle: 'full' })}</Typography>; return <Typography>{formatDate(parseISO(content[name]), { dateStyle: 'full' })}</Typography>;
case 'size': case 'size':
if (elementType === 'folder') if (contentType === 'folder')
return ( return (
<Typography <Typography
aria-label={formatMessage({ aria-label={formatMessage({
@ -48,10 +31,10 @@ export const CellContent = ({
</Typography> </Typography>
); );
return <Typography>{formatBytes(content)}</Typography>; return <Typography>{formatBytes(content[name])}</Typography>;
case 'ext': case 'ext':
if (elementType === 'folder') if (contentType === 'folder')
return ( return (
<Typography <Typography
aria-label={formatMessage({ aria-label={formatMessage({
@ -63,10 +46,10 @@ export const CellContent = ({
</Typography> </Typography>
); );
return <Typography>{getFileExtension(content).toUpperCase()}</Typography>; return <Typography>{getFileExtension(content[name]).toUpperCase()}</Typography>;
case 'text': case 'text':
return <Typography>{content}</Typography>; return <Typography>{content[name]}</Typography>;
default: default:
return ( return (
@ -82,22 +65,19 @@ export const CellContent = ({
} }
}; };
CellContent.defaultProps = {
alternativeText: null,
content: '',
fileExtension: '',
mime: '',
thumbnailURL: null,
url: null,
};
CellContent.propTypes = { CellContent.propTypes = {
alternativeText: PropTypes.string,
content: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
fileExtension: PropTypes.string,
mime: PropTypes.string,
thumbnailURL: PropTypes.string,
cellType: PropTypes.string.isRequired, cellType: PropTypes.string.isRequired,
elementType: PropTypes.string.isRequired, contentType: PropTypes.string.isRequired,
url: PropTypes.string, content: PropTypes.shape({
alternativeText: PropTypes.string,
ext: PropTypes.string,
formats: PropTypes.shape({
thumbnail: PropTypes.shape({
url: PropTypes.string,
}),
}),
mime: PropTypes.string,
url: PropTypes.string,
}).isRequired,
name: PropTypes.string.isRequired,
}; };

View File

@ -1,69 +1,80 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import styled from 'styled-components'; import styled from 'styled-components';
import { prefixFileUrlWithBackendUrl, pxToRem } from '@strapi/helper-plugin'; import { getFileExtension, prefixFileUrlWithBackendUrl, pxToRem } from '@strapi/helper-plugin';
import { Avatar } from '@strapi/design-system/Avatar'; import { Avatar, Initials } from '@strapi/design-system/Avatar';
import { Flex } from '@strapi/design-system/Flex'; import { Box } from '@strapi/design-system/Box';
import { Typography } from '@strapi/design-system/Typography';
import { Icon } from '@strapi/design-system/Icon'; import { Icon } from '@strapi/design-system/Icon';
import Folder from '@strapi/icons/Folder'; import Folder from '@strapi/icons/Folder';
const GenericAssetWrapper = styled(Flex)` import { AssetType } from '../../constants';
span { import { createAssetUrl } from '../../utils';
/* The smallest fontSize in the DS is not small enough in this case */ import { VideoPreview } from '../AssetCard/VideoPreview';
font-size: ${pxToRem(10)};
const VideoPreviewWrapper = styled(Box)`
figure {
width: 26px;
height: 26px;
}
canvas,
video {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
} }
`; `;
export const PreviewCell = ({ alternativeText, fileExtension, mime, thumbnailURL, type, url }) => { export const PreviewCell = ({ type, content }) => {
if (type === 'folder') { if (type === 'folder') {
return ( return (
<Flex <Initials background="secondary100" textColor="secondary600">
background="secondary100" <Icon color="secondary500" width={pxToRem(16)} height={pxToRem(16)} as={Folder} />
height={pxToRem(26)} </Initials>
justifyContent="center"
width={pxToRem(26)}
borderRadius="50%"
>
<Icon color="secondary500" as={Folder} />
</Flex>
); );
} }
if (mime.includes('image')) { const { alternativeText, ext, formats, mime, name, url } = content;
const mediaURL = prefixFileUrlWithBackendUrl(thumbnailURL) ?? prefixFileUrlWithBackendUrl(url);
if (mime.includes(AssetType.Image)) {
const mediaURL =
prefixFileUrlWithBackendUrl(formats?.thumbnail?.url) ?? prefixFileUrlWithBackendUrl(url);
return <Avatar src={mediaURL} alt={alternativeText} preview />; return <Avatar src={mediaURL} alt={alternativeText} preview />;
} }
if (mime.includes(AssetType.Video)) {
return (
<VideoPreviewWrapper>
<VideoPreview
url={createAssetUrl(content, true)}
mime={mime}
alt={alternativeText ?? name}
/>
</VideoPreviewWrapper>
);
}
return ( return (
<GenericAssetWrapper <Initials background="secondary100" textColor="secondary600">
background="secondary100" {getFileExtension(ext)}
height={pxToRem(26)} </Initials>
justifyContent="center"
width={pxToRem(26)}
borderRadius="50%"
>
<Typography variant="sigma" textColor="secondary600">
{fileExtension}
</Typography>
</GenericAssetWrapper>
); );
}; };
PreviewCell.defaultProps = {
alternativeText: null,
fileExtension: '',
mime: '',
thumbnailURL: null,
url: null,
};
PreviewCell.propTypes = { PreviewCell.propTypes = {
alternativeText: PropTypes.string, content: PropTypes.shape({
fileExtension: PropTypes.string, alternativeText: PropTypes.string,
mime: PropTypes.string, ext: PropTypes.string,
thumbnailURL: PropTypes.string, formats: PropTypes.shape({
thumbnail: PropTypes.shape({
url: PropTypes.string,
}),
}),
mime: PropTypes.string,
name: PropTypes.string,
url: PropTypes.string,
}).isRequired,
type: PropTypes.string.isRequired, type: PropTypes.string.isRequired,
url: PropTypes.string,
}; };

View File

@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { getFileExtension, onRowClick, stopPropagation } from '@strapi/helper-plugin'; import { onRowClick, stopPropagation } from '@strapi/helper-plugin';
import { BaseCheckbox } from '@strapi/design-system/BaseCheckbox'; import { BaseCheckbox } from '@strapi/design-system/BaseCheckbox';
import { Flex } from '@strapi/design-system/Flex'; import { Flex } from '@strapi/design-system/Flex';
import { IconButton } from '@strapi/design-system/IconButton'; import { IconButton } from '@strapi/design-system/IconButton';
@ -35,18 +35,7 @@ export const TableRows = ({
return ( return (
<Tbody> <Tbody>
{rows.map((element) => { {rows.map((element) => {
const { const { id, isSelectable, name, folderURL, type: contentType } = element;
alternativeText,
id,
isSelectable,
name,
ext,
url,
mime,
folderURL,
formats,
type: elementType,
} = element;
const isSelected = !!selected.find((currentRow) => currentRow.id === id); const isSelected = !!selected.find((currentRow) => currentRow.id === id);
@ -54,16 +43,16 @@ export const TableRows = ({
<Tr <Tr
key={id} key={id}
{...onRowClick({ {...onRowClick({
fn: () => handleRowClickFn(element, elementType, id), fn: () => handleRowClickFn(element, contentType, id),
})} })}
> >
<Td {...stopPropagation}> <Td {...stopPropagation}>
<BaseCheckbox <BaseCheckbox
aria-label={formatMessage( aria-label={formatMessage(
{ {
id: elementType === 'asset' ? 'list-assets-select' : 'list.folder.select', id: contentType === 'asset' ? 'list-assets-select' : 'list.folder.select',
defaultMessage: defaultMessage:
elementType === 'asset' ? 'Select {name} asset' : 'Select {name} folder', contentType === 'asset' ? 'Select {name} asset' : 'Select {name} folder',
}, },
{ name } { name }
)} )}
@ -76,14 +65,10 @@ export const TableRows = ({
return ( return (
<Td key={name}> <Td key={name}>
<CellContent <CellContent
alternativeText={alternativeText} content={element}
content={element[name]}
fileExtension={getFileExtension(ext)}
mime={mime}
cellType={cellType} cellType={cellType}
elementType={elementType} contentType={contentType}
thumbnailURL={formats?.thumbnail?.url} name={name}
url={url}
/> />
</Td> </Td>
); );
@ -91,7 +76,7 @@ export const TableRows = ({
<Td {...stopPropagation}> <Td {...stopPropagation}>
<Flex justifyContent="flex-end"> <Flex justifyContent="flex-end">
{elementType === 'folder' && ( {contentType === 'folder' && (
<IconButton <IconButton
forwardedAs={folderURL ? Link : undefined} forwardedAs={folderURL ? Link : undefined}
label={formatMessage({ label={formatMessage({
@ -111,7 +96,7 @@ export const TableRows = ({
defaultMessage: 'Edit', defaultMessage: 'Edit',
})} })}
onClick={() => onClick={() =>
elementType === 'asset' ? onEditAsset(element) : onEditFolder(element) contentType === 'asset' ? onEditAsset(element) : onEditFolder(element)
} }
noBorder noBorder
> >

View File

@ -6,14 +6,20 @@ import { ThemeProvider, lightTheme } from '@strapi/design-system';
import { CellContent } from '../CellContent'; import { CellContent } from '../CellContent';
const PROPS_FIXTURE = { const PROPS_FIXTURE = {
alternativeText: 'alternative alt',
cellType: 'image', cellType: 'image',
elementType: 'asset', contentType: 'asset',
content: 'michka-picture-url-default.jpeg', content: {
fileExtension: '.jpeg', alternativeText: 'alternative alt',
mime: 'image/jpeg', ext: 'jpeg',
thumbnailURL: 'michka-picture-url-thumbnail.jpeg', formats: {
url: 'michka-picture-url-default.jpeg', thumbnail: {
url: 'michka-picture-url-thumbnail.jpeg',
},
},
mime: 'image/jpeg',
url: 'michka-picture-url-default.jpeg',
},
name: 'preview',
}; };
const ComponentFixture = (props) => { const ComponentFixture = (props) => {
@ -42,49 +48,63 @@ describe('TableList | CellContent', () => {
}); });
it('should render image cell type when element type is asset and mime does not include image', () => { it('should render image cell type when element type is asset and mime does not include image', () => {
const { container, getByText } = setup({ mime: 'application/pdf', fileExtension: 'pdf' }); const { container, getByText } = setup({
content: { ...PROPS_FIXTURE.element, mime: 'application/pdf', ext: 'pdf' },
});
expect(getByText('pdf')).toBeInTheDocument(); expect(getByText('pdf')).toBeInTheDocument();
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
it('should render image cell type when element type is folder', () => { it('should render image cell type when element type is folder', () => {
const { container } = setup({ elementType: 'folder' }); const { container } = setup({ contentType: 'folder' });
expect(container.querySelector('path')).toBeInTheDocument(); expect(container.querySelector('path')).toBeInTheDocument();
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
it('should render text cell type', () => { it('should render text cell type', () => {
const { container, getByText } = setup({ cellType: 'text', content: 'some text' }); const { container, getByText } = setup({
cellType: 'text',
content: { ...PROPS_FIXTURE.content, name: 'some text' },
name: 'name',
});
expect(getByText('some text')).toBeInTheDocument(); expect(getByText('some text')).toBeInTheDocument();
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
it('should render extension cell type when element type is asset', () => { it('should render extension cell type when element type is asset', () => {
const { container, getByText } = setup({ cellType: 'ext', content: '.pdf' }); const { container, getByText } = setup({
cellType: 'ext',
content: { ...PROPS_FIXTURE.content, ext: '.pdf' },
name: 'ext',
});
expect(getByText('PDF')).toBeInTheDocument(); expect(getByText('PDF')).toBeInTheDocument();
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
it('should render extension cell type with "-" when element type is folder', () => { it('should render extension cell type with "-" when element type is folder', () => {
const { container, getByText } = setup({ cellType: 'ext', elementType: 'folder' }); const { container, getByText } = setup({ cellType: 'ext', contentType: 'folder' });
expect(getByText('-')).toBeInTheDocument(); expect(getByText('-')).toBeInTheDocument();
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
it('should render size cell type when element type is asset', () => { it('should render size cell type when element type is asset', () => {
const { container, getByText } = setup({ cellType: 'size', content: '20.5435' }); const { container, getByText } = setup({
cellType: 'size',
content: { ...PROPS_FIXTURE.content, size: '20.5435' },
name: 'size',
});
expect(getByText('21KB')).toBeInTheDocument(); expect(getByText('21KB')).toBeInTheDocument();
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
it('should render size cell type with "-" when element type is folder', () => { it('should render size cell type with "-" when element type is folder', () => {
const { container, getByText } = setup({ cellType: 'size', elementType: 'folder' }); const { container, getByText } = setup({ cellType: 'size', contentType: 'folder' });
expect(getByText('-')).toBeInTheDocument(); expect(getByText('-')).toBeInTheDocument();
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
@ -93,7 +113,8 @@ describe('TableList | CellContent', () => {
it('should render date cell type', () => { it('should render date cell type', () => {
const { container, getByText } = setup({ const { container, getByText } = setup({
cellType: 'date', cellType: 'date',
content: '2022-11-18T12:08:02.202Z', content: { ...PROPS_FIXTURE.content, updatedAt: '2022-11-18T12:08:02.202Z' },
name: 'updatedAt',
}); });
expect(getByText('Friday, November 18, 2022')).toBeInTheDocument(); expect(getByText('Friday, November 18, 2022')).toBeInTheDocument();

View File

@ -5,12 +5,17 @@ import { ThemeProvider, lightTheme } from '@strapi/design-system';
import { PreviewCell } from '../PreviewCell'; import { PreviewCell } from '../PreviewCell';
const PROPS_FIXTURE = { const PROPS_FIXTURE = {
alternativeText: 'alternative alt', content: {
fileExtension: 'jpeg', alternativeText: 'alternative alt',
mime: 'image/jpeg', ext: 'jpeg',
name: 'michka', formats: {
thumbnailURL: 'michka-picture-url-thumbnail.jpeg', thumbnail: {
url: 'michka-picture-url-default.jpeg', url: 'michka-picture-url-thumbnail.jpeg',
},
},
mime: 'image/jpeg',
url: 'michka-picture-url-default.jpeg',
},
type: 'asset', type: 'asset',
}; };
@ -41,7 +46,7 @@ describe('TableList | PreviewCell', () => {
}); });
it('should render an image with default url if thumbnail is not available', () => { it('should render an image with default url if thumbnail is not available', () => {
const { getByRole } = setup({ thumbnailURL: undefined }); const { getByRole } = setup({ content: { ...PROPS_FIXTURE.content, formats: undefined } });
expect(getByRole('img', { name: 'alternative alt' })).toHaveAttribute( expect(getByRole('img', { name: 'alternative alt' })).toHaveAttribute(
'src', 'src',
@ -59,7 +64,9 @@ describe('TableList | PreviewCell', () => {
describe('rendering files', () => { describe('rendering files', () => {
it('should render a file with fileExtension', () => { it('should render a file with fileExtension', () => {
const { getByText } = setup({ mime: 'application/pdf', fileExtension: 'pdf' }); const { getByText } = setup({
content: { ...PROPS_FIXTURE.content, mime: 'application/pdf', ext: 'pdf' },
});
expect(getByText('pdf')).toBeInTheDocument(); expect(getByText('pdf')).toBeInTheDocument();
}); });

View File

@ -218,8 +218,8 @@ exports[`TableList | CellContent should render image cell type when element type
.c0 { .c0 {
background: #eaf5ff; background: #eaf5ff;
border-radius: 50%; border-radius: 50%;
width: 1.625rem; width: 26px;
height: 1.625rem; height: 26px;
} }
.c1 { .c1 {
@ -241,22 +241,23 @@ exports[`TableList | CellContent should render image cell type when element type
} }
.c3 { .c3 {
font-size: 0.875rem;
line-height: 1.43;
font-weight: 600; font-weight: 600;
font-size: 0.6875rem; font-size: 0.6875rem;
line-height: 1.45;
text-transform: uppercase;
color: #0c75af; color: #0c75af;
text-transform: uppercase;
} }
.c2 span { .c2 span {
font-size: 0.625rem; line-height: 0;
} }
<div> <div>
<div <div
class="c0 c1 c2" class="c0 c1 c2"
height="1.625rem" height="26px"
width="1.625rem" width="26px"
> >
<span <span
class="c3" class="c3"
@ -355,7 +356,7 @@ exports[`TableList | CellContent should render image cell type when element type
`; `;
exports[`TableList | CellContent should render image cell type when element type is folder 1`] = ` exports[`TableList | CellContent should render image cell type when element type is folder 1`] = `
.c4 { .c6 {
border: 0; border: 0;
-webkit-clip: rect(0 0 0 0); -webkit-clip: rect(0 0 0 0);
clip: rect(0 0 0 0); clip: rect(0 0 0 0);
@ -370,12 +371,14 @@ exports[`TableList | CellContent should render image cell type when element type
.c0 { .c0 {
background: #eaf5ff; background: #eaf5ff;
border-radius: 50%; border-radius: 50%;
width: 1.625rem; width: 26px;
height: 1.625rem; height: 26px;
} }
.c2 { .c4 {
color: #66b7f1; color: #66b7f1;
width: 1rem;
height: 1rem;
} }
.c1 { .c1 {
@ -396,32 +399,49 @@ exports[`TableList | CellContent should render image cell type when element type
justify-content: center; justify-content: center;
} }
.c3 path { .c3 {
font-size: 0.875rem;
line-height: 1.43;
font-weight: 600;
font-size: 0.6875rem;
color: #0c75af;
text-transform: uppercase;
}
.c5 path {
fill: #66b7f1; fill: #66b7f1;
} }
.c2 span {
line-height: 0;
}
<div> <div>
<div <div
class="c0 c1" class="c0 c1 c2"
height="1.625rem" height="26px"
width="1.625rem" width="26px"
> >
<svg <span
class="c2 c3" class="c3"
fill="none"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
> >
<path <svg
d="M12.414 5H21a1 1 0 011 1v14a1 1 0 01-1 1H3a1 1 0 01-1-1V4a1 1 0 011-1h7.414l2 2z" class="c4 c5"
fill="#212134" fill="none"
/> height="1rem"
</svg> viewBox="0 0 24 24"
width="1rem"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.414 5H21a1 1 0 011 1v14a1 1 0 01-1 1H3a1 1 0 01-1-1V4a1 1 0 011-1h7.414l2 2z"
fill="#212134"
/>
</svg>
</span>
</div> </div>
<div <div
class="c4" class="c6"
> >
<p <p
aria-live="polite" aria-live="polite"