Merge pull request #10932 from strapi/ds-migration/layout-ml

Adding layouts for ML
This commit is contained in:
cyril lopez 2021-09-14 09:27:17 +02:00 committed by GitHub
commit 42f79ad767
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 419 additions and 137 deletions

View File

@ -39,11 +39,6 @@ const SettingsPage = lazy(() =>
// import(
// /* webpackChunkName: "content-type-builder" */ '@strapi/plugin-content-type-builder/admin/src/pages/App'
// )
// );
// const Upload = lazy(() =>
// import(/* webpackChunkName: "upload" */ '@strapi/plugin-upload/admin/src/pages/App')
// );
// Simple hook easier for testing
const useTrackUsage = () => {
const { trackUsage } = useTracking();
@ -88,9 +83,8 @@ const Admin = () => {
<Route path="/content-manager" component={CM} />
{/* TODO */}
{/* <
<Route path="/plugins/content-type-builder" component={CTB} />
<Route path="/plugins/upload" component={Upload} /> */}
*/}
{routes}
<Route path="/settings/:settingId" component={SettingsPage} />
<Route path="/settings" component={SettingsPage} exact />

View File

@ -617,5 +617,7 @@
"or": "OR",
"request.error.model.unknown": "This model doesn't exist",
"skipToContent": "Skip to content",
"clearLabel": "Clear"
"clearLabel": "Clear",
"submit": "Submit",
"anErrorOccurred": "Woops! Something went wrong. Please, try again."
}

View File

@ -0,0 +1,31 @@
<!--- AnErrorOccurred.stories.mdx --->
import { Meta, ArgsTable, Canvas, Story } from '@storybook/addon-docs';
import { Main, Row, Button } from '@strapi/parts';
import AnErrorOccurred from './index';
<Meta title="components/AnErrorOccurred" />
# AnErrorOccurred
This component is used to display an empty state.
## Usage
<Canvas>
<Story name="base">
<Main>
<AnErrorOccurred
content={{
id: 'app.components.EmptyStateLayout.content-document',
defaultMessage: 'An error occured',
}}
action={<Button>Do something</Button>}
/>
</Main>
</Story>
</Canvas>
### Props
<ArgsTable of={AnErrorOccurred} />

View File

@ -0,0 +1,38 @@
import React from 'react';
import AlertWarningIcon from '@strapi/icons/AlertWarningIcon';
import { EmptyStateLayout } from '@strapi/parts/EmptyStateLayout';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
const AnErrorOccurred = ({ content, ...rest }) => {
const { formatMessage } = useIntl();
return (
<EmptyStateLayout
icon={<AlertWarningIcon width="10rem" />}
content={formatMessage(
{ id: content.id, defaultMessage: content.defaultMessage },
content.values
)}
{...rest}
/>
);
};
AnErrorOccurred.defaultProps = {
content: {
id: 'anErrorOccurred',
defaultMessage: 'Woops! Something went wrong. Please, try again.',
values: {},
},
};
AnErrorOccurred.propTypes = {
content: PropTypes.shape({
id: PropTypes.string,
defaultMessage: PropTypes.string,
values: PropTypes.object,
}),
};
export default AnErrorOccurred;

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import { SearchIcon } from '@strapi/icons';
@ -7,11 +7,29 @@ import { IconButton } from '@strapi/parts/IconButton';
import useQueryParams from '../../hooks/useQueryParams';
const Search = ({ label }) => {
const wrapperRef = useRef(null);
const iconButtonRef = useRef(null);
const isMountedRef = useRef(false);
const [isOpen, setIsOpen] = useState(false);
const [{ query }, setQuery] = useQueryParams();
const [value, setValue] = useState(query?._q || '');
const { formatMessage } = useIntl();
const handleToggle = () => setIsOpen(prev => !prev);
useEffect(() => {
if (isMountedRef.current) {
if (isOpen) {
wrapperRef.current.querySelector('input').focus();
} else {
iconButtonRef.current.focus();
}
}
isMountedRef.current = true;
}, [isOpen]);
useEffect(() => {
const handler = setTimeout(() => {
if (value) {
@ -25,26 +43,26 @@ const Search = ({ label }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
const handleToggle = () => {
setIsOpen(prev => !prev);
};
if (isOpen) {
return (
<Searchbar
name="search"
onChange={({ target: { value } }) => setValue(value)}
onBlur={() => setIsOpen(false)}
value={value}
clearLabel={formatMessage({ id: 'clearLabel', defaultMessage: 'Clear' })}
onClear={() => setValue('')}
>
{label}
</Searchbar>
<div ref={wrapperRef}>
<Searchbar
name="search"
onChange={({ target: { value } }) => setValue(value)}
onBlur={() => setIsOpen(false)}
value={value}
clearLabel={formatMessage({ id: 'clearLabel', defaultMessage: 'Clear' })}
onClear={() => setValue('')}
>
{label}
</Searchbar>
</div>
);
}
return <IconButton icon={<SearchIcon />} label="Search" onClick={handleToggle} />;
return (
<IconButton ref={iconButtonRef} icon={<SearchIcon />} label="Search" onClick={handleToggle} />
);
};
Search.propTypes = {

View File

@ -186,6 +186,7 @@ export { default as EmptyStateLayout } from './components/EmptyStateLayout';
export { default as NoContent } from './components/NoContent';
export { default as NoMedia } from './components/NoMedia';
export { default as NoPermissions } from './components/NoPermissions';
export { default as AnErrorOccurred } from './components/AnErrorOccurred';
export { default as EmptyBodyTable } from './components/EmptyBodyTable';
export { default as GenericInput } from './components/GenericInput';
export * from './components/InjectionZone';

View File

@ -0,0 +1,52 @@
import { useEffect } from 'react';
import { useQuery } from 'react-query';
import { useNotifyAT } from '@strapi/parts/LiveRegions';
import { useNotification, useQueryParams } from '@strapi/helper-plugin';
import { useIntl } from 'react-intl';
import { axiosInstance, getRequestUrl } from '../utils';
export const useAssets = ({ skipWhen }) => {
const { formatMessage } = useIntl();
const toggleNotification = useNotification();
const { notifyStatus } = useNotifyAT();
const [{ rawQuery, query }, setQuery] = useQueryParams();
const dataRequestURL = getRequestUrl('files');
const { data, error, isLoading } = useQuery(
'assets',
async () => {
const { data } = await axiosInstance.get(`${dataRequestURL}${rawQuery}`);
return data;
},
{ enabled: !skipWhen }
);
useEffect(() => {
if (!query) {
setQuery({ sort: 'updatedAt:DESC', page: 1, pageSize: 10 });
}
}, [query, setQuery]);
useEffect(() => {
if (data) {
notifyStatus(
formatMessage({
id: 'list.asset.at.finished',
defaultMessage: 'The assets have finished loading.',
})
);
}
}, [data, notifyStatus, formatMessage]);
useEffect(() => {
if (error) {
toggleNotification({
type: 'warning',
message: { id: 'notification.error' },
});
}
}, [error, toggleNotification]);
return { data, error, isLoading };
};

View File

@ -23,7 +23,20 @@ export default {
register(app) {
// TODO update doc and guides
app.addComponents({ name: 'media-library', Component: InputModalStepper });
app.addMenuLink({
to: `/plugins/${pluginId}`,
icon,
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: 'Media Library',
},
permissions: pluginPermissions.main,
Component: async () => {
const component = await import(/* webpackChunkName: "media-library-page" */ './pages/App');
return component;
},
});
// TODO
// app.addCorePluginMenuLink({
// to: `/plugins/${pluginId}`,

View File

@ -0,0 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
// TODO: implement the view
export const ListView = ({ assets }) => {
return <div>Number of assets: {assets.length}</div>;
};
ListView.propTypes = {
assets: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
};

View File

@ -1,31 +1,133 @@
import React from 'react';
import { Switch, Route, Redirect } from 'react-router-dom';
import { LoadingIndicatorPage, useRBAC } from '@strapi/helper-plugin';
import pluginId from '../../pluginId';
import { Redirect } from 'react-router-dom';
import { useIntl } from 'react-intl';
import styled from 'styled-components';
import {
LoadingIndicatorPage,
useRBAC,
useFocusWhenNavigate,
NoPermissions,
NoMedia,
AnErrorOccurred,
Search,
} from '@strapi/helper-plugin';
import { Layout, HeaderLayout, ContentLayout, ActionLayout } from '@strapi/parts/Layout';
import { Main } from '@strapi/parts/Main';
import { Button } from '@strapi/parts/Button';
import AddIcon from '@strapi/icons/AddIcon';
import { Box } from '@strapi/parts/Box';
import { BaseCheckbox } from '@strapi/parts/BaseCheckbox';
import { ListView } from './components/ListView';
import { useAssets } from '../../hooks/useAssets';
import { getTrad } from '../../utils';
import pluginPermissions from '../../permissions';
import { AppContext } from '../../contexts';
import HomePage from '../HomePage';
const BoxWithHeight = styled(Box)`
height: ${32 / 16}rem;
display: flex;
align-items: center;
`;
const App = () => {
const state = useRBAC(pluginPermissions);
const { formatMessage } = useIntl();
const { data, isLoading, error } = useAssets({
skipWhen: !state.allowedActions.canMain,
});
// Show a loader while all permissions are being checked
if (state.isLoading) {
return <LoadingIndicatorPage />;
useFocusWhenNavigate();
const canRead = state.allowedActions.canMain;
const loading = state.isLoading || isLoading;
if (!loading && !canRead) {
return <Redirect to="/" />;
}
if (state.allowedActions.canMain) {
return (
<AppContext.Provider value={state}>
<Switch>
<Route path={`/plugins/${pluginId}`} component={HomePage} />
</Switch>
</AppContext.Provider>
);
}
return (
<Layout>
<Main aria-busy={loading}>
<HeaderLayout
title={formatMessage({
id: getTrad('plugin.name'),
defaultMessage: 'Media Library',
})}
subtitle={formatMessage(
{
id: getTrad(
data?.length > 0
? 'header.content.assets-multiple'
: 'header.content.assets.assets-single'
),
defaultMessage: '0 assets',
},
{ number: data?.length || 0 }
)}
primaryAction={
<Button startIcon={<AddIcon />}>
{formatMessage({
id: getTrad('header.actions.upload-assets'),
defaultMessage: 'Upload new assets',
})}
</Button>
}
/>
return <Redirect to="/" />;
<ActionLayout
startActions={
<>
<BoxWithHeight
paddingLeft={2}
paddingRight={2}
background="neutral0"
hasRadius
borderColor="neutral200"
>
<BaseCheckbox
aria-label={formatMessage({
id: getTrad('bulk.select.label'),
defaultMessage: 'Select all assets',
})}
/>
</BoxWithHeight>
<Button variant="tertiary">Filter</Button>
</>
}
endActions={
<Search
label={formatMessage({
id: getTrad('search.label'),
defaultMessage: 'Search for an asset',
})}
/>
}
/>
<ContentLayout>
{loading && <LoadingIndicatorPage />}
{error && <AnErrorOccurred />}
{!canRead && <NoPermissions />}
{canRead && data && data.length === 0 && (
<NoMedia
action={
<Button variant="secondary" startIcon={<AddIcon />}>
{formatMessage({
id: getTrad('modal.header.browse'),
defaultMessage: 'Upload assets',
})}
</Button>
}
content={formatMessage({
id: getTrad('list.assets.empty'),
defaultMessage: 'Upload your first assets...',
})}
/>
)}
{canRead && data && data.length > 0 && <ListView assets={data} />}
</ContentLayout>
</Main>
</Layout>
);
};
export default App;

View File

@ -0,0 +1,96 @@
import React from 'react';
import { ThemeProvider, lightTheme } from '@strapi/parts';
import { QueryClientProvider, QueryClient } from 'react-query';
import { render as renderTL, screen, waitFor } from '@testing-library/react';
import { useRBAC } from '@strapi/helper-plugin';
import { Provider } from 'react-redux';
import { combineReducers, createStore } from 'redux';
import { MemoryRouter } from 'react-router-dom';
import reducers from '../../../reducers';
import en from '../../../translations/en.json';
import server from './server';
import MediaLibraryPage from '..';
jest.mock('@strapi/helper-plugin', () => ({
...jest.requireActual('@strapi/helper-plugin'),
useRBAC: jest.fn(),
useNotification: jest.fn(() => jest.fn()),
}));
jest.mock('../../../utils', () => ({
...jest.requireActual('../../../utils'),
getTrad: x => x,
}));
jest.mock('react-intl', () => ({
FormattedMessage: ({ id }) => id,
useIntl: () => ({ formatMessage: jest.fn(({ id }) => en[id]) }),
}));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});
const store = createStore(combineReducers(reducers));
const renderML = () =>
renderTL(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<ThemeProvider theme={lightTheme}>
<MemoryRouter>
<MediaLibraryPage />
</MemoryRouter>
</ThemeProvider>
</Provider>
</QueryClientProvider>
);
describe('Media library homepage', () => {
beforeAll(() => server.listen());
beforeEach(() => {
useRBAC.mockReturnValue({ isLoading: false, allowedActions: { canMain: true } });
});
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
afterAll(() => server.close());
describe('loading state', () => {
it('shows a loader when resolving the permissions', () => {
useRBAC.mockReturnValue({ isLoading: true, allowedActions: { canMain: false } });
renderML();
expect(screen.getByRole('main').getAttribute('aria-busy')).toBe('true');
expect(screen.getByText('Loading content.')).toBeInTheDocument();
});
it('shows a loader when resolving the assets', () => {
renderML();
expect(screen.getByRole('main').getAttribute('aria-busy')).toBe('true');
expect(screen.getByText('Loading content.')).toBeInTheDocument();
});
});
describe('empty state', () => {
it('shows an empty state when there are no assets found', async () => {
renderML();
await waitFor(() =>
expect(screen.getByText('Upload your first assets...')).toBeInTheDocument()
);
expect(screen.getByRole('main').getAttribute('aria-busy')).toBe('false');
});
});
});

View File

@ -0,0 +1,10 @@
import { setupServer } from 'msw/node';
import { rest } from 'msw';
const server = setupServer(
rest.get('*/upload/files*', (req, res, ctx) => {
return res(ctx.json([]));
})
);
export default server;

View File

@ -17,10 +17,11 @@ import { useAppContext, useSelectTimestamps } from '../../hooks';
import Container from '../../components/Container';
import HomePageContent from './HomePageContent';
import HomePageModalStepper from '../../components/HomePageModalStepper';
import { generateStringFromParams, getHeaderLabel } from './utils';
import { getHeaderLabel } from './utils';
import init from './init';
import reducer, { initialState } from './reducer';
// TODO: remove this file when ML is migrated
const HomePage = () => {
const toggleNotification = useNotification();
const { allowedActions } = useAppContext();
@ -72,7 +73,7 @@ const HomePage = () => {
const fetchData = async () => {
const dataRequestURL = getRequestUrl('files');
const params = generateStringFromParams(query);
const params = query;
const paramsToSend = params.includes('sort')
? params
@ -98,7 +99,7 @@ const HomePage = () => {
};
const fetchDataCount = async () => {
const params = generateStringFromParams(query, ['_limit', '_start']);
const params = query;
const requestURL = getRequestUrl('files/count');
try {

View File

@ -1,27 +0,0 @@
import { isEmpty, toString } from 'lodash';
import generateParamsFromQuery from './generateParamsFromQuery';
const generateStringFromParams = (query, paramsToFilter = []) => {
let paramsString = '';
const paramsObject = generateParamsFromQuery(query);
Object.keys(paramsObject)
.filter(key => {
return !paramsToFilter.includes(key) && !isEmpty(toString(paramsObject[key]));
})
.forEach(key => {
const value = paramsObject[key];
if (key.includes('mime') && value === 'file') {
const revertedKey = key.includes('_ncontains') ? 'mime_contains' : 'mime_ncontains';
paramsString += `&${revertedKey}=image&${revertedKey}=video`;
} else {
paramsString += `&${key}=${value}`;
}
});
return paramsString.substring(1);
};
export default generateStringFromParams;

View File

@ -1,2 +1 @@
export { default as generateStringFromParams } from './generateStringFromParams';
export { default as getHeaderLabel } from './getHeaderLabel';

View File

@ -1,63 +0,0 @@
import generateStringFromParams from '../generateStringFromParams';
describe('MEDIA LIBRARY | pages | HomePage | utils', () => {
describe('generateStringFromParams', () => {
it('should return a string with query params if query is empty', () => {
const search = '';
const query = new URLSearchParams(search);
const expected = '_limit=10&_start=0';
expect(generateStringFromParams(query)).toEqual(expected);
});
it('should return a string with query params if search is not empty', () => {
const search = '_limit=20&_start=0&mime_contains=image';
const query = new URLSearchParams(search);
const expected = '_limit=20&_start=0&mime_contains=image';
expect(generateStringFromParams(query)).toEqual(expected);
});
describe('return a string with converted filters if value is file', () => {
it('should return _ncontains instead of _contains', () => {
const search = '?mime_ncontains=file';
const query = new URLSearchParams(search);
const expected = '_limit=10&_start=0&mime_contains=image&mime_contains=video';
expect(generateStringFromParams(query)).toEqual(expected);
});
it('should return _contains instead of _ncontains', () => {
const search = '?_limit=20&_start=0&mime_contains=file';
const query = new URLSearchParams(search);
const expected = '_limit=20&_start=0&mime_ncontains=image&mime_ncontains=video';
expect(generateStringFromParams(query)).toEqual(expected);
});
});
describe('it should filter the defined params', () => {
it('should return _ncontains instead of _contains', () => {
const search = '?mime_ncontains=file&test=true';
const query = new URLSearchParams(search);
const expected = '_limit=10&_start=0&mime_contains=image&mime_contains=video&test=true';
expect(generateStringFromParams(query, [])).toEqual(expected);
});
it('should not return the _limit param', () => {
const search = '?mime_ncontains=file';
const query = new URLSearchParams(search);
const expected = '_start=0&mime_contains=image&mime_contains=video';
expect(generateStringFromParams(query, ['_limit'])).toEqual(expected);
});
});
});
});

View File

@ -1,5 +1,6 @@
{
"button.next": "Next",
"bulk.select.label": "Select all assets",
"checkControl.crop-duplicate": "Duplicate & crop the asset",
"checkControl.crop-original": "Crop the original asset",
"control-card.add": "Add",
@ -39,6 +40,8 @@
"list.assets.selected.plural": "{number} assets selected",
"list.assets.selected.singular": "{number} asset selected",
"list.assets.type-not-allowed": "This type of file is not allowed.",
"list.assets.empty": "Upload your first assets...",
"list.asset.at.finished": "The assets have finished loading.",
"modal.file-details.date": "Date",
"modal.file-details.dimensions": "Dimensions",
"modal.file-details.extension": "Extension",
@ -62,7 +65,8 @@
"plugin.description.long": "Media file management.",
"plugin.description.short": "Media file management.",
"plugin.name": "Media Library",
"search.placeholder": "Search for an asset...",
"search.label": "Search for an asset",
"search.placeholder": "e.g: the first dog on the moon",
"settings.form.autoOrientation.description": "Automatically rotate image according to EXIF orientation tag",
"settings.form.autoOrientation.label": "Enable auto orientation",
"settings.form.responsiveDimensions.description": "It automatically generates multiple formats (large, medium, small) of the uploaded asset",