Added basic routing for adminX settings

refs https://github.com/TryGhost/Product/issues/3349

- adds basic routing in adminX settings based on hash url path
- adds new routing provider that manages hash routing across adminX
- updates sidebar navigation to update route directly
- updates theme/design settings to update route for opening edit modals
This commit is contained in:
Rishabh 2023-07-04 20:26:46 +05:30 committed by Rishabh Garg
parent 56fcba1d27
commit 79a7f8c408
14 changed files with 196 additions and 43 deletions

View File

@ -2,6 +2,7 @@ import Button from './admin-x-ds/global/Button';
import DataProvider from './components/providers/DataProvider';
import Heading from './admin-x-ds/global/Heading';
import NiceModal from '@ebay/nice-modal-react';
import RoutingProvider from './components/providers/RoutingProvider';
import Settings from './components/Settings';
import Sidebar from './components/Sidebar';
import {GlobalDirtyStateProvider} from './hooks/useGlobalDirtyState';
@ -19,33 +20,35 @@ function App({ghostVersion, officialThemes, setDirty}: AppProps) {
return (
<ServicesProvider ghostVersion={ghostVersion} officialThemes={officialThemes}>
<DataProvider>
<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>
<RoutingProvider>
<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-[260px] grow-0 md:fixed md:top-[8vmin] md:basis-[260px]">
<div className='h-[84px]'>
<Heading>Settings</Heading>
{/* Sidebar */}
<div className="relative min-w-[260px] grow-0 md:fixed md:top-[8vmin] md:basis-[260px]">
<div className='h-[84px]'>
<Heading>Settings</Heading>
</div>
<div className="relative mt-[-32px] w-[260px] 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-[260px] 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-[300px] md:pt-[85px]">
<Settings />
</div>
</div>
<div className="flex-auto pt-[3vmin] md:ml-[300px] md:pt-[85px]">
<Settings />
</div>
</div>
</NiceModal.Provider>
</div>
</GlobalDirtyStateProvider>
</NiceModal.Provider>
</div>
</GlobalDirtyStateProvider>
</RoutingProvider>
</DataProvider>
</ServicesProvider>
);

View File

@ -3,16 +3,15 @@ import React from 'react';
import SettingNavItem from '../admin-x-ds/settings/SettingNavItem';
import SettingNavSection from '../admin-x-ds/settings/SettingNavSection';
import TextField from '../admin-x-ds/global/form/TextField';
import useRouting from '../hooks/useRouting';
import {useSearch} from './providers/ServiceProvider';
const Sidebar: React.FC = () => {
const {filter, setFilter} = useSearch();
const {updateRoute} = useRouting();
const handleSectionClick = (e: React.MouseEvent<HTMLButtonElement>) => {
const element = document.getElementById(e.currentTarget.name);
if (element) {
element.scrollIntoView({behavior: 'smooth'});
}
updateRoute(e.currentTarget.name);
};
return (

View File

@ -0,0 +1,106 @@
import ChangeThemeModal from '../settings/site/ThemeModal';
import DesignModal from '../settings/site/DesignModal';
import InviteUserModal from '../settings/general/InviteUserModal';
import NavigationModal from '../settings/site/NavigationModal';
import NiceModal from '@ebay/nice-modal-react';
import React, {createContext, useCallback, useContext, useEffect, useState} from 'react';
import {SettingsContext} from './SettingsProvider';
type RoutingContextProps = {
route: string;
updateRoute: (newPath: string) => void;
};
export const RouteContext = createContext<RoutingContextProps>({
route: '',
updateRoute: () => {}
});
function getHashPath(urlPath: string | undefined) {
if (!urlPath) {
return null;
}
const regex = /\/settings-x\/(.*)/;
const match = urlPath?.match(regex);
if (match) {
const afterSettingsX = match[1];
return afterSettingsX;
}
return null;
}
function handleNavigation() {
// Get the hash from the URL
let hash = window.location.hash;
// Remove the leading '#' character from the hash
hash = hash.substring(1);
// Get the path name from the hash
const pathName = getHashPath(hash);
if (pathName) {
if (pathName === 'themes/manage') {
NiceModal.show(ChangeThemeModal);
} else if (pathName === 'branding-and-design/edit') {
NiceModal.show(DesignModal);
} else if (pathName === 'navigation/edit') {
NiceModal.show(NavigationModal);
} else if (pathName === 'users/invite') {
NiceModal.show(InviteUserModal);
}
const element = document.getElementById(pathName);
if (element) {
element.scrollIntoView({behavior: 'smooth'});
}
return pathName;
}
return '';
}
type RouteProviderProps = {
children: React.ReactNode;
};
const RoutingProvider: React.FC<RouteProviderProps> = ({children}) => {
const [route, setRoute] = useState<string>('');
const {settingsLoaded} = useContext(SettingsContext) || {};
const updateRoute = useCallback((newPath: string) => {
if (newPath) {
window.location.hash = `/settings-x/${newPath}`;
} else {
window.location.hash = `/settings-x`;
}
}, []);
useEffect(() => {
const handleHashChange = () => {
const matchedRoute = handleNavigation();
setRoute(matchedRoute);
};
if (settingsLoaded) {
const matchedRoute = handleNavigation();
setRoute(matchedRoute);
}
window.addEventListener('hashchange', handleHashChange);
return () => {
window.removeEventListener('hashchange', handleHashChange);
};
}, [settingsLoaded]);
return (
<RouteContext.Provider value={{
updateRoute,
route
}}>
{children}
</RouteContext.Provider>
);
};
export default RoutingProvider;

View File

@ -8,6 +8,7 @@ interface SettingsContextProps {
saveSettings: (updatedSettings: Setting[]) => Promise<Setting[]>;
siteData: SiteData | null;
config: Config | null;
settingsLoaded: boolean;
}
interface SettingsProviderProps {
@ -18,6 +19,7 @@ const SettingsContext = createContext<SettingsContextProps>({
settings: null,
siteData: null,
config: null,
settingsLoaded: false,
saveSettings: async () => []
});
@ -84,6 +86,7 @@ const SettingsProvider: React.FC<SettingsProviderProps> = ({children}) => {
const [settings, setSettings] = useState<Setting[] | null> (null);
const [siteData, setSiteData] = useState<SiteData | null> (null);
const [config, setConfig] = useState<Config | null> (null);
const [settingsLoaded, setSettingsLoaded] = useState<boolean> (false);
useEffect(() => {
const fetchSettings = async (): Promise<void> => {
@ -98,6 +101,7 @@ const SettingsProvider: React.FC<SettingsProviderProps> = ({children}) => {
setSettings(serialiseSettingsData(settingsData.settings));
setSiteData(siteDataResponse.site);
setConfig(configData.config);
setSettingsLoaded(true);
} catch (error) {
// Log error in settings API
}
@ -127,7 +131,7 @@ const SettingsProvider: React.FC<SettingsProviderProps> = ({children}) => {
// Provide the settings and the saveSettings function to the children components
return (
<SettingsContext.Provider value={{
settings, saveSettings, siteData, config
settings, saveSettings, siteData, config, settingsLoaded
}}>
{children}
</SettingsContext.Provider>
@ -135,4 +139,3 @@ const SettingsProvider: React.FC<SettingsProviderProps> = ({children}) => {
};
export {SettingsContext, SettingsProvider};

View File

@ -3,6 +3,7 @@ import NiceModal from '@ebay/nice-modal-react';
import Radio from '../../../admin-x-ds/global/form/Radio';
import TextField from '../../../admin-x-ds/global/form/TextField';
import useRoles from '../../../hooks/useRoles';
import useRouting from '../../../hooks/useRouting';
import useStaffUsers from '../../../hooks/useStaffUsers';
import validator from 'validator';
import {ServicesContext} from '../../providers/ServiceProvider';
@ -15,6 +16,7 @@ const InviteUserModal = NiceModal.create(() => {
const {api} = useContext(ServicesContext);
const {roles, assignableRoles, getRoleId} = useRoles();
const {invites, setInvites} = useStaffUsers();
const {updateRoute} = useRouting();
const focusRef = useRef<HTMLInputElement>(null);
const [email, setEmail] = useState<string>('');
@ -116,6 +118,9 @@ const InviteUserModal = NiceModal.create(() => {
return (
<Modal
afterClose={() => {
updateRoute('users');
}}
cancelLabel=''
okLabel={okLabel}
size={540}

View File

@ -1,6 +1,5 @@
import Avatar from '../../../admin-x-ds/global/Avatar';
import Button from '../../../admin-x-ds/global/Button';
import InviteUserModal from './InviteUserModal';
import List from '../../../admin-x-ds/global/List';
import ListItem from '../../../admin-x-ds/global/ListItem';
import NiceModal from '@ebay/nice-modal-react';
@ -9,6 +8,7 @@ import React, {useContext, useState} from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import TabView from '../../../admin-x-ds/global/TabView';
import UserDetailModal from './UserDetailModal';
import useRouting from '../../../hooks/useRouting';
import useStaffUsers from '../../../hooks/useStaffUsers';
import {ServicesContext} from '../../providers/ServiceProvider';
import {User} from '../../../types/api';
@ -190,9 +190,9 @@ const Users: React.FC<{ keywords: string[] }> = ({keywords}) => {
invites,
updateUser
} = useStaffUsers();
const {updateRoute} = useRouting();
const showInviteModal = () => {
NiceModal.show(InviteUserModal);
updateRoute('users/invite');
};
const buttons = (

View File

@ -5,6 +5,7 @@ import TabView, {Tab} from '../../../admin-x-ds/global/TabView';
import ThemePreview from './designAndBranding/ThemePreview';
import ThemeSettings from './designAndBranding/ThemeSettings';
import useForm from '../../../hooks/useForm';
import useRouting from '../../../hooks/useRouting';
import {CustomThemeSetting, Post, Setting, SettingValue} from '../../../types/api';
import {PreviewModalContent} from '../../../admin-x-ds/global/modal/PreviewModal';
import {ServicesContext} from '../../providers/ServiceProvider';
@ -61,6 +62,7 @@ const DesignModal: React.FC = () => {
const [themeSettings, setThemeSettings] = useState<Array<CustomThemeSetting>>([]);
const [latestPost, setLatestPost] = useState<Post | null>(null);
const [selectedPreviewTab, setSelectedPreviewTab] = useState('homepage');
const {updateRoute} = useRouting();
useEffect(() => {
api.latestPost.browse().then((response) => {
@ -183,6 +185,9 @@ const DesignModal: React.FC = () => {
/>;
return <PreviewModalContent
afterClose={() => {
updateRoute('branding-and-design');
}}
buttonsDisabled={saveState === 'saving'}
defaultTab='homepage'
dirty={saveState === 'unsaved'}
@ -198,6 +203,7 @@ const DesignModal: React.FC = () => {
onOk={async () => {
await handleSave();
modal.remove();
updateRoute('branding-and-design');
}}
onSelectURL={onSelectURL}
/>;

View File

@ -1,12 +1,12 @@
import Button from '../../../admin-x-ds/global/Button';
import DesignModal from './DesignModal';
import NiceModal from '@ebay/nice-modal-react';
import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import useRouting from '../../../hooks/useRouting';
const DesignSetting: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {updateRoute} = useRouting();
const openPreviewModal = () => {
NiceModal.show(DesignModal);
updateRoute('branding-and-design/edit');
};
return (

View File

@ -1,12 +1,12 @@
import Button from '../../../admin-x-ds/global/Button';
import NavigationModal from './NavigationModal';
import NiceModal from '@ebay/nice-modal-react';
import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import useRouting from '../../../hooks/useRouting';
const Navigation: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {updateRoute} = useRouting();
const openPreviewModal = () => {
NiceModal.show(NavigationModal);
updateRoute('navigation/edit');
};
return (

View File

@ -3,13 +3,14 @@ import NavigationEditForm from './navigation/NavigationEditForm';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import TabView from '../../../admin-x-ds/global/TabView';
import useNavigationEditor, {NavigationItem} from '../../../hooks/site/useNavigationEditor';
import useRouting from '../../../hooks/useRouting';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {getSettingValues} from '../../../utils/helpers';
import {useState} from 'react';
const NavigationModal = NiceModal.create(() => {
const modal = useModal();
const {updateRoute} = useRouting();
const {
localSettings,
updateSetting,
@ -39,6 +40,9 @@ const NavigationModal = NiceModal.create(() => {
return (
<Modal
afterClose={() => {
updateRoute('navigation');
}}
buttonsDisabled={saveState === 'saving'}
dirty={localSettings.some(setting => setting.dirty)}
scrolling={true}
@ -50,6 +54,7 @@ const NavigationModal = NiceModal.create(() => {
if (navigation.validate() && secondaryNavigation.validate()) {
await handleSave();
modal.remove();
updateRoute('navigation');
}
}}
>

View File

@ -1,14 +1,14 @@
import Button from '../../../admin-x-ds/global/Button';
import ChangeThemeModal from './ThemeModal';
import NiceModal from '@ebay/nice-modal-react';
import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import useRouting from '../../../hooks/useRouting';
const Theme: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {updateRoute} = useRouting();
return (
<SettingGroup
customButtons={<Button color='green' label='Manage themes' link onClick={() => {
NiceModal.show(ChangeThemeModal);
updateRoute('themes/manage');
}}/>}
description="Change or upload themes"
keywords={keywords}

View File

@ -10,6 +10,7 @@ import React, {useState} from 'react';
import TabView from '../../../admin-x-ds/global/TabView';
import ThemeInstalledModal from './theme/ThemeInstalledModal';
import ThemePreview from './theme/ThemePreview';
import useRouting from '../../../hooks/useRouting';
import {API} from '../../../utils/api';
import {OfficialTheme} from '../../../models/themes';
import {Theme} from '../../../types/api';
@ -107,6 +108,7 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
themes,
setThemes
}) => {
const {updateRoute} = useRouting();
const api = useApi();
const left =
<TabView
@ -154,6 +156,7 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
color='black'
label='OK'
onClick = {() => {
updateRoute('theme');
modal.remove();
}} />
</div>;
@ -188,6 +191,7 @@ const ChangeThemeModal = NiceModal.create(() => {
const [selectedTheme, setSelectedTheme] = useState<OfficialTheme|null>(null);
const [previewMode, setPreviewMode] = useState('desktop');
const [isInstalling, setInstalling] = useState(false);
const {updateRoute} = useRouting();
const modal = useModal();
const {themes, setThemes} = useThemes();
@ -251,6 +255,9 @@ const ChangeThemeModal = NiceModal.create(() => {
return (
<Modal
afterClose={() => {
updateRoute('theme');
}}
cancelLabel=''
footer={false}
noPadding={true}

View File

@ -0,0 +1,18 @@
import {RouteContext} from '../components/providers/RoutingProvider';
import {useContext} from 'react';
export type RoutingHook = {
route: string;
updateRoute: (newPath: string) => void
};
const useRouting = (): RoutingHook => {
const {route, updateRoute} = useContext(RouteContext);
return {
route,
updateRoute
};
};
export default useRouting;

View File

@ -53,8 +53,9 @@ Router.map(function () {
this.route('collections');
this.route('collection.new', {path: '/collections/new'});
this.route('collection', {path: '/collections/:collection_slug'});
this.route('settings-x');
this.route('settings-x', function () {
this.route('settings-x', {path: '/*sub'});
});
this.route('settings');
this.route('settings.general', {path: '/settings/general'});
this.route('settings.membership', {path: '/settings/members'});