Initial import
This commit is contained in:
469
src/app/main/layout/MainLayout.tsx
Executable file
469
src/app/main/layout/MainLayout.tsx
Executable file
@@ -0,0 +1,469 @@
|
||||
import { Layout } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useGestureLayer, useGesturePageState, useSearchLayer } from '../../../layer';
|
||||
import { useAppStore } from '../../../store';
|
||||
import { useTokenAccess } from '../tokenAccess';
|
||||
import { useAppConfig } from '../appConfig';
|
||||
import { ChatNotificationBridgeV2 } from '../ChatNotificationBridgeV2';
|
||||
import { ChatRuntimeBridgeV2 } from '../ChatRuntimeBridgeV2';
|
||||
import { useUnreadCounts } from '../chatV2/hooks/useUnreadCounts';
|
||||
import { matchesShortcut, isTypingTarget, scrollToElement } from '../mainView/utils';
|
||||
import { MainContent } from '../MainContent';
|
||||
import { MainHeader } from '../MainHeader';
|
||||
import { MainSidebar } from '../MainSidebar';
|
||||
import { buildSearchOptions } from './buildSearchOptions';
|
||||
import { MainLayoutContextProvider } from './MainLayoutContext';
|
||||
import { useMainLayoutData } from './useMainLayoutData';
|
||||
import '../MainLayout.css';
|
||||
import {
|
||||
buildApiMenuItems,
|
||||
buildApisPath,
|
||||
buildChatMenuItems,
|
||||
buildChatPath,
|
||||
buildDocsMenuItems,
|
||||
buildDocsPath,
|
||||
buildPlanMenuItems,
|
||||
buildPlansPath,
|
||||
buildPlayMenuItems,
|
||||
buildPlayPath,
|
||||
buildSavedLayoutPath,
|
||||
DOCS_DEFAULT_FOLDER,
|
||||
PLAN_MENU_ANCHOR_IDS,
|
||||
renderSidebarIntro,
|
||||
resolveCurrentPageDescriptor,
|
||||
resolvePlanOpenKeys,
|
||||
resolvePlanQuickFilterMenu,
|
||||
resolvePlayOpenKeys,
|
||||
resolveSavedLayoutIdFromMenuKey,
|
||||
resolveTopMenuPath,
|
||||
type ApiSectionKey,
|
||||
type ChatSectionKey,
|
||||
type PlanSectionKey,
|
||||
type PlaySidebarKey,
|
||||
type TopMenuKey,
|
||||
} from '../routes';
|
||||
|
||||
function parseRoute(pathname: string): {
|
||||
topMenu: TopMenuKey;
|
||||
docsMenu: string;
|
||||
apiMenu: ApiSectionKey;
|
||||
planMenu: PlanSectionKey;
|
||||
chatMenu: ChatSectionKey;
|
||||
playMenu: PlaySidebarKey;
|
||||
} {
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const [top, first, second] = segments;
|
||||
|
||||
if (top === 'docs') {
|
||||
return {
|
||||
topMenu: 'docs',
|
||||
docsMenu: first || DOCS_DEFAULT_FOLDER,
|
||||
apiMenu: 'components',
|
||||
planMenu: 'all',
|
||||
chatMenu: 'live',
|
||||
playMenu: 'layout',
|
||||
};
|
||||
}
|
||||
|
||||
if (top === 'apis' && (first === 'components' || first === 'widgets')) {
|
||||
return {
|
||||
topMenu: 'apis',
|
||||
docsMenu: DOCS_DEFAULT_FOLDER,
|
||||
apiMenu: first,
|
||||
planMenu: 'all',
|
||||
chatMenu: 'live',
|
||||
playMenu: 'layout',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
top === 'plans' &&
|
||||
(first === 'all' ||
|
||||
first === 'in-progress' ||
|
||||
first === 'done' ||
|
||||
first === 'error' ||
|
||||
first === 'release' ||
|
||||
first === 'release-review' ||
|
||||
first === 'board' ||
|
||||
first === 'charts' ||
|
||||
first === 'schedule' ||
|
||||
first === 'history' ||
|
||||
first === 'server-command')
|
||||
) {
|
||||
return {
|
||||
topMenu: 'plans',
|
||||
docsMenu: DOCS_DEFAULT_FOLDER,
|
||||
apiMenu: 'components',
|
||||
planMenu: first,
|
||||
chatMenu: 'live',
|
||||
playMenu: 'layout',
|
||||
};
|
||||
}
|
||||
|
||||
if (top === 'chat' && (first === 'live' || first === 'changes' || first === 'errors' || first === 'manage')) {
|
||||
return {
|
||||
topMenu: 'chat',
|
||||
docsMenu: DOCS_DEFAULT_FOLDER,
|
||||
apiMenu: 'components',
|
||||
planMenu: 'all',
|
||||
chatMenu: first,
|
||||
playMenu: 'layout',
|
||||
};
|
||||
}
|
||||
|
||||
if (top === 'play' && first === 'layout') {
|
||||
return {
|
||||
topMenu: 'play',
|
||||
docsMenu: DOCS_DEFAULT_FOLDER,
|
||||
apiMenu: 'components',
|
||||
planMenu: 'all',
|
||||
chatMenu: 'live',
|
||||
playMenu: first,
|
||||
};
|
||||
}
|
||||
|
||||
if (top === 'play' && first === 'layout-record' && second) {
|
||||
return {
|
||||
topMenu: 'play',
|
||||
docsMenu: DOCS_DEFAULT_FOLDER,
|
||||
apiMenu: 'components',
|
||||
planMenu: 'all',
|
||||
chatMenu: 'live',
|
||||
playMenu: `layout-record:${second}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
topMenu: 'plans',
|
||||
docsMenu: DOCS_DEFAULT_FOLDER,
|
||||
apiMenu: 'components',
|
||||
planMenu: 'all',
|
||||
chatMenu: 'live',
|
||||
playMenu: 'layout',
|
||||
};
|
||||
}
|
||||
|
||||
function isRestrictedTopMenu(topMenu: TopMenuKey, hasAccess: boolean) {
|
||||
return !hasAccess && topMenu !== 'docs';
|
||||
}
|
||||
|
||||
function resolveSidebarOpenKeys(topMenu: TopMenuKey, hasAccess: boolean) {
|
||||
if (!hasAccess) {
|
||||
return ['docs-group'];
|
||||
}
|
||||
|
||||
if (topMenu === 'docs' || topMenu === 'apis') {
|
||||
return ['docs-group', 'api-group'];
|
||||
}
|
||||
|
||||
return topMenu === 'play' ? resolvePlayOpenKeys() : resolvePlanOpenKeys();
|
||||
}
|
||||
|
||||
export function MainLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { setCurrentPage, setFocusedComponentId } = useAppStore();
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const appConfig = useAppConfig();
|
||||
const { openSearch, setOptions: setSearchOptions } = useSearchLayer();
|
||||
const layoutData = useMainLayoutData();
|
||||
const routeState = useMemo(() => parseRoute(location.pathname), [location.pathname]);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [contentExpanded, setContentExpanded] = useState(false);
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
||||
const [sidebarOpenKeys, setSidebarOpenKeys] = useState<string[]>(resolveSidebarOpenKeys(routeState.topMenu, hasAccess));
|
||||
const [activePlanQuickFilter, setActivePlanQuickFilter] = useState<
|
||||
'working' | 'release-pending-main' | 'automation-failed' | null
|
||||
>(routeState.planMenu === 'release' ? 'release-pending-main' : null);
|
||||
const [planQuickFilterRequestKey, setPlanQuickFilterRequestKey] = useState(routeState.planMenu === 'release' ? 1 : 0);
|
||||
const { componentSampleEntries, widgetSampleEntries, componentSamples, widgetSamples, docsDocuments, savedLayouts, setSavedLayouts, docFolders } = layoutData;
|
||||
const { chatUnreadCount } = useUnreadCounts();
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(max-width: 768px)');
|
||||
const updateViewport = () => {
|
||||
setIsMobileViewport(mediaQuery.matches);
|
||||
};
|
||||
|
||||
updateViewport();
|
||||
mediaQuery.addEventListener('change', updateViewport);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', updateViewport);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobileViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}, [isMobileViewport]);
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarOpenKeys(resolveSidebarOpenKeys(routeState.topMenu, hasAccess));
|
||||
}, [hasAccess, routeState.topMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
if (docFolders.length > 0 && routeState.topMenu === 'docs' && !docFolders.includes(routeState.docsMenu)) {
|
||||
navigate(buildDocsPath(docFolders[0]), { replace: true });
|
||||
}
|
||||
}, [docFolders, navigate, routeState.docsMenu, routeState.topMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(routeState.playMenu);
|
||||
|
||||
if (savedLayoutId && !savedLayouts.some((record) => record.id === savedLayoutId)) {
|
||||
navigate(buildPlayPath('layout'), { replace: true });
|
||||
}
|
||||
}, [navigate, routeState.playMenu, savedLayouts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRestrictedTopMenu(routeState.topMenu, hasAccess)) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(`${buildDocsPath(DOCS_DEFAULT_FOLDER)}${location.search}`, { replace: true });
|
||||
}, [hasAccess, location.search, navigate, routeState.topMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
if (routeState.topMenu !== 'plans') {
|
||||
setActivePlanQuickFilter(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (routeState.planMenu === 'release') {
|
||||
setActivePlanQuickFilter('release-pending-main');
|
||||
return;
|
||||
}
|
||||
|
||||
setActivePlanQuickFilter((current) => (current === 'working' || current === 'automation-failed' ? current : null));
|
||||
}, [routeState.planMenu, routeState.topMenu]);
|
||||
|
||||
useGesturePageState('anyway');
|
||||
useGestureLayer({
|
||||
id: 'main-layout',
|
||||
enabled: !(isMobileViewport && routeState.topMenu === 'docs' && routeState.docsMenu === 'worklogs'),
|
||||
gestures: [
|
||||
{
|
||||
id: 'mobile-top-right-pull-alert',
|
||||
activeStates: ['anyway'],
|
||||
mobileOnly: true,
|
||||
trigger: 'pull-down-top-right',
|
||||
onTrigger: () => {
|
||||
openSearch();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'mobile-middle-right-search-window',
|
||||
activeStates: ['anyway'],
|
||||
mobileOnly: true,
|
||||
trigger: 'pull-left-middle-right',
|
||||
onTrigger: () => {
|
||||
openSearch('window');
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.repeat || isTypingTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesShortcut(event, appConfig.gestureShortcuts.openWindowSearch)) {
|
||||
event.preventDefault();
|
||||
openSearch('window');
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesShortcut(event, appConfig.gestureShortcuts.openSearch)) {
|
||||
event.preventDefault();
|
||||
openSearch();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleWindowKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleWindowKeyDown);
|
||||
};
|
||||
}, [appConfig.gestureShortcuts.openSearch, appConfig.gestureShortcuts.openWindowSearch, openSearch]);
|
||||
|
||||
const selectedDocs = useMemo(
|
||||
() => docsDocuments.filter((document) => document.folder === routeState.docsMenu),
|
||||
[docsDocuments, routeState.docsMenu],
|
||||
);
|
||||
|
||||
const searchOptions = useMemo(
|
||||
() =>
|
||||
buildSearchOptions({
|
||||
componentSamples,
|
||||
widgetSamples,
|
||||
docFolders,
|
||||
docsDocuments,
|
||||
hasAccess,
|
||||
navigateTo: (path) => {
|
||||
navigate(path);
|
||||
},
|
||||
setFocusedComponentId,
|
||||
requestPlanQuickFilter: (filter) => {
|
||||
setActivePlanQuickFilter(filter);
|
||||
setPlanQuickFilterRequestKey((previous) => previous + 1);
|
||||
},
|
||||
}),
|
||||
[componentSamples, docFolders, docsDocuments, hasAccess, navigate, setFocusedComponentId, widgetSamples],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchOptions(searchOptions);
|
||||
}, [searchOptions, setSearchOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(
|
||||
resolveCurrentPageDescriptor({
|
||||
topMenu: routeState.topMenu,
|
||||
docsMenu: routeState.docsMenu,
|
||||
apiMenu: routeState.apiMenu,
|
||||
planMenu: routeState.planMenu,
|
||||
chatMenu: routeState.chatMenu,
|
||||
playMenu: routeState.playMenu,
|
||||
savedLayouts,
|
||||
}),
|
||||
);
|
||||
}, [routeState, savedLayouts, setCurrentPage]);
|
||||
|
||||
const currentDocsFolder = docFolders.includes(routeState.docsMenu) ? routeState.docsMenu : docFolders[0] ?? DOCS_DEFAULT_FOLDER;
|
||||
const sidebarIntro = renderSidebarIntro(routeState.topMenu);
|
||||
const apiMenuItems = useMemo(() => buildApiMenuItems(), []);
|
||||
const docsMenuItems = useMemo(() => buildDocsMenuItems(docFolders), [docFolders]);
|
||||
const planMenuItems = useMemo(() => buildPlanMenuItems(hasAccess), [hasAccess]);
|
||||
const chatMenuItems = useMemo(() => buildChatMenuItems(hasAccess, chatUnreadCount), [chatUnreadCount, hasAccess]);
|
||||
const playMenuItems = useMemo(() => buildPlayMenuItems(savedLayouts), [savedLayouts]);
|
||||
const initialSelectedPlanId = Number(searchParams.get('planId'));
|
||||
const initialSelectedWorkId = searchParams.get('workId');
|
||||
|
||||
return (
|
||||
<MainLayoutContextProvider
|
||||
value={{
|
||||
topMenu: routeState.topMenu,
|
||||
selectedDocsMenu: routeState.docsMenu,
|
||||
selectedApiMenu: routeState.apiMenu,
|
||||
selectedPlanMenu: routeState.planMenu,
|
||||
selectedChatMenu: routeState.chatMenu,
|
||||
selectedPlayMenu: routeState.playMenu,
|
||||
activePlanQuickFilter,
|
||||
planQuickFilterRequestKey,
|
||||
initialSelectedPlanId: Number.isFinite(initialSelectedPlanId) ? initialSelectedPlanId : null,
|
||||
initialSelectedWorkId,
|
||||
selectedDocs,
|
||||
docsDocuments,
|
||||
componentSampleEntries,
|
||||
widgetSampleEntries,
|
||||
componentSamples,
|
||||
widgetSamples,
|
||||
savedLayouts,
|
||||
setSavedLayouts,
|
||||
searchOptions,
|
||||
}}
|
||||
>
|
||||
<Layout className="app-shell app-shell--docs-api">
|
||||
<ChatRuntimeBridgeV2 />
|
||||
<ChatNotificationBridgeV2 />
|
||||
{contentExpanded ? null : (
|
||||
<MainHeader
|
||||
activeTopMenu={routeState.topMenu}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
contentExpanded={contentExpanded}
|
||||
isMobileViewport={isMobileViewport}
|
||||
onToggleSidebar={() => {
|
||||
setSidebarCollapsed((previous) => !previous);
|
||||
}}
|
||||
onToggleContentExpanded={() => {
|
||||
setContentExpanded((previous) => !previous);
|
||||
}}
|
||||
onChangeTopMenu={(menu) => {
|
||||
navigate(resolveTopMenuPath(menu, currentDocsFolder));
|
||||
setSidebarCollapsed(false);
|
||||
}}
|
||||
onOpenPlanQuickFilter={(filter) => {
|
||||
const targetPlanMenu = resolvePlanQuickFilterMenu(filter);
|
||||
setActivePlanQuickFilter(filter);
|
||||
setPlanQuickFilterRequestKey((previous) => previous + 1);
|
||||
navigate(buildPlansPath(targetPlanMenu));
|
||||
setSidebarCollapsed(isMobileViewport);
|
||||
scrollToElement(PLAN_MENU_ANCHOR_IDS[targetPlanMenu] ?? 'plan-menu-all');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Layout>
|
||||
{contentExpanded || (isMobileViewport && sidebarCollapsed) ? null : (
|
||||
<MainSidebar
|
||||
activeTopMenu={routeState.topMenu}
|
||||
hasAccess={hasAccess}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
isMobileViewport={isMobileViewport}
|
||||
openKeys={sidebarOpenKeys}
|
||||
apiMenuItems={apiMenuItems}
|
||||
docsMenuItems={docsMenuItems}
|
||||
planMenuItems={planMenuItems}
|
||||
chatMenuItems={chatMenuItems}
|
||||
playMenuItems={playMenuItems}
|
||||
selectedApiMenu={routeState.apiMenu}
|
||||
selectedDocsMenu={routeState.docsMenu}
|
||||
selectedPlanMenu={routeState.planMenu}
|
||||
selectedChatMenu={routeState.chatMenu}
|
||||
selectedPlayMenu={routeState.playMenu}
|
||||
onOpenKeysChange={setSidebarOpenKeys}
|
||||
onSelectApiMenu={(key) => {
|
||||
navigate(buildApisPath(key as ApiSectionKey));
|
||||
if (isMobileViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
onSelectDocsMenu={(key) => {
|
||||
navigate(buildDocsPath(key));
|
||||
if (isMobileViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
onSelectPlanMenu={(key) => {
|
||||
setActivePlanQuickFilter(key === 'release' ? 'release-pending-main' : null);
|
||||
setPlanQuickFilterRequestKey((previous) => previous + 1);
|
||||
navigate(buildPlansPath(key));
|
||||
if (isMobileViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
onSelectChatMenu={(key) => {
|
||||
navigate(buildChatPath(key));
|
||||
if (isMobileViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
onSelectPlayMenu={(key) => {
|
||||
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(key);
|
||||
navigate(savedLayoutId ? buildSavedLayoutPath(savedLayoutId) : buildPlayPath(key as 'layout'));
|
||||
if (isMobileViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
introColor={sidebarIntro.color}
|
||||
introTag={sidebarIntro.tag}
|
||||
introDescription={sidebarIntro.description}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMobileViewport && !sidebarCollapsed ? null : (
|
||||
<MainContent contentExpanded={contentExpanded} onToggleContentExpanded={() => setContentExpanded((previous) => !previous)}>
|
||||
<Outlet />
|
||||
</MainContent>
|
||||
)}
|
||||
</Layout>
|
||||
</Layout>
|
||||
</MainLayoutContextProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user