Added dirty state navigation blocking to AdminX settings

refs https://github.com/TryGhost/Team/issues/3349
This commit is contained in:
Jono Mingard 2023-06-20 12:58:44 +10:00
parent 4fd0473f1b
commit f084fbd025
8 changed files with 145 additions and 23 deletions

View File

@ -4,6 +4,7 @@ import Heading from './admin-x-ds/global/Heading';
import NiceModal from '@ebay/nice-modal-react';
import Settings from './components/Settings';
import Sidebar from './components/Sidebar';
import {GlobalDirtyStateProvider} from './hooks/useGlobalDirtyState';
import {OfficialTheme} from './models/themes';
import {ServicesProvider} from './components/providers/ServiceProvider';
import {Toaster} from 'react-hot-toast';
@ -11,37 +12,40 @@ import {Toaster} from 'react-hot-toast';
interface AppProps {
ghostVersion: string;
officialThemes: OfficialTheme[]
setDirty?: (dirty: boolean) => void;
}
function App({ghostVersion, officialThemes}: AppProps) {
function App({ghostVersion, officialThemes, setDirty}: AppProps) {
return (
<ServicesProvider ghostVersion={ghostVersion} officialThemes={officialThemes}>
<DataProvider>
<div className="admin-x-settings">
<Toaster />
<NiceModal.Provider>
<div className='fixed left-6 top-4'>
<Button label='&larr; Done' link={true} onClick={() => window.history.back()} />
</div>
<GlobalDirtyStateProvider setDirty={setDirty}>
<div className="admin-x-settings">
<Toaster />
<NiceModal.Provider>
<div className='fixed left-6 top-4'>
<Button label='&larr; Done' link={true} onClick={() => window.history.back()} />
</div>
{/* Main container */}
<div className="mx-auto flex max-w-[1080px] flex-col px-[5vmin] py-[12vmin] md:flex-row md:items-start md:gap-x-10 md:py-[8vmin]">
{/* Main container */}
<div className="mx-auto flex max-w-[1080px] flex-col px-[5vmin] py-[12vmin] md:flex-row md:items-start md:gap-x-10 md:py-[8vmin]">
{/* Sidebar */}
<div className="relative min-w-[240px] grow-0 md:fixed md:top-[8vmin] md:basis-[240px]">
<div className='h-[84px]'>
<Heading>Settings</Heading>
{/* Sidebar */}
<div className="relative min-w-[240px] grow-0 md:fixed md:top-[8vmin] md:basis-[240px]">
<div className='h-[84px]'>
<Heading>Settings</Heading>
</div>
<div className="relative mt-[-32px] w-[240px] overflow-x-hidden after:absolute after:inset-x-0 after:top-0 after:block after:h-[40px] after:bg-gradient-to-b after:from-white after:to-transparent after:content-['']">
<Sidebar />
</div>
</div>
<div className="relative mt-[-32px] w-[240px] overflow-x-hidden after:absolute after:inset-x-0 after:top-0 after:block after:h-[40px] after:bg-gradient-to-b after:from-white after:to-transparent after:content-['']">
<Sidebar />
<div className="flex-auto pt-[3vmin] md:ml-[280px] md:pt-[84px]">
<Settings />
</div>
</div>
<div className="flex-auto pt-[3vmin] md:ml-[280px] md:pt-[84px]">
<Settings />
</div>
</div>
</NiceModal.Provider>
</div>
</NiceModal.Provider>
</div>
</GlobalDirtyStateProvider>
</DataProvider>
</ServicesProvider>
);

View File

@ -3,9 +3,10 @@ import ButtonGroup from '../ButtonGroup';
import ConfirmationModal from './ConfirmationModal';
import Heading from '../Heading';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React from 'react';
import React, {useEffect} from 'react';
import StickyFooter from '../StickyFooter';
import clsx from 'clsx';
import useGlobalDirtyState from '../../../hooks/useGlobalDirtyState';
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'bleed' | number;
@ -65,6 +66,11 @@ const Modal: React.FC<ModalProps> = ({
)
}) => {
const modal = useModal();
const {setGlobalDirtyState} = useGlobalDirtyState();
useEffect(() => {
setGlobalDirtyState(dirty);
}, [dirty, setGlobalDirtyState]);
let buttons: ButtonProps[] = [];

View File

@ -0,0 +1,57 @@
import React, {useCallback, useContext, useEffect, useId, useState} from 'react';
interface GlobalDirtyState {
setGlobalDirtyState: (reason: string, dirty: boolean) => void;
}
const GlobalDirtyStateContext = React.createContext<GlobalDirtyState>({setGlobalDirtyState: () => {}});
export const GlobalDirtyStateProvider = ({setDirty, children}: {setDirty?: (dirty: boolean) => void; children: React.ReactNode}) => {
// Allows each component to register itself as dirty, so when one is reset/saved the overall page dirty state persists
const [dirtyReasons, setDirtyReasons] = useState<string[]>([]);
const setGlobalDirtyState = useCallback((reason: string, dirty: boolean) => {
setDirtyReasons((current) => {
if (dirty && !current.includes(reason)) {
return [...current, reason];
}
if (!dirty && current.includes(reason)) {
return current.filter(currentReason => currentReason !== reason);
}
return current;
});
}, []);
useEffect(() => {
setDirty?.(dirtyReasons.length > 0);
}, [dirtyReasons, setDirty]);
return (
<GlobalDirtyStateContext.Provider value={{setGlobalDirtyState}}>
{children}
</GlobalDirtyStateContext.Provider>
);
};
const useGlobalDirtyState = () => {
const id = useId();
const {setGlobalDirtyState} = useContext(GlobalDirtyStateContext);
useEffect(() => {
// Make sure the state is reset when the component unmounts
return () => setGlobalDirtyState(id, false);
}, [id, setGlobalDirtyState]);
const setDirty = useCallback(
(dirty: boolean) => setGlobalDirtyState(id, dirty),
[id, setGlobalDirtyState]
);
return {
setGlobalDirtyState: setDirty
};
};
export default useGlobalDirtyState;

View File

@ -1,5 +1,6 @@
import React, {useContext, useEffect, useRef, useState} from 'react';
import useForm, {SaveState} from './useForm';
import useGlobalDirtyState from './useGlobalDirtyState';
import {Setting, SettingValue, SiteData} from '../types/api';
import {SettingsContext} from '../components/providers/SettingsProvider';
@ -36,6 +37,12 @@ const useSettingGroup = (): SettingGroupHook => {
}
});
const {setGlobalDirtyState} = useGlobalDirtyState();
useEffect(() => {
setGlobalDirtyState(localSettings.some(setting => setting.dirty));
}, [localSettings, setGlobalDirtyState]);
useEffect(() => {
if (isEditing && focusRef.current) {
focusRef.current.focus();

View File

@ -240,6 +240,7 @@ export default class AdminXSettings extends Component {
<AdminXApp
ghostVersion={config.APP.version}
officialThemes={officialThemes}
setDirty={this.args.setDirty}
/>
</Suspense>
</ErrorHandler>

View File

@ -2,13 +2,21 @@ import AboutModal from '../components/modals/settings/about';
import Controller from '@ember/controller';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
export default class SettingsController extends Controller {
@service modals;
@service upgradeStatus;
@tracked dirty = false;
@action
openAbout() {
this.advancedModal = this.modals.open(AboutModal);
}
@action
setDirty(dirty) {
this.dirty = dirty;
}
}

View File

@ -1,9 +1,12 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import ConfirmUnsavedChangesModal from '../components/modals/confirm-unsaved-changes';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default class SettingsXRoute extends AuthenticatedRoute {
@service session;
@service ui;
@service modals;
beforeModel() {
super.beforeModel(...arguments);
@ -28,4 +31,40 @@ export default class SettingsXRoute extends AuthenticatedRoute {
super.deactivate(...arguments);
this.ui.set('isFullScreen', false);
}
@action
async willTransition(transition) {
if (this.hasConfirmed) {
this.hasConfirmed = false;
return true;
}
transition.abort();
// wait for any existing confirm modal to be closed before allowing transition
if (this.confirmModal) {
return;
}
const shouldLeave = await this.confirmUnsavedChanges();
if (shouldLeave) {
this.hasConfirmed = true;
return transition.retry();
}
}
async confirmUnsavedChanges() {
if (this.controller.dirty) {
this.confirmModal = this.modals
.open(ConfirmUnsavedChangesModal)
.finally(() => {
this.confirmModal = null;
});
return this.confirmModal;
}
return true;
}
}

View File

@ -1 +1 @@
<AdminX::Settings />
<AdminX::Settings @setDirty={{this.setDirty}} />