diff --git a/packages/core/admin/admin/src/pages/Admin/index.js b/packages/core/admin/admin/src/pages/Admin/index.js index 96d5659fe6..67f12b8d53 100644 --- a/packages/core/admin/admin/src/pages/Admin/index.js +++ b/packages/core/admin/admin/src/pages/Admin/index.js @@ -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 = () => { {/* TODO */} {/* < - - */} + */} {routes} diff --git a/packages/core/admin/admin/src/translations/en.json b/packages/core/admin/admin/src/translations/en.json index efaaa1fb5b..5cc062f409 100644 --- a/packages/core/admin/admin/src/translations/en.json +++ b/packages/core/admin/admin/src/translations/en.json @@ -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." } diff --git a/packages/core/helper-plugin/lib/src/components/AnErrorOccurred/AnErrorOccurred.stories.mdx b/packages/core/helper-plugin/lib/src/components/AnErrorOccurred/AnErrorOccurred.stories.mdx new file mode 100644 index 0000000000..1b3f53a8c5 --- /dev/null +++ b/packages/core/helper-plugin/lib/src/components/AnErrorOccurred/AnErrorOccurred.stories.mdx @@ -0,0 +1,31 @@ + + +import { Meta, ArgsTable, Canvas, Story } from '@storybook/addon-docs'; +import { Main, Row, Button } from '@strapi/parts'; +import AnErrorOccurred from './index'; + + + +# AnErrorOccurred + +This component is used to display an empty state. + +## Usage + + + +
+ Do something} + /> +
+
+
+ +### Props + + diff --git a/packages/core/helper-plugin/lib/src/components/AnErrorOccurred/index.js b/packages/core/helper-plugin/lib/src/components/AnErrorOccurred/index.js new file mode 100644 index 0000000000..03831d8513 --- /dev/null +++ b/packages/core/helper-plugin/lib/src/components/AnErrorOccurred/index.js @@ -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 ( + } + 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; diff --git a/packages/core/helper-plugin/lib/src/components/Search/index.js b/packages/core/helper-plugin/lib/src/components/Search/index.js index 17ec78da8b..113b05c1db 100644 --- a/packages/core/helper-plugin/lib/src/components/Search/index.js +++ b/packages/core/helper-plugin/lib/src/components/Search/index.js @@ -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 ( - setValue(value)} - onBlur={() => setIsOpen(false)} - value={value} - clearLabel={formatMessage({ id: 'clearLabel', defaultMessage: 'Clear' })} - onClear={() => setValue('')} - > - {label} - +
+ setValue(value)} + onBlur={() => setIsOpen(false)} + value={value} + clearLabel={formatMessage({ id: 'clearLabel', defaultMessage: 'Clear' })} + onClear={() => setValue('')} + > + {label} + +
); } - return } label="Search" onClick={handleToggle} />; + return ( + } label="Search" onClick={handleToggle} /> + ); }; Search.propTypes = { diff --git a/packages/core/helper-plugin/lib/src/index.js b/packages/core/helper-plugin/lib/src/index.js index a65de24fc5..b2dae0043e 100644 --- a/packages/core/helper-plugin/lib/src/index.js +++ b/packages/core/helper-plugin/lib/src/index.js @@ -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'; diff --git a/packages/core/upload/admin/src/hooks/useAssets.js b/packages/core/upload/admin/src/hooks/useAssets.js new file mode 100644 index 0000000000..198fb3e59a --- /dev/null +++ b/packages/core/upload/admin/src/hooks/useAssets.js @@ -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 }; +}; diff --git a/packages/core/upload/admin/src/index.js b/packages/core/upload/admin/src/index.js index 32c2381aaa..d61716091c 100644 --- a/packages/core/upload/admin/src/index.js +++ b/packages/core/upload/admin/src/index.js @@ -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}`, diff --git a/packages/core/upload/admin/src/pages/App/components/ListView.js b/packages/core/upload/admin/src/pages/App/components/ListView.js new file mode 100644 index 0000000000..f48a6468b2 --- /dev/null +++ b/packages/core/upload/admin/src/pages/App/components/ListView.js @@ -0,0 +1,11 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +// TODO: implement the view +export const ListView = ({ assets }) => { + return
Number of assets: {assets.length}
; +}; + +ListView.propTypes = { + assets: PropTypes.arrayOf(PropTypes.shape({})).isRequired, +}; diff --git a/packages/core/upload/admin/src/pages/App/index.js b/packages/core/upload/admin/src/pages/App/index.js index 86c080afcf..480c071664 100644 --- a/packages/core/upload/admin/src/pages/App/index.js +++ b/packages/core/upload/admin/src/pages/App/index.js @@ -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 ; + useFocusWhenNavigate(); + + const canRead = state.allowedActions.canMain; + const loading = state.isLoading || isLoading; + + if (!loading && !canRead) { + return ; } - if (state.allowedActions.canMain) { - return ( - - - - - - ); - } + return ( + +
+ 0 + ? 'header.content.assets-multiple' + : 'header.content.assets.assets-single' + ), + defaultMessage: '0 assets', + }, + { number: data?.length || 0 } + )} + primaryAction={ + + } + /> - return ; + + + + + + + } + endActions={ + + } + /> + + + {loading && } + {error && } + {!canRead && } + {canRead && data && data.length === 0 && ( + }> + {formatMessage({ + id: getTrad('modal.header.browse'), + defaultMessage: 'Upload assets', + })} + + } + content={formatMessage({ + id: getTrad('list.assets.empty'), + defaultMessage: 'Upload your first assets...', + })} + /> + )} + {canRead && data && data.length > 0 && } + +
+
+ ); }; export default App; diff --git a/packages/core/upload/admin/src/pages/App/tests/App.test.js b/packages/core/upload/admin/src/pages/App/tests/App.test.js new file mode 100644 index 0000000000..3debc40174 --- /dev/null +++ b/packages/core/upload/admin/src/pages/App/tests/App.test.js @@ -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( + + + + + + + + + + ); + +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'); + }); + }); +}); diff --git a/packages/core/upload/admin/src/pages/App/tests/server.js b/packages/core/upload/admin/src/pages/App/tests/server.js new file mode 100644 index 0000000000..dea57b197c --- /dev/null +++ b/packages/core/upload/admin/src/pages/App/tests/server.js @@ -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; diff --git a/packages/core/upload/admin/src/pages/HomePage/index.js b/packages/core/upload/admin/src/pages/HomePage/index.js index 084570f825..9bf0f89ab7 100644 --- a/packages/core/upload/admin/src/pages/HomePage/index.js +++ b/packages/core/upload/admin/src/pages/HomePage/index.js @@ -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 { diff --git a/packages/core/upload/admin/src/pages/HomePage/utils/generateStringFromParams.js b/packages/core/upload/admin/src/pages/HomePage/utils/generateStringFromParams.js deleted file mode 100644 index b67f9e999c..0000000000 --- a/packages/core/upload/admin/src/pages/HomePage/utils/generateStringFromParams.js +++ /dev/null @@ -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; diff --git a/packages/core/upload/admin/src/pages/HomePage/utils/index.js b/packages/core/upload/admin/src/pages/HomePage/utils/index.js index 45124e524b..31c65f3993 100644 --- a/packages/core/upload/admin/src/pages/HomePage/utils/index.js +++ b/packages/core/upload/admin/src/pages/HomePage/utils/index.js @@ -1,2 +1 @@ -export { default as generateStringFromParams } from './generateStringFromParams'; export { default as getHeaderLabel } from './getHeaderLabel'; diff --git a/packages/core/upload/admin/src/pages/HomePage/utils/tests/generateStringFromParams.test.js b/packages/core/upload/admin/src/pages/HomePage/utils/tests/generateStringFromParams.test.js deleted file mode 100644 index 21a30bd9ea..0000000000 --- a/packages/core/upload/admin/src/pages/HomePage/utils/tests/generateStringFromParams.test.js +++ /dev/null @@ -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); - }); - }); - }); -}); diff --git a/packages/core/upload/admin/src/translations/en.json b/packages/core/upload/admin/src/translations/en.json index bba5fc51da..6235a99665 100644 --- a/packages/core/upload/admin/src/translations/en.json +++ b/packages/core/upload/admin/src/translations/en.json @@ -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", diff --git a/packages/core/upload/admin/src/pages/HomePage/utils/generateParamsFromQuery.js b/packages/core/upload/admin/src/utils/generateParamsFromQuery.js similarity index 100% rename from packages/core/upload/admin/src/pages/HomePage/utils/generateParamsFromQuery.js rename to packages/core/upload/admin/src/utils/generateParamsFromQuery.js diff --git a/packages/core/upload/admin/src/pages/HomePage/utils/tests/generateParamsFromQuery.test.js b/packages/core/upload/admin/src/utils/tests/generateParamsFromQuery.test.js similarity index 100% rename from packages/core/upload/admin/src/pages/HomePage/utils/tests/generateParamsFromQuery.test.js rename to packages/core/upload/admin/src/utils/tests/generateParamsFromQuery.test.js