diff --git a/src/admin/components/Routes.tsx b/src/admin/components/Routes.tsx index 80696f35ea..2c69f7e740 100644 --- a/src/admin/components/Routes.tsx +++ b/src/admin/components/Routes.tsx @@ -13,8 +13,8 @@ import Versions from './views/Versions'; import Version from './views/Version'; import { DocumentInfoProvider } from './utilities/DocumentInfo'; import { useLocale } from './utilities/Locale'; -import { useFullscreenLoader } from './utilities/FullscreenLoaderProvider'; -import { ForceFullscreenLoader } from './elements/Loading'; +import { useLoadingOverlay } from './utilities/LoadingOverlay'; +import { FullscreenLoader } from './elements/Loading'; const Dashboard = lazy(() => import('./views/Dashboard')); const ForgotPassword = lazy(() => import('./views/ForgotPassword')); @@ -33,7 +33,7 @@ const Routes = () => { const [initialized, setInitialized] = useState(null); const { user, permissions, refreshCookie } = useAuth(); const { i18n } = useTranslation(); - const { setShowLoader } = useFullscreenLoader(); + const { toggleLoadingOverlay } = useLoadingOverlay(); const locale = useLocale(); const canAccessAdmin = permissions?.canAccessAdmin; @@ -74,12 +74,9 @@ const Routes = () => { } }, [i18n.language, routes, userCollection]); - React.useEffect(() => { - setShowLoader(isLoadingUser); - }, [isLoadingUser, setShowLoader]); - return ( - }> + }> + { diff --git a/src/admin/components/elements/DatePicker/index.tsx b/src/admin/components/elements/DatePicker/index.tsx index 92c400b855..2444293d1f 100644 --- a/src/admin/components/elements/DatePicker/index.tsx +++ b/src/admin/components/elements/DatePicker/index.tsx @@ -1,5 +1,5 @@ import React, { Suspense, lazy } from 'react'; -import Loading from '../Loading'; +import { Loading } from '../Loading'; import { Props } from './types'; const DatePicker = lazy(() => import('./DatePicker')); diff --git a/src/admin/components/elements/Loading/index.scss b/src/admin/components/elements/Loading/index.scss index 754f88abf5..e591d779f8 100644 --- a/src/admin/components/elements/Loading/index.scss +++ b/src/admin/components/elements/Loading/index.scss @@ -1 +1,139 @@ @import '../../../scss/styles'; + +.fullscreen-loader { + isolation: isolate; + height: 100%; + width: 100%; + left: 0; + top: 0; + bottom: 0; + position: fixed; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + pointer-events: none; + z-index: calc(var(--z-status) + 1); + + &.fullscreen-loader--entering { + opacity: 1; + animation: 500ms fade-in ease-in-out; + pointer-events: all; + } + + &.fullscreen-loader--exiting { + opacity: 0; + animation: 500ms fade-out ease-in-out; + } + + &.fullscreen-loader--withoutNav { + left: var(--nav-width); + width: calc(100% - var(--nav-width)); + } + + &:after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: var(--theme-bg); + opacity: .85; + z-index: -1; + } + + &__bars { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr 1fr; + gap: 7px; + align-items: center; + } + + &__bar { + width: 2px; + background-color: white; + height: 15px; + + &:nth-child(1) { + transform: translateY(0); + animation: animate-bar--odd 1.25s infinite; + } + + &:nth-child(2) { + transform: translateY(-2px); + animation: animate-bar--even 1.25s infinite; + } + + &:nth-child(3) { + transform: translateY(0); + animation: animate-bar--odd 1.25s infinite; + } + + &:nth-child(4) { + transform: translateY(-2px); + animation: animate-bar--even 1.25s infinite; + } + + &:nth-child(5) { + transform: translateY(0); + animation: animate-bar--odd 1.25s infinite; + } + } + + &__text { + margin-top: base(.75); + text-transform: uppercase; + font-family: var(--font-body); + font-size: base(.75); + letter-spacing: 3px; + } +} + +@keyframes animate-bar--even { + 0% { + transform: translateY(2px); + } + + 50% { + transform: translateY(-2px); + } + + 100% { + transform: translateY(2px); + } +} + +@keyframes animate-bar--odd { + 0% { + transform: translateY(-2px); + } + + 50% { + transform: translateY(2px); + } + + 100% { + transform: translateY(-2px); + } +} + +@keyframes fade-in { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes fade-out { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} diff --git a/src/admin/components/elements/Loading/index.tsx b/src/admin/components/elements/Loading/index.tsx index 7c1377b26e..ea6f27e363 100644 --- a/src/admin/components/elements/Loading/index.tsx +++ b/src/admin/components/elements/Loading/index.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { useFullscreenLoader } from '../../utilities/FullscreenLoaderProvider'; +import { useLoadingOverlay } from '../../utilities/LoadingOverlay'; import './index.scss'; -const Loading: React.FC = () => { +export const Loading: React.FC = () => { const baseClass = 'loading'; const { t } = useTranslation('general'); @@ -18,18 +18,53 @@ const Loading: React.FC = () => { ); }; -export const ForceFullscreenLoader: React.FC = () => { - const { setShowLoader } = useFullscreenLoader(); +const baseClass = 'fullscreen-loader'; + +type Props = { + show?: boolean; + loadingText?: string; + overlayType?: string +} +export const FullscreenLoader: React.FC = ({ loadingText, show = true, overlayType }) => { + const { t } = useTranslation('general'); + + return ( +
+
+
+
+
+
+
+
+ + {loadingText || t('loading')} +
+ ); +}; + +export const SuspenseLoader: React.FC = () => { + const { toggleLoadingOverlay } = useLoadingOverlay(); React.useEffect(() => { - setShowLoader(true); + toggleLoadingOverlay({ + key: 'suspense', + isLoading: true, + }); return () => { - setShowLoader(false); + toggleLoadingOverlay({ + key: 'suspense', + isLoading: false, + }); }; - }, [setShowLoader]); + }, [toggleLoadingOverlay]); return null; }; - -export default Loading; diff --git a/src/admin/components/elements/ShimmerEffect/index.scss b/src/admin/components/elements/ShimmerEffect/index.scss new file mode 100644 index 0000000000..cf46164976 --- /dev/null +++ b/src/admin/components/elements/ShimmerEffect/index.scss @@ -0,0 +1,26 @@ +.shimmer-effect { + position: relative; + overflow: hidden; + + &__shimmer { + position: absolute; + height: 100%; + width: 100%; + top: 0; + left: 0; + transform: translateX(-100%); + background: linear-gradient(130deg, + rgba(0, 0, 0, 0) 0%, + rgba(#1A1A1A, .5) 35%, + rgba(#252525, 1) 50%, + rgba(#1A1A1A, .5) 65%, + rgba(0, 0, 0, 0) 100%); + animation: shimmer 3s infinite; + } +} + +@keyframes shimmer { + 100% { + transform: translateX(100%); + } +} diff --git a/src/admin/components/elements/ShimmerEffect/index.tsx b/src/admin/components/elements/ShimmerEffect/index.tsx new file mode 100644 index 0000000000..39aac4de25 --- /dev/null +++ b/src/admin/components/elements/ShimmerEffect/index.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; + +import './index.scss'; + +export const ShimmerEffect: React.FC<{ children?: React.ReactNode }> = ({ children }) => { + return ( +
+ {children} +
+
+ ); +}; diff --git a/src/admin/components/forms/field-types/Code/index.tsx b/src/admin/components/forms/field-types/Code/index.tsx index ad9281fd83..823fab6a79 100644 --- a/src/admin/components/forms/field-types/Code/index.tsx +++ b/src/admin/components/forms/field-types/Code/index.tsx @@ -1,5 +1,5 @@ import React, { Suspense, lazy } from 'react'; -import Loading from '../../../elements/Loading'; +import { Loading } from '../../../elements/Loading'; import { Props } from './types'; const Code = lazy(() => import('./Code')); diff --git a/src/admin/components/forms/field-types/JSON/index.tsx b/src/admin/components/forms/field-types/JSON/index.tsx index b707c411e3..78b3f06e63 100644 --- a/src/admin/components/forms/field-types/JSON/index.tsx +++ b/src/admin/components/forms/field-types/JSON/index.tsx @@ -1,5 +1,5 @@ import React, { Suspense, lazy } from 'react'; -import Loading from '../../../elements/Loading'; +import { Loading } from '../../../elements/Loading'; import { Props } from './types'; const JSON = lazy(() => import('./JSON')); diff --git a/src/admin/components/forms/field-types/RichText/index.tsx b/src/admin/components/forms/field-types/RichText/index.tsx index c11058f19f..1212e0c1fc 100644 --- a/src/admin/components/forms/field-types/RichText/index.tsx +++ b/src/admin/components/forms/field-types/RichText/index.tsx @@ -1,5 +1,5 @@ import React, { Suspense, lazy } from 'react'; -import Loading from '../../../elements/Loading'; +import { Loading } from '../../../elements/Loading'; import { Props } from './types'; const RichText = lazy(() => import('./RichText')); diff --git a/src/admin/components/utilities/FullscreenLoaderProvider/index.scss b/src/admin/components/utilities/FullscreenLoaderProvider/index.scss deleted file mode 100644 index 049afe68dd..0000000000 --- a/src/admin/components/utilities/FullscreenLoaderProvider/index.scss +++ /dev/null @@ -1,133 +0,0 @@ -@import '../../../scss/styles'; - -.fullscreenLoader { - isolation: isolate; - height: 100%; - width: 100%; - left: 0; - top: 0; - bottom: 0; - position: fixed; - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - z-index: 9000; - backdrop-filter: blur(5px); - - &.fullscreenLoader--entering { - opacity: 1; - animation: fade-in ease-in-out; - } - - &.fullscreenLoader--exiting { - opacity: 0; - animation: fade-out ease-in-out; - } - - &:after { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: var(--theme-bg); - opacity: .85; - z-index: -1; - } - - &__bars { - display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr 1fr; - gap: 7px; - align-items: center; - } - - &__bar { - width: 2px; - background-color: white; - height: 15px; - - &:nth-child(1) { - transform: translateY(0); - animation: animate-bar--odd 1s infinite; - } - - &:nth-child(2) { - transform: translateY(-2px); - animation: animate-bar--even 1s infinite; - } - - &:nth-child(3) { - transform: translateY(0); - animation: animate-bar--odd 1s infinite; - } - - &:nth-child(4) { - transform: translateY(-2px); - animation: animate-bar--even 1s infinite; - } - - &:nth-child(5) { - transform: translateY(0); - animation: animate-bar--odd 1s infinite; - } - } - - &__text { - margin-top: base(.75); - text-transform: uppercase; - font-family: var(--font-body); - font-size: base(.75); - letter-spacing: 3px; - } -} - -@keyframes animate-bar--even { - 0% { - transform: translateY(2px); - } - - 50% { - transform: translateY(-2px); - } - - 100% { - transform: translateY(2px); - } -} - -@keyframes animate-bar--odd { - 0% { - transform: translateY(-2px); - } - - 50% { - transform: translateY(2px); - } - - 100% { - transform: translateY(-2px); - } -} - -@keyframes fade-in { - 0% { - opacity: 0; - } - - 100% { - opacity: 1; - } -} - -@keyframes fade-out { - 0% { - opacity: 1; - } - - 100% { - opacity: 0; - } -} diff --git a/src/admin/components/utilities/FullscreenLoaderProvider/index.tsx b/src/admin/components/utilities/FullscreenLoaderProvider/index.tsx deleted file mode 100644 index 0d7196d933..0000000000 --- a/src/admin/components/utilities/FullscreenLoaderProvider/index.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import React, { - createContext, useContext, useState, -} from 'react'; -import { useTranslation } from 'react-i18next'; -import { useDelay } from '../../../hooks/useDelay'; - -import './index.scss'; - -export type FullscreenLoaderContext = { - showLoader: boolean - setShowLoader: (show: boolean) => void -} - -const initialContext: FullscreenLoaderContext = { - showLoader: false, - setShowLoader: undefined, -}; - -const Context = createContext(initialContext); - -const delayBeforeRender = 500; -const animationDuration = 500; -const minShowTime = (animationDuration * 2) + 750; - -const baseClass = 'fullscreenLoader'; - -export const FullscreenLoaderProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => { - const { t } = useTranslation('general'); - - const [showLoader, setShowLoader] = useState(false); - const [isShowing, setIsShowing] = React.useState(false); - const [hasDelayed, setHasDelayed] = useDelay(delayBeforeRender, showLoader); - const [isHiding, setIsHiding] = React.useState(false); - const [displayedAt, setDisplayedAt] = React.useState(); - - React.useEffect(() => { - if (hasDelayed && showLoader && !isShowing) { - setDisplayedAt(Date.now()); - setIsShowing(true); - } - }, [showLoader, isShowing, displayedAt, hasDelayed]); - - React.useEffect(() => { - let closeTimeout: NodeJS.Timeout; - const shouldHide = isShowing && !showLoader; - - const hide = () => { - const timeDisplayed = Date.now() - displayedAt; - const remainingShowTime = minShowTime - timeDisplayed; - - if (remainingShowTime > 0 && timeDisplayed < minShowTime) { - closeTimeout = setTimeout(hide, remainingShowTime); - } else { - setIsHiding(true); - closeTimeout = setTimeout(() => { - setIsShowing(false); - setHasDelayed(false); - setIsHiding(false); - }, animationDuration); - } - }; - - - if (shouldHide) { - hide(); - } - - return () => { - if (closeTimeout) clearTimeout(closeTimeout); - }; - }, [showLoader, isShowing, displayedAt, setHasDelayed]); - - return ( - - {hasDelayed && isShowing && ( -
-
-
-
-
-
-
-
- - {t('loading')} -
- )} - {children} - - ); -}; - -export const useFullscreenLoader = (): FullscreenLoaderContext => useContext(Context); - -export default Context; diff --git a/src/admin/components/utilities/LoadingOverlay/index.tsx b/src/admin/components/utilities/LoadingOverlay/index.tsx new file mode 100644 index 0000000000..46987e9994 --- /dev/null +++ b/src/admin/components/utilities/LoadingOverlay/index.tsx @@ -0,0 +1,72 @@ +import React, { + createContext, useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { useTimeoutRender } from '../../../hooks/useSuspendedRender'; +import { reducer, defaultLoadingOverlayState } from './reducer'; +import { FullscreenLoader } from '../../elements/Loading'; +import type { LoadingOverlayContext, ToggleLoadingOverlay } from './types'; + +const initialContext: LoadingOverlayContext = { + toggleLoadingOverlay: undefined, + setLoadingOverlayText: undefined, +}; + +const Context = createContext(initialContext); + +export const LoadingOverlayProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => { + const { t } = useTranslation('general'); + + const [loadingOverlayText, setLoadingOverlayText] = useState(t('loading')); + const [overlays, dispatchOverlay] = React.useReducer(reducer, defaultLoadingOverlayState); + const { isMounted, isUnmounting, triggerRenderTimeout: suspendDisplay } = useTimeoutRender({ show: overlays.isLoading }); + + const toggleLoadingOverlay = React.useCallback(({ type, key, isLoading }) => { + if (isLoading) { + suspendDisplay(); + dispatchOverlay({ + type: 'add', + payload: { + type, + key, + }, + }); + } else { + dispatchOverlay({ + type: 'remove', + payload: { + key, + }, + }); + } + }, [suspendDisplay]); + + return ( + + {isMounted && ( + + )} + {children} + + ); +}; + +export const useLoadingOverlay = (): LoadingOverlayContext => { + const contextHook = React.useContext(Context); + if (contextHook === undefined) { + throw new Error('useLoadingOverlay must be used within a LoadingOverlayProvider'); + } + + return contextHook; +}; + +export default Context; diff --git a/src/admin/components/utilities/LoadingOverlay/reducer.ts b/src/admin/components/utilities/LoadingOverlay/reducer.ts new file mode 100644 index 0000000000..33ecbd4763 --- /dev/null +++ b/src/admin/components/utilities/LoadingOverlay/reducer.ts @@ -0,0 +1,26 @@ +import { Action, State } from './types'; + +export const defaultLoadingOverlayState = { + isLoading: false, + overlayType: null, + loaders: [], +}; + +// react reducer return type +export const reducer = (state: State, action: Action) => { + const loadersCopy = [...state.loaders]; + const { type = 'fullscreen', key = 'user' } = action.payload; + + if (action.type === 'add') { + loadersCopy.push({ type, key }); + } else if (action.type === 'remove') { + const index = loadersCopy.findIndex((item) => item.key === key && item.type === type); + loadersCopy.splice(index, 1); + } + + return { + isLoading: loadersCopy.length > 0, + overlayType: loadersCopy.length > 0 ? loadersCopy[loadersCopy.length - 1].type : state?.overlayType, + loaders: loadersCopy, + }; +}; diff --git a/src/admin/components/utilities/LoadingOverlay/types.ts b/src/admin/components/utilities/LoadingOverlay/types.ts new file mode 100644 index 0000000000..9a93b0611f --- /dev/null +++ b/src/admin/components/utilities/LoadingOverlay/types.ts @@ -0,0 +1,47 @@ +export type LoadingOverlayTypes = 'fullscreen' | 'withoutNav' + +type ToggleLoadingOverlayOptions = { + type?: LoadingOverlayTypes + key: string + isLoading?: boolean +} +export type ToggleLoadingOverlay = (options: ToggleLoadingOverlayOptions) => void + +type Add = { + type: 'add' + payload: { + type: LoadingOverlayTypes + key: string + loadingText?: string + } +} +type Remove = { + type: 'remove' + payload: { + key: string + type?: never + loadingText?: never + } +} +export type Action = Add | Remove +export type State = { + isLoading: boolean + overlayType: null | string + loaders: { + type: LoadingOverlayTypes + key: string + }[] +} +export type ReducerState = { + isLoading: boolean + overlayType: null | LoadingOverlayTypes + loaders: { + type: LoadingOverlayTypes + key: string + }[] +} + +export type LoadingOverlayContext = { + toggleLoadingOverlay: ToggleLoadingOverlay + setLoadingOverlayText?: (text: string) => void +} diff --git a/src/admin/components/views/Account/Default.tsx b/src/admin/components/views/Account/Default.tsx index 1d59574391..c989c44741 100644 --- a/src/admin/components/views/Account/Default.tsx +++ b/src/admin/components/views/Account/Default.tsx @@ -21,6 +21,7 @@ import { Gutter } from '../../elements/Gutter'; import ReactSelect from '../../elements/ReactSelect'; import Label from '../../forms/Label'; import type { Translation } from '../../../../translations/type'; +import { useLoadingOverlay } from '../../utilities/LoadingOverlay'; import './index.scss'; @@ -52,11 +53,20 @@ const DefaultAccount: React.FC = (props) => { const { admin: { dateFormat }, routes: { admin } } = useConfig(); const { t, i18n } = useTranslation('authentication'); + const { toggleLoadingOverlay } = useLoadingOverlay(); const languageOptions = Object.entries(i18n.options.resources).map(([language, resource]) => ( { label: (resource as Translation).general.thisLanguage, value: language } )); + React.useEffect(() => { + toggleLoadingOverlay({ + key: 'account', + type: 'withoutNav', + isLoading, + }); + }, [isLoading, toggleLoadingOverlay]); + const classes = [ baseClass, ].filter(Boolean).join(' '); diff --git a/src/admin/components/views/Account/index.tsx b/src/admin/components/views/Account/index.tsx index 29de9b83af..785da83e2c 100644 --- a/src/admin/components/views/Account/index.tsx +++ b/src/admin/components/views/Account/index.tsx @@ -12,7 +12,6 @@ import RenderCustomComponent from '../../utilities/RenderCustomComponent'; import { useDocumentInfo } from '../../utilities/DocumentInfo'; import { Fields } from '../../forms/Form/types'; import { usePreferences } from '../../utilities/Preferences'; -import { useFullscreenLoader } from '../../utilities/FullscreenLoaderProvider'; const AccountView: React.FC = () => { const { state: locationState } = useLocation<{ data: unknown }>(); @@ -22,7 +21,6 @@ const AccountView: React.FC = () => { const [initialState, setInitialState] = useState(); const { id, preferencesKey, docPermissions, getDocPermissions, slug } = useDocumentInfo(); const { getPreference } = usePreferences(); - const { setShowLoader } = useFullscreenLoader(); const { serverURL, @@ -40,7 +38,6 @@ const AccountView: React.FC = () => { } = useConfig(); const { t } = useTranslation('authentication'); - const isLoading = !initialState || !docPermissions; const collection = collections.find((coll) => coll.slug === slug); const { fields } = collection; @@ -76,8 +73,6 @@ const AccountView: React.FC = () => { }, [setStepNav, t]); useEffect(() => { - if (isLoadingData) return; - const awaitInitialState = async () => { const state = await buildStateFromSchema({ fieldSchema: fields, @@ -93,11 +88,9 @@ const AccountView: React.FC = () => { }; awaitInitialState(); - }, [dataToRender, fields, id, user, locale, preferencesKey, getPreference, t, isLoadingData]); + }, [dataToRender, fields, id, user, locale, preferencesKey, getPreference, t]); - useEffect(() => { - setShowLoader(isLoading); - }, [isLoading, setShowLoader]); + const isLoading = !initialState || !docPermissions || isLoadingData; return ( = (props) => { const { admin: { dateFormat } } = useConfig(); const { publishedDoc } = useDocumentInfo(); const { t, i18n } = useTranslation('general'); + const { toggleLoadingOverlay } = useLoadingOverlay(); const { global, data, onSave, permissions, action, apiURL, initialState, isLoading, updatedAt, @@ -49,6 +51,14 @@ const DefaultGlobalView: React.FC = (props) => { const hasSavePermission = permissions?.update?.permission; + React.useEffect(() => { + toggleLoadingOverlay({ + key: 'global-edit', + type: 'withoutNav', + isLoading, + }); + }, [toggleLoadingOverlay, isLoading]); + return (
{!isLoading && ( diff --git a/src/admin/components/views/Global/index.tsx b/src/admin/components/views/Global/index.tsx index 752c96f2f9..7e0c55fe05 100644 --- a/src/admin/components/views/Global/index.tsx +++ b/src/admin/components/views/Global/index.tsx @@ -13,7 +13,6 @@ import { IndexProps } from './types'; import { useDocumentInfo } from '../../utilities/DocumentInfo'; import { Fields } from '../../forms/Form/types'; import { usePreferences } from '../../utilities/Preferences'; -import { useFullscreenLoader } from '../../utilities/FullscreenLoaderProvider'; const GlobalView: React.FC = (props) => { const { state: locationState } = useLocation<{ data?: Record }>(); @@ -25,9 +24,6 @@ const GlobalView: React.FC = (props) => { const { getVersions, preferencesKey, docPermissions, getDocPermissions } = useDocumentInfo(); const { getPreference } = usePreferences(); const { t } = useTranslation(); - const { setShowLoader } = useFullscreenLoader(); - - const isLoading = !initialState || !docPermissions; const { serverURL, @@ -75,8 +71,6 @@ const GlobalView: React.FC = (props) => { }, [setStepNav, label]); useEffect(() => { - if (isLoadingData) return; - const awaitInitialState = async () => { const state = await buildStateFromSchema({ fieldSchema: fields, data: dataToRender, user, operation: 'update', locale, t }); await getPreference(preferencesKey); @@ -84,11 +78,9 @@ const GlobalView: React.FC = (props) => { }; awaitInitialState(); - }, [dataToRender, fields, user, locale, getPreference, preferencesKey, t, isLoadingData]); + }, [dataToRender, fields, user, locale, getPreference, preferencesKey, t]); - useEffect(() => { - setShowLoader(isLoading); - }, [isLoading, setShowLoader]); + const isLoading = !initialState || !docPermissions || isLoadingData; return ( { const history = useHistory(); const { t } = useTranslation('authentication'); const { user, setToken } = useAuth(); - const { setShowLoader } = useFullscreenLoader(); + const { toggleLoadingOverlay } = useLoadingOverlay(); const config = useConfig(); const { admin: { @@ -43,8 +43,11 @@ const Login: React.FC = () => { const collection = collections.find(({ slug }) => slug === userSlug); const onSuccess = (data) => { - setShowLoader(false); if (data.token) { + toggleLoadingOverlay({ + key: 'login', + isLoading: false, + }); setToken(data.token); history.push(admin); } @@ -97,7 +100,12 @@ const Login: React.FC = () => { disableSuccessStatus waitForAutocomplete disableNativeFormSubmission={false} - onSubmit={() => setShowLoader(true)} + onSubmit={() => { + toggleLoadingOverlay({ + key: 'login', + isLoading: true, + }); + }} onSuccess={onSuccess} method="post" action={`${serverURL}${api}/${userSlug}/login`} diff --git a/src/admin/components/views/Version/Version.tsx b/src/admin/components/views/Version/Version.tsx index 3b84752ded..c8742065a4 100644 --- a/src/admin/components/views/Version/Version.tsx +++ b/src/admin/components/views/Version/Version.tsx @@ -21,7 +21,7 @@ import { Field, FieldAffectingData, fieldAffectsData } from '../../../../fields/ import { FieldPermissions } from '../../../../auth'; import { useLocale } from '../../utilities/Locale'; import { Gutter } from '../../elements/Gutter'; -import { useFullscreenLoader } from '../../utilities/FullscreenLoaderProvider'; +import { useLoadingOverlay } from '../../utilities/LoadingOverlay'; import './index.scss'; @@ -37,7 +37,7 @@ const VersionView: React.FC = ({ collection, global }) => { const { permissions } = useAuth(); const locale = useLocale(); const { t, i18n } = useTranslation('version'); - const { setShowLoader } = useFullscreenLoader(); + const { toggleLoadingOverlay } = useLoadingOverlay(); let originalDocFetchURL: string; let versionFetchURL: string; @@ -141,8 +141,11 @@ const VersionView: React.FC = ({ collection, global }) => { }, [setStepNav, collection, global, dateFormat, doc, mostRecentDoc, admin, id, locale, t, i18n]); useEffect(() => { - setShowLoader(isLoadingData); - }, [isLoadingData, setShowLoader]); + toggleLoadingOverlay({ + key: 'version', + isLoading: isLoadingData, + }); + }, [isLoadingData, toggleLoadingOverlay]); let metaTitle: string; let metaDesc: string; diff --git a/src/admin/components/views/Version/index.tsx b/src/admin/components/views/Version/index.tsx index fee86184df..edd9ec8d12 100644 --- a/src/admin/components/views/Version/index.tsx +++ b/src/admin/components/views/Version/index.tsx @@ -1,5 +1,5 @@ import React, { Suspense, lazy } from 'react'; -import Loading from '../../elements/Loading'; +import { Loading } from '../../elements/Loading'; import { Props } from './types'; const VersionView = lazy(() => import('./Version')); diff --git a/src/admin/components/views/Versions/index.tsx b/src/admin/components/views/Versions/index.tsx index 224710f718..6532e2aff2 100644 --- a/src/admin/components/views/Versions/index.tsx +++ b/src/admin/components/views/Versions/index.tsx @@ -21,7 +21,7 @@ import { SanitizedGlobalConfig } from '../../../../globals/config/types'; import { shouldIncrementVersionCount } from '../../../../versions/shouldIncrementVersionCount'; import { Gutter } from '../../elements/Gutter'; import { getTranslation } from '../../../../utilities/getTranslation'; -import { useFullscreenLoader } from '../../utilities/FullscreenLoaderProvider'; +import { useLoadingOverlay } from '../../utilities/LoadingOverlay'; import './index.scss'; @@ -35,7 +35,7 @@ const Versions: React.FC = ({ collection, global }) => { const [tableColumns] = useState(() => getColumns(collection, global, t)); const [fetchURL, setFetchURL] = useState(''); const { page, sort, limit } = useSearchParams(); - const { setShowLoader } = useFullscreenLoader(); + const { toggleLoadingOverlay } = useLoadingOverlay(); let docURL: string; let entityLabel: string; @@ -148,8 +148,11 @@ const Versions: React.FC = ({ collection, global }) => { }, [setParams, page, sort, limit, serverURL, api, id, global, collection]); useEffect(() => { - setShowLoader(isLoadingData); - }, [isLoadingData, setShowLoader]); + toggleLoadingOverlay({ + key: 'versions', + isLoading: isLoadingData, + }); + }, [isLoadingData, toggleLoadingOverlay]); let useIDLabel = doc[useAsTitle] === doc?.id; let heading: string; diff --git a/src/admin/components/views/collections/Edit/Default.tsx b/src/admin/components/views/collections/Edit/Default.tsx index 5e4e6813ad..8f1b31cbcf 100644 --- a/src/admin/components/views/collections/Edit/Default.tsx +++ b/src/admin/components/views/collections/Edit/Default.tsx @@ -28,6 +28,7 @@ import { OperationContext } from '../../../utilities/OperationProvider'; import { Gutter } from '../../../elements/Gutter'; import { getTranslation } from '../../../../../utilities/getTranslation'; import { SetStepNav } from './SetStepNav'; +import { useLoadingOverlay } from '../../../utilities/LoadingOverlay'; import './index.scss'; @@ -57,6 +58,8 @@ const DefaultEditView: React.FC = (props) => { updatedAt, } = props; + const { toggleLoadingOverlay } = useLoadingOverlay(); + const { slug, fields, @@ -79,6 +82,14 @@ const DefaultEditView: React.FC = (props) => { const operation = isEditing ? 'update' : 'create'; + React.useEffect(() => { + toggleLoadingOverlay({ + key: 'collection-edit', + type: 'withoutNav', + isLoading, + }); + }, [isLoading, toggleLoadingOverlay]); + return (
{!isLoading && ( @@ -95,7 +106,7 @@ const DefaultEditView: React.FC = (props) => { )}
diff --git a/src/admin/components/views/collections/Edit/index.tsx b/src/admin/components/views/collections/Edit/index.tsx index c28748e1e8..5db899b38a 100644 --- a/src/admin/components/views/collections/Edit/index.tsx +++ b/src/admin/components/views/collections/Edit/index.tsx @@ -16,7 +16,6 @@ import { Fields } from '../../../forms/Form/types'; import { usePreferences } from '../../../utilities/Preferences'; import { EditDepthContext } from '../../../utilities/EditDepth'; import { CollectionPermission } from '../../../../../auth'; -import { useFullscreenLoader } from '../../../utilities/FullscreenLoaderProvider'; const EditView: React.FC = (props) => { const { collection: incomingCollection, isEditing } = props; @@ -47,9 +46,6 @@ const EditView: React.FC = (props) => { const { getVersions, preferencesKey, getDocPermissions, docPermissions } = useDocumentInfo(); const { getPreference } = usePreferences(); const { t } = useTranslation('general'); - const { setShowLoader } = useFullscreenLoader(); - - const isLoading = !initialState || !docPermissions; const onSave = useCallback(async (json: any) => { getVersions(); @@ -71,8 +67,6 @@ const EditView: React.FC = (props) => { const dataToRender = (locationState as Record)?.data || data; useEffect(() => { - if (isLoadingData) return; - const awaitInitialState = async () => { setUpdatedAt(dataToRender?.updatedAt); const state = await buildStateFromSchema({ fieldSchema: fields, data: dataToRender, user, operation: isEditing ? 'update' : 'create', id, locale, t }); @@ -89,10 +83,6 @@ const EditView: React.FC = (props) => { } }, [history, redirect]); - useEffect(() => { - setShowLoader(isLoading); - }, [isLoading, setShowLoader]); - if (isError) { return ( @@ -102,6 +92,7 @@ const EditView: React.FC = (props) => { const apiURL = `${serverURL}${api}/${slug}/${id}${collection.versions.drafts ? '?draft=true' : ''}`; const action = `${serverURL}${api}/${slug}${isEditing ? `/${id}` : ''}?locale=${locale}&depth=0&fallback-locale=null`; const hasSavePermission = (isEditing && docPermissions?.update?.permission) || (!isEditing && (docPermissions as CollectionPermission)?.create?.permission); + const isLoading = !initialState || !docPermissions || isLoadingData; return ( diff --git a/src/admin/hooks/useDelay.tsx b/src/admin/hooks/useDelay.tsx index 4b6bd890f2..e18a529e5e 100644 --- a/src/admin/hooks/useDelay.tsx +++ b/src/admin/hooks/useDelay.tsx @@ -1,21 +1,19 @@ import * as React from 'react'; -type Result = [boolean, React.Dispatch>]; -export const useDelay = (delay: number, run = true): Result => { +type Result = [boolean, () => void]; +export const useDelay = (delay: number): Result => { const [hasDelayed, setHasDelayed] = React.useState(false); - React.useEffect(() => { - let delayTimeout: NodeJS.Timeout; - if (run) { - delayTimeout = setTimeout(() => { - setHasDelayed(true); - }, delay); - } + const triggerDelay = React.useCallback(() => { + setHasDelayed(false); + const delayTimeout = setTimeout(() => { + setHasDelayed(true); + }, delay); return () => { clearTimeout(delayTimeout); }; - }, [delay, run]); + }, [delay]); - return [hasDelayed, setHasDelayed]; + return [hasDelayed, triggerDelay]; }; diff --git a/src/admin/hooks/useSuspendedRender.ts b/src/admin/hooks/useSuspendedRender.ts new file mode 100644 index 0000000000..558b2f241f --- /dev/null +++ b/src/admin/hooks/useSuspendedRender.ts @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { useDelay } from './useDelay'; + +type TimeoutRenderProps = { + /** `true` starts the mount process. + * `false` starts the unmount process. + * */ + show: boolean; + /** Time in ms to wait before "mounting" the component. */ + timeout?: number; + /** Time in ms the `appear` phase of the animation. (enter transition time + appear time) */ + appearTime?: number; + /** Time in ms the `exit` phase of the animation. */ + exitTimeout?: number; + /** Time in ms to wait before actually unmounting the component. */ + unmountTimeout?: number; +}; +type useTimeoutRender = (props: TimeoutRenderProps) => { + /** `true` if the component has mounted after the timeout. */ + isMounted: boolean; + /** `true` if the component is unmounting. */ + isUnmounting: boolean; + /** Call this function to trigger the timeout delay before rendering. */ + triggerRenderTimeout: () => void; +}; +export const useTimeoutRender: useTimeoutRender = ({ show, timeout = 500, appearTime = 1250, exitTimeout = 500 }) => { + const [hasDelayed, triggerDelay] = useDelay(timeout); + const [isMounted, setIsMounted] = React.useState(false); + const [isUnmounting, setIsUnmounting] = React.useState(false); + const onMountTimestampRef = React.useRef(0); + const unmountTimeoutRef: React.MutableRefObject = React.useRef(); + + const unmount = React.useCallback(() => { + setIsUnmounting(true); + unmountTimeoutRef.current = setTimeout(() => { + setIsMounted(false); + setIsUnmounting(false); + }, exitTimeout); + }, [setIsUnmounting, exitTimeout]); + + React.useEffect(() => { + const shouldMount = hasDelayed && !isMounted && show; + const shouldUnmount = isMounted && !show; + + if (shouldMount) { + onMountTimestampRef.current = Date.now(); + setIsMounted(true); + } else if (shouldUnmount) { + const totalTimeMounted = Date.now() - onMountTimestampRef.current; + const timeoutExtension = (appearTime + exitTimeout) - totalTimeMounted; + clearTimeout(unmountTimeoutRef.current); + unmountTimeoutRef.current = setTimeout(unmount, Math.max(0, timeoutExtension)); + } + }, [isMounted, show, unmount, appearTime, exitTimeout, hasDelayed]); + + return { + isMounted, + isUnmounting, + triggerRenderTimeout: triggerDelay, + }; +}; diff --git a/src/admin/index.tsx b/src/admin/index.tsx index 79c1224920..bc6654a40e 100644 --- a/src/admin/index.tsx +++ b/src/admin/index.tsx @@ -18,7 +18,7 @@ import Routes from './components/Routes'; import { StepNavProvider } from './components/elements/StepNav'; import { ThemeProvider } from './components/utilities/Theme'; import { I18n } from './components/utilities/I18n'; -import { FullscreenLoaderProvider } from './components/utilities/FullscreenLoaderProvider'; +import { LoadingOverlayProvider } from './components/utilities/LoadingOverlay'; import './scss/app.scss'; @@ -46,11 +46,11 @@ const Index = () => ( - + - +