InvenTree/src/frontend/src/components/nav/PanelGroup.tsx

248 lines
7.4 KiB
TypeScript

import {
ActionIcon,
Divider,
Paper,
Stack,
Tabs,
Tooltip
} from '@mantine/core';
import {
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarRightCollapse
} from '@tabler/icons-react';
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import {
Navigate,
Route,
Routes,
useLocation,
useNavigate,
useParams
} from 'react-router-dom';
import { ModelType } from '../../enums/ModelType';
import { identifierString } from '../../functions/conversion';
import { cancelEvent } from '../../functions/events';
import { navigateToLink } from '../../functions/navigation';
import { usePluginPanels } from '../../hooks/UsePluginPanels';
import { useLocalState } from '../../states/LocalState';
import { Boundary } from '../Boundary';
import { StylishText } from '../items/StylishText';
import { PanelType } from './Panel';
/**
* Set of properties which define a panel group:
*
* @param pageKey - Unique key for this panel group
* @param panels - List of panels to display
* @param model - The target model for this panel group (e.g. 'part' / 'salesorder')
* @param id - The target ID for this panel group (set to *null* for groups which do not target a specific model instance)
* @param instance - The target model instance for this panel group
* @param selectedPanel - The currently selected panel
* @param onPanelChange - Callback when the active panel changes
* @param collapsible - If true, the panel group can be collapsed (defaults to true)
*/
export type PanelProps = {
pageKey: string;
panels: PanelType[];
instance?: any;
model?: ModelType | string;
id?: number | null;
selectedPanel?: string;
onPanelChange?: (panel: string) => void;
collapsible?: boolean;
};
function BasePanelGroup({
pageKey,
panels,
onPanelChange,
selectedPanel,
instance,
model,
id,
collapsible = true
}: Readonly<PanelProps>): ReactNode {
const localState = useLocalState();
const location = useLocation();
const navigate = useNavigate();
const { panel } = useParams();
const [expanded, setExpanded] = useState<boolean>(true);
// Hook to load plugins for this panel
const pluginPanels = usePluginPanels({
model: model,
instance: instance,
id: id
});
const allPanels = useMemo(
() => [...panels, ...pluginPanels],
[panels, pluginPanels]
);
const activePanels = useMemo(
() => allPanels.filter((panel) => !panel.hidden && !panel.disabled),
[allPanels]
);
// Callback when the active panel changes
const handlePanelChange = useCallback(
(panel: string, event?: any) => {
if (event && (event?.ctrlKey || event?.shiftKey)) {
const url = `${location.pathname}/../${panel}`;
cancelEvent(event);
navigateToLink(url, navigate, event);
} else {
navigate(`../${panel}`);
}
localState.setLastUsedPanel(pageKey)(panel);
// Optionally call external callback hook
if (panel && onPanelChange) {
onPanelChange(panel);
}
},
[activePanels, navigate, location, onPanelChange]
);
// if the selected panel state changes update the current panel
useEffect(() => {
if (selectedPanel && selectedPanel !== panel) {
handlePanelChange(selectedPanel);
}
}, [selectedPanel, panel]);
// Determine the current panels selection (must be a valid panel)
const currentPanel: string = useMemo(() => {
if (activePanels.findIndex((p) => p.name === panel) === -1) {
return activePanels[0]?.name ?? '';
} else {
return panel ?? '';
}
}, [activePanels, panel]);
return (
<Boundary label={`PanelGroup-${pageKey}`}>
<Paper p="sm" radius="xs" shadow="xs">
<Tabs value={currentPanel} orientation="vertical" keepMounted={false}>
<Tabs.List justify="left">
{allPanels.map(
(panel) =>
!panel.hidden && (
<Tooltip
label={`tooltip-${panel.name}`}
key={panel.name}
disabled={expanded}
position="right"
>
<Tabs.Tab
p="xs"
key={`panel-label-${panel.name}`}
value={panel.name}
leftSection={panel.icon}
hidden={panel.hidden}
disabled={panel.disabled}
style={{ cursor: panel.disabled ? 'unset' : 'pointer' }}
onClick={(event: any) =>
handlePanelChange(panel.name, event)
}
>
{expanded && panel.label}
</Tabs.Tab>
</Tooltip>
)
)}
{collapsible && (
<ActionIcon
style={{
paddingLeft: '10px'
}}
onClick={() => setExpanded(!expanded)}
variant="transparent"
size="md"
>
{expanded ? (
<IconLayoutSidebarLeftCollapse opacity={0.5} />
) : (
<IconLayoutSidebarRightCollapse opacity={0.5} />
)}
</ActionIcon>
)}
</Tabs.List>
{allPanels.map(
(panel) =>
!panel.hidden && (
<Tabs.Panel
key={`panel-${panel.name}`}
value={panel.name}
aria-label={`nav-panel-${identifierString(
`${pageKey}-${panel.name}`
)}`}
p="sm"
style={{
overflowX: 'scroll',
width: '100%'
}}
>
<Stack gap="md">
{panel.showHeadline !== false && (
<>
<StylishText size="xl">{panel.label}</StylishText>
<Divider />
</>
)}
<Boundary label={`PanelContent-${panel.name}`}>
{panel.content}
</Boundary>
</Stack>
</Tabs.Panel>
)
)}
</Tabs>
</Paper>
</Boundary>
);
}
function IndexPanelComponent({
pageKey,
selectedPanel,
panels
}: Readonly<PanelProps>) {
const lastUsedPanel = useLocalState((state) => {
const panelName =
selectedPanel || state.lastUsedPanels[pageKey] || panels[0]?.name;
const panel = panels.findIndex(
(p) => p.name === panelName && !p.disabled && !p.hidden
);
if (panel === -1) {
return panels.find((p) => !p.disabled && !p.hidden)?.name || '';
}
return panelName;
});
return <Navigate to={lastUsedPanel} replace />;
}
/**
* Render a panel group. The current panel will be appended to the current url.
* The last opened panel will be stored in local storage and opened if no panel is provided via url param
* @param panels - The list of panels to display
* @param onPanelChange - Callback when the active panel changes
* @param collapsible - If true, the panel group can be collapsed (defaults to true)
*/
export function PanelGroup(props: Readonly<PanelProps>) {
return (
<Routes>
<Route index element={<IndexPanelComponent {...props} />} />
<Route path="/:panel/*" element={<BasePanelGroup {...props} />} />
</Routes>
);
}