diff --git a/packages/core/admin/admin/src/assets/images/icon_offline-cloud.svg b/packages/core/admin/admin/src/assets/images/icon_offline-cloud.svg new file mode 100644 index 0000000000..bed9d93484 --- /dev/null +++ b/packages/core/admin/admin/src/assets/images/icon_offline-cloud.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/index.js b/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/index.js index e81ac5bb07..c8dc356f04 100644 --- a/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/index.js +++ b/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/index.js @@ -269,7 +269,7 @@ const EditViewDataManagerProvider = ({ ); const createFormData = useCallback( - (data) => { + data => { // First we need to remove the added keys needed for the dnd const preparedData = removeKeyInObject(cloneDeep(data), '__temp_key__'); // Then we need to apply our helper @@ -293,7 +293,7 @@ const EditViewDataManagerProvider = ({ }, [hasDraftAndPublish, shouldNotRunValidations]); const handleSubmit = useCallback( - async (e) => { + async e => { e.preventDefault(); let errors = {}; @@ -363,8 +363,8 @@ const EditViewDataManagerProvider = ({ }, [allLayoutData, currentContentTypeLayout, isCreatingEntry, modifiedData, onPublish]); const shouldCheckDZErrors = useCallback( - (dzName) => { - const doesDZHaveError = Object.keys(formErrors).some((key) => key.split('.')[0] === dzName); + dzName => { + const doesDZHaveError = Object.keys(formErrors).some(key => key.split('.')[0] === dzName); const shouldCheckErrors = !isEmpty(formErrors) && doesDZHaveError; return shouldCheckErrors; @@ -418,7 +418,7 @@ const EditViewDataManagerProvider = ({ }); }, []); - const onRemoveRelation = useCallback((keys) => { + const onRemoveRelation = useCallback(keys => { dispatch({ type: 'REMOVE_RELATION', keys, diff --git a/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/utils/schema.js b/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/utils/schema.js index db7b94d8dc..0d846a4c09 100644 --- a/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/utils/schema.js +++ b/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/utils/schema.js @@ -10,11 +10,11 @@ import { translatedErrors as errorsTrads } from '@strapi/helper-plugin'; import isFieldTypeNumber from '../../../utils/isFieldTypeNumber'; yup.addMethod(yup.mixed, 'defined', function() { - return this.test('defined', errorsTrads.required, (value) => value !== undefined); + return this.test('defined', errorsTrads.required, value => value !== undefined); }); yup.addMethod(yup.array, 'notEmptyMin', function(min) { - return this.test('notEmptyMin', errorsTrads.min, (value) => { + return this.test('notEmptyMin', errorsTrads.min, value => { if (isEmpty(value)) { return true; } @@ -51,7 +51,7 @@ yup.addMethod(yup.string, 'isSuperior', function(message, min) { }); }); -const getAttributes = (data) => get(data, ['attributes'], {}); +const getAttributes = data => get(data, ['attributes'], {}); const createYupSchema = ( model, @@ -97,7 +97,7 @@ const createYupSchema = ( if (attribute.repeatable === true) { const { min, max, required } = attribute; - let componentSchema = yup.lazy((value) => { + let componentSchema = yup.lazy(value => { let baseSchema = yup.array().of(componentFieldSchema); if (min) { @@ -123,7 +123,7 @@ const createYupSchema = ( return acc; } - const componentSchema = yup.lazy((obj) => { + const componentSchema = yup.lazy(obj => { if (obj !== undefined) { return attribute.required === true && !options.isDraft ? componentFieldSchema.defined() @@ -154,7 +154,7 @@ const createYupSchema = ( if (min) { if (attribute.required) { dynamicZoneSchema = dynamicZoneSchema - .test('min', errorsTrads.min, (value) => { + .test('min', errorsTrads.min, value => { if (options.isCreatingEntry) { return value && value.length >= min; } @@ -165,7 +165,7 @@ const createYupSchema = ( return value !== null && value.length >= min; }) - .test('required', errorsTrads.required, (value) => { + .test('required', errorsTrads.required, value => { if (options.isCreatingEntry) { return value !== null || value !== undefined; } @@ -180,7 +180,7 @@ const createYupSchema = ( dynamicZoneSchema = dynamicZoneSchema.notEmptyMin(min); } } else if (attribute.required && !options.isDraft) { - dynamicZoneSchema = dynamicZoneSchema.test('required', errorsTrads.required, (value) => { + dynamicZoneSchema = dynamicZoneSchema.test('required', errorsTrads.required, value => { if (options.isCreatingEntry) { return value !== null || value !== undefined; } @@ -215,7 +215,7 @@ const createYupSchemaAttribute = (type, validations, options) => { if (type === 'json') { schema = yup .mixed(errorsTrads.json) - .test('isJSON', errorsTrads.json, (value) => { + .test('isJSON', errorsTrads.json, value => { if (value === undefined) { return true; } @@ -238,7 +238,7 @@ const createYupSchemaAttribute = (type, validations, options) => { if (['number', 'integer', 'float', 'decimal'].includes(type)) { schema = yup .number() - .transform((cv) => (isNaN(cv) ? undefined : cv)) + .transform(cv => (isNaN(cv) ? undefined : cv)) .typeError(); } @@ -250,7 +250,7 @@ const createYupSchemaAttribute = (type, validations, options) => { schema = yup.date(); } - Object.keys(validations).forEach((validation) => { + Object.keys(validations).forEach(validation => { const validationValue = validations[validation]; if ( @@ -269,7 +269,7 @@ const createYupSchemaAttribute = (type, validations, options) => { if (options.isCreatingEntry) { schema = schema.required(errorsTrads.required); } else { - schema = schema.test('required', errorsTrads.required, (value) => { + schema = schema.test('required', errorsTrads.required, value => { // Field is not touched and the user is editing the entry if (value === undefined && !options.isFromComponent) { return true; diff --git a/packages/core/admin/admin/src/content-manager/components/Inputs/index.js b/packages/core/admin/admin/src/content-manager/components/Inputs/index.js index 3f59cd56cf..15a75e80fc 100644 --- a/packages/core/admin/admin/src/content-manager/components/Inputs/index.js +++ b/packages/core/admin/admin/src/content-manager/components/Inputs/index.js @@ -157,10 +157,10 @@ function Inputs({ return disabled; }, [disabled, isCreatingEntry, isUserAllowedToEditField, isUserAllowedToReadField]); - const options = useMemo( - () => generateOptions(fieldSchema.enum || [], isRequired), - [fieldSchema, isRequired] - ); + const options = useMemo(() => generateOptions(fieldSchema.enum || [], isRequired), [ + fieldSchema, + isRequired, + ]); const { label, description, placeholder, visible } = metadatas; diff --git a/packages/core/admin/admin/src/hooks/useNavigatorOnLine/index.js b/packages/core/admin/admin/src/hooks/useNavigatorOnLine/index.js new file mode 100644 index 0000000000..f2fed9c827 --- /dev/null +++ b/packages/core/admin/admin/src/hooks/useNavigatorOnLine/index.js @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react'; + +/** + * For more details about this hook see: + * https://www.30secondsofcode.org/react/s/use-navigator-on-line + */ +const useNavigatorOnLine = () => { + const onlineStatus = + typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean' + ? navigator.onLine + : true; + + const [isOnline, setIsOnline] = useState(onlineStatus); + + const setOnline = () => setIsOnline(true); + const setOffline = () => setIsOnline(false); + + useEffect(() => { + window.addEventListener('online', setOnline); + window.addEventListener('offline', setOffline); + + return () => { + window.removeEventListener('online', setOnline); + window.removeEventListener('offline', setOffline); + }; + }, []); + + return isOnline; +}; + +export default useNavigatorOnLine; diff --git a/packages/core/admin/admin/src/hooks/useNavigatorOnLine/tests/index.test.js b/packages/core/admin/admin/src/hooks/useNavigatorOnLine/tests/index.test.js new file mode 100644 index 0000000000..16a84eadb4 --- /dev/null +++ b/packages/core/admin/admin/src/hooks/useNavigatorOnLine/tests/index.test.js @@ -0,0 +1,48 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import useNavigatorOnLine from '../index'; + +describe('useNavigatorOnLine', () => { + it('returns the online state', () => { + jest.spyOn(window.navigator, 'onLine', 'get').mockReturnValue(true); + const { result } = renderHook(() => useNavigatorOnLine()); + + expect(result.current).toEqual(true); + }); + + it('returns the offline state', () => { + jest.spyOn(window.navigator, 'onLine', 'get').mockReturnValue(false); + const { result } = renderHook(() => useNavigatorOnLine()); + + expect(result.current).toEqual(false); + }); + + it('listens for network change online', async () => { + // Initialize an offline state + jest.spyOn(window.navigator, 'onLine', 'get').mockReturnValue(false); + const { result, waitForNextUpdate } = renderHook(() => useNavigatorOnLine()); + + await act(async () => { + // Simulate a change from offline to online + window.dispatchEvent(new window.Event('online')); + + await waitForNextUpdate(); + }); + + expect(result.current).toEqual(true); + }); + + it('listens for network change offline', async () => { + // Initialize an online state + jest.spyOn(window.navigator, 'onLine', 'get').mockReturnValue(true); + const { result, waitForNextUpdate } = renderHook(() => useNavigatorOnLine()); + + await act(async () => { + // Simulate a change from online to offline + window.dispatchEvent(new window.Event('offline')); + + await waitForNextUpdate(); + }); + + expect(result.current).toEqual(false); + }); +}); diff --git a/packages/core/admin/admin/src/pages/MarketplacePage/components/PageHeader/index.js b/packages/core/admin/admin/src/pages/MarketplacePage/components/PageHeader/index.js new file mode 100644 index 0000000000..b68e862098 --- /dev/null +++ b/packages/core/admin/admin/src/pages/MarketplacePage/components/PageHeader/index.js @@ -0,0 +1,46 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; +import { HeaderLayout } from '@strapi/design-system/Layout'; +import { LinkButton } from '@strapi/design-system/LinkButton'; +import Upload from '@strapi/icons/Upload'; +import { useTracking } from '@strapi/helper-plugin'; + +const PageHeader = ({ isOnline }) => { + const { formatMessage } = useIntl(); + const { trackUsage } = useTracking(); + + return ( + } + variant="tertiary" + href="https://market.strapi.io/submit-plugin" + onClick={() => trackUsage('didSubmitPlugin')} + > + {formatMessage({ + id: 'admin.pages.MarketPlacePage.submit.plugin.link', + defaultMessage: 'Submit your plugin', + })} + + ) + } + /> + ); +}; + +export default PageHeader; + +PageHeader.propTypes = { + isOnline: PropTypes.bool.isRequired, +}; diff --git a/packages/core/admin/admin/src/pages/MarketplacePage/index.js b/packages/core/admin/admin/src/pages/MarketplacePage/index.js index 3f4a703eab..8ba4657f86 100644 --- a/packages/core/admin/admin/src/pages/MarketplacePage/index.js +++ b/packages/core/admin/admin/src/pages/MarketplacePage/index.js @@ -13,20 +13,23 @@ import { useAppInfos, } from '@strapi/helper-plugin'; import { Grid, GridItem } from '@strapi/design-system/Grid'; -import { Layout, HeaderLayout, ContentLayout } from '@strapi/design-system/Layout'; +import { Layout, ContentLayout } from '@strapi/design-system/Layout'; import { Main } from '@strapi/design-system/Main'; import { Searchbar } from '@strapi/design-system/Searchbar'; import { Box } from '@strapi/design-system/Box'; -import { LinkButton } from '@strapi/design-system/LinkButton'; import { useNotifyAT } from '@strapi/design-system/LiveRegions'; -import Upload from '@strapi/icons/Upload'; +import { Typography } from '@strapi/design-system/Typography'; +import { Flex } from '@strapi/design-system/Flex'; import PluginCard from './components/PluginCard'; import { EmptyPluginSearch } from './components/EmptyPluginSearch'; +import PageHeader from './components/PageHeader'; import { fetchAppInformation } from './utils/api'; import useFetchInstalledPlugins from '../../hooks/useFetchInstalledPlugins'; import useFetchMarketplacePlugins from '../../hooks/useFetchMarketplacePlugins'; import adminPermissions from '../../permissions'; +import offlineCloud from '../../assets/images/icon_offline-cloud.svg'; +import useNavigatorOnLine from '../../hooks/useNavigatorOnLine'; import MissingPluginBanner from './components/MissingPluginBanner'; const matchSearch = (plugins, search) => { @@ -49,6 +52,7 @@ const MarketPlacePage = () => { const toggleNotification = useNotification(); const [searchQuery, setSearchQuery] = useState(''); const { autoReload: isInDevelopmentMode } = useAppInfos(); + const isOnline = useNavigatorOnLine(); useFocusWhenNavigate(); @@ -117,6 +121,42 @@ const MarketPlacePage = () => { } }, [toggleNotification, isInDevelopmentMode]); + if (!isOnline) { + return ( + +
+ + + + + {formatMessage({ + id: 'admin.pages.MarketPlacePage.offline.title', + defaultMessage: 'You are offline', + })} + + + + + {formatMessage({ + id: 'admin.pages.MarketPlacePage.offline.subtitle', + defaultMessage: + 'You need to be connected to the Internet to access Strapi Market.', + })} + + + offline + +
+
+ ); + } + if (hasFailed) { return ( @@ -151,29 +191,7 @@ const MarketPlacePage = () => { defaultMessage: 'Marketplace - Plugins', })} /> - } - variant="tertiary" - href="https://market.strapi.io/submit-plugin" - onClick={() => trackUsage('didSubmitPlugin')} - > - {formatMessage({ - id: 'admin.pages.MarketPlacePage.submit.plugin.link', - defaultMessage: 'Submit your plugin', - })} - - } - /> + jest.fn(() => true)); + jest.mock('@strapi/helper-plugin', () => ({ ...jest.requireActual('@strapi/helper-plugin'), useNotification: jest.fn(() => { @@ -1664,7 +1667,7 @@ describe('Marketplace page', () => { expect(noResult).toBeVisible(); }); - it('handles production environment', async () => { + it('handles production environment', () => { // Simulate production environment useAppInfos.mockImplementation(() => ({ autoReload: false })); const { queryByText } = render(App); @@ -1678,9 +1681,25 @@ describe('Marketplace page', () => { }, blockTransition: true, }); + expect(toggleNotification).toHaveBeenCalledTimes(1); // Should not show install buttons expect(queryByText(/copy install command/i)).not.toBeInTheDocument(); }); + + it('shows an online layout', () => { + render(App); + const offlineText = screen.queryByText('You are offline'); + + expect(offlineText).toEqual(null); + }); + + it('shows the offline layout', () => { + useNavigatorOnLine.mockReturnValueOnce(false); + render(App); + const offlineText = screen.getByText('You are offline'); + + expect(offlineText).toBeVisible(); + }); }); diff --git a/packages/core/admin/admin/src/translations/en.json b/packages/core/admin/admin/src/translations/en.json index dd1f8d3b0a..c15c833aab 100644 --- a/packages/core/admin/admin/src/translations/en.json +++ b/packages/core/admin/admin/src/translations/en.json @@ -205,6 +205,8 @@ "Users.components.List.empty.withFilters": "There is no users with the applied filters...", "Users.components.List.empty.withSearch": "There is no users corresponding to the search ({search})...", "admin.pages.MarketPlacePage.helmet": "Marketplace - Plugins", + "admin.pages.MarketPlacePage.offline.title": "You are offline", + "admin.pages.MarketPlacePage.offline.subtitle": "You need to be connected to the Internet to access Strapi Market.", "admin.pages.MarketPlacePage.plugin.copy": "Copy install command", "admin.pages.MarketPlacePage.plugin.copy.success": "Install command ready to be pasted in your terminal", "admin.pages.MarketPlacePage.plugin.info": "Learn more", diff --git a/packages/core/helper-plugin/lib/src/utils/getYupInnerErrors/index.js b/packages/core/helper-plugin/lib/src/utils/getYupInnerErrors/index.js index 92fd58a89b..a71608194b 100644 --- a/packages/core/helper-plugin/lib/src/utils/getYupInnerErrors/index.js +++ b/packages/core/helper-plugin/lib/src/utils/getYupInnerErrors/index.js @@ -1,8 +1,14 @@ import { get } from 'lodash'; -const getYupInnerErrors = (error) => { +const getYupInnerErrors = error => { return get(error, 'inner', []).reduce((acc, curr) => { - acc[curr.path.split('[').join('.').split(']').join('')] = { + acc[ + curr.path + .split('[') + .join('.') + .split(']') + .join('') + ] = { id: curr.message, defaultMessage: curr.message, };