mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-17 21:41:49 +03:00
Added dirty state navigation blocking to AdminX settings
refs https://github.com/TryGhost/Team/issues/3349
This commit is contained in:
parent
4fd0473f1b
commit
f084fbd025
@ -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='← 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='← 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>
|
||||
);
|
||||
|
@ -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[] = [];
|
||||
|
||||
|
57
ghost/admin-x-settings/src/hooks/useGlobalDirtyState.tsx
Normal file
57
ghost/admin-x-settings/src/hooks/useGlobalDirtyState.tsx
Normal 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;
|
@ -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();
|
||||
|
@ -240,6 +240,7 @@ export default class AdminXSettings extends Component {
|
||||
<AdminXApp
|
||||
ghostVersion={config.APP.version}
|
||||
officialThemes={officialThemes}
|
||||
setDirty={this.args.setDirty}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorHandler>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -1 +1 @@
|
||||
<AdminX::Settings />
|
||||
<AdminX::Settings @setDirty={{this.setDirty}} />
|
Loading…
Reference in New Issue
Block a user