[PUI] Part detail page (#5521)

* Very basic part detail page

- Simply displays the ID of the part (not any actual data)
- Navigate from the part table

* Implement generic PanelGroup component

- Used for displaying sets of panelized data
- Will be used a lot within the interface

* Reload part page after edit form

* Fix loading overlay for part page

* Fix search panel

* Add panels to part index page

* Fix icons

* Fix table row actions menu

* PanelGroup: allow active panel to be changed externally

* Fix SearchDrawer issue

- AbortController does not work as expected
- Might need to revisit this later

* Improve form loading indicator
This commit is contained in:
Oliver 2023-09-10 22:52:46 +10:00 committed by GitHub
parent 9a6c2d2953
commit a210e905dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 393 additions and 57 deletions

View File

@ -151,6 +151,7 @@ export function ApiForm({
// Fetch initial data if the fetchInitialData property is set
if (props.fetchInitialData) {
initialDataQuery.remove();
initialDataQuery.refetch();
}
}, []);
@ -162,7 +163,7 @@ export function ApiForm({
queryFn: async () => {
let method = props.method?.toLowerCase() ?? 'get';
api({
return api({
method: method,
url: url,
data: form.values,
@ -199,6 +200,8 @@ export function ApiForm({
closeForm();
break;
}
return response;
})
.catch((error) => {
if (error.response) {

View File

@ -0,0 +1,87 @@
import { Tabs } from '@mantine/core';
import { ReactNode } from 'react';
import { useEffect, useState } from 'react';
/**
* Type used to specify a single panel in a panel group
*/
export type PanelType = {
name: string;
label: string;
icon?: ReactNode;
content: ReactNode;
hidden?: boolean;
disabled?: boolean;
};
/**
*
* @param panels : PanelDefinition[] - The list of panels to display
* @param activePanel : string - The name of the currently active panel (defaults to the first panel)
* @param setActivePanel : (panel: string) => void - Function to set the active panel
* @param onPanelChange : (panel: string) => void - Callback when the active panel changes
* @returns
*/
export function PanelGroup({
panels,
selectedPanel,
onPanelChange
}: {
panels: PanelType[];
selectedPanel?: string;
onPanelChange?: (panel: string) => void;
}): ReactNode {
// Default to the provided panel name, or the first panel
const [activePanelName, setActivePanelName] = useState<string>(
selectedPanel || panels.length > 0 ? panels[0].name : ''
);
// Update the active panel when the selected panel changes
useEffect(() => {
if (selectedPanel) {
setActivePanelName(selectedPanel);
}
}, [selectedPanel]);
// Callback when the active panel changes
function handlePanelChange(panel: string) {
setActivePanelName(panel);
// Optionally call external callback hook
if (onPanelChange) {
onPanelChange(panel);
}
}
return (
<Tabs
value={activePanelName}
orientation="vertical"
onTabChange={handlePanelChange}
keepMounted={false}
>
<Tabs.List>
{panels.map(
(panel, idx) =>
!panel.hidden && (
<Tabs.Tab
value={panel.name}
icon={panel.icon}
hidden={panel.hidden}
>
{panel.label}
</Tabs.Tab>
)
)}
</Tabs.List>
{panels.map(
(panel, idx) =>
!panel.hidden && (
<Tabs.Panel key={idx} value={panel.name}>
{panel.content}
</Tabs.Panel>
)
)}
</Tabs>
);
}

View File

@ -25,7 +25,8 @@ import {
IconX
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../App';
import { RenderInstance } from '../render/Instance';
@ -172,10 +173,12 @@ function buildSearchQueries(): SearchQuery[] {
*/
function QueryResultGroup({
query,
onRemove
onRemove,
onResultClick
}: {
query: SearchQuery;
onRemove: (query: string) => void;
onResultClick: (query: string, pk: number) => void;
}) {
if (query.results.count == 0) {
return null;
@ -206,7 +209,13 @@ function QueryResultGroup({
<Divider />
<Stack>
{query.results.results.map((result: any) => (
<RenderInstance instance={result} model={query.name} />
<div onClick={() => onResultClick(query.name, result.pk)}>
<RenderInstance
key={`${query.name}-${result.pk}`}
instance={result}
model={query.name}
/>
</div>
))}
</Stack>
<Space />
@ -263,14 +272,8 @@ export function SearchDrawer({
params[query.name] = query.parameters;
});
// Cancel any pending search queries
getAbortController().abort();
return api
.post(`/search/`, {
params: params,
signal: getAbortController().signal
})
.post(`/search/`, params)
.then(function (response) {
return response.data;
})
@ -315,26 +318,25 @@ export function SearchDrawer({
}
}, [searchQuery.data]);
// Controller to cancel previous search queries
const abortControllerRef = useRef<AbortController | null>(null);
const getAbortController = useCallback(() => {
if (!abortControllerRef.current) {
abortControllerRef.current = new AbortController();
}
return abortControllerRef.current;
}, []);
// Callback to remove a set of results from the list
function removeResults(query: string) {
setQueryResults(queryResults.filter((q) => q.name != query));
}
// Callback when the drawer is closed
function closeDrawer() {
setValue('');
onClose();
}
const navigate = useNavigate();
// Callback when one of the search results is clicked
function onResultClick(query: string, pk: number) {
closeDrawer();
navigate(`/${query}/${pk}/`);
}
return (
<Drawer
opened={opened}
@ -410,6 +412,7 @@ export function SearchDrawer({
<QueryResultGroup
query={query}
onRemove={(query) => removeResults(query)}
onResultClick={(query, pk) => onResultClick(query, pk)}
/>
))}
</Stack>

View File

@ -25,7 +25,7 @@ export function RowActions({
}): ReactNode {
return (
actions.length > 0 && (
<Menu>
<Menu withinPortal={true}>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots />

View File

@ -2,6 +2,7 @@ import { t } from '@lingui/macro';
import { Text } from '@mantine/core';
import { IconEdit, IconTrash } from '@tabler/icons-react';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { editPart } from '../../../functions/forms/PartForms';
import { notYetImplemented } from '../../../functions/notifications';
@ -190,13 +191,11 @@ function partTableParams(params: any): any {
* @returns
*/
export function PartListTable({ params = {} }: { params?: any }) {
let tableParams = useMemo(() => partTableParams(params), []);
let tableParams = useMemo(() => partTableParams(params), [params]);
let tableColumns = useMemo(() => partTableColumns(), []);
let tableFilters = useMemo(() => partTableFilters(), []);
// Add required query parameters
tableParams.category_detail = true;
// Callback function for generating set of row actions
function partTableRowActions(record: any): RowAction[] {
let actions: RowAction[] = [];
@ -213,16 +212,18 @@ export function PartListTable({ params = {} }: { params?: any }) {
}
});
if (record.IPN) {
actions.push({
title: t`View IPN`,
onClick: () => {}
});
}
actions.push({
title: t`Detail`,
onClick: () => {
navigate(`/part/${record.pk}/`);
}
});
return actions;
}
const navigate = useNavigate();
return (
<InvenTreeTable
url="part/"

View File

@ -23,7 +23,7 @@ export const footerLinks = [
export const navTabs = [
{ text: <Trans>Home</Trans>, name: 'home' },
{ text: <Trans>Dashboard</Trans>, name: 'dashboard' },
{ text: <Trans>Parts</Trans>, name: 'parts' },
{ text: <Trans>Parts</Trans>, name: 'part' },
{ text: <Trans>Stock</Trans>, name: 'stock' },
{ text: <Trans>Build</Trans>, name: 'build' }
];

View File

@ -97,7 +97,8 @@ export function editPart({
url: '/part/',
pk: part_id,
successMessage: t`Part updated`,
fields: partFields({ editing: true })
fields: partFields({ editing: true }),
onFormSuccess: callback
});
}

View File

@ -1,20 +0,0 @@
import { Trans } from '@lingui/macro';
import { Group } from '@mantine/core';
import { PlaceholderPill } from '../../components/items/Placeholder';
import { StylishText } from '../../components/items/StylishText';
import { PartListTable } from '../../components/tables/part/PartTable';
export default function Part() {
return (
<>
<Group>
<StylishText>
<Trans>Parts</Trans>
</StylishText>
<PlaceholderPill />
</Group>
<PartListTable />
</>
);
}

View File

@ -0,0 +1,200 @@
import { t } from '@lingui/macro';
import {
Button,
Group,
LoadingOverlay,
Skeleton,
Space,
Stack,
Tabs,
Text
} from '@mantine/core';
import {
IconBox,
IconBuilding,
IconCurrencyDollar,
IconInfoCircle,
IconLayersLinked,
IconList,
IconListTree,
IconNotes,
IconPackages,
IconPaperclip,
IconShoppingCart,
IconTestPipe,
IconTools,
IconTruckDelivery,
IconVersions
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { api } from '../../App';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { StockItemTable } from '../../components/tables/stock/StockItemTable';
import { editPart } from '../../functions/forms/PartForms';
export default function PartDetail() {
const { id } = useParams();
// Part data
const [part, setPart] = useState<any>({});
// Part data panels (recalculate when part data changes)
const partPanels: PanelType[] = useMemo(() => {
return [
{
name: 'details',
label: t`Details`,
icon: <IconInfoCircle size="18" />,
content: <Text>part details go here</Text>
},
{
name: 'stock',
label: t`Stock`,
icon: <IconPackages size="18" />,
content: partStockTab()
},
{
name: 'variants',
label: t`Variants`,
icon: <IconVersions size="18" />,
hidden: !part.is_template,
content: <Text>part variants go here</Text>
},
{
name: 'bom',
label: t`Bill of Materials`,
icon: <IconListTree size="18" />,
hidden: !part.assembly,
content: part.assembly && <Text>part BOM goes here</Text>
},
{
name: 'builds',
label: t`Build Orders`,
icon: <IconTools size="18" />,
hidden: !part.assembly && !part.component,
content: <Text>part builds go here</Text>
},
{
name: 'used_in',
label: t`Used In`,
icon: <IconList size="18" />,
hidden: !part.component,
content: <Text>part used in goes here</Text>
},
{
name: 'pricing',
label: t`Pricing`,
icon: <IconCurrencyDollar size="18" />,
content: <Text>part pricing goes here</Text>
},
{
name: 'suppliers',
label: t`Suppliers`,
icon: <IconBuilding size="18" />,
content: <Text>part suppliers go here</Text>,
hidden: !part.purchaseable
},
{
name: 'purchase_orders',
label: t`Purchase Orders`,
icon: <IconShoppingCart size="18" />,
content: <Text>part purchase orders go here</Text>,
hidden: !part.purchaseable
},
{
name: 'sales_orders',
label: t`Sales Orders`,
icon: <IconTruckDelivery size="18" />,
content: <Text>part sales orders go here</Text>,
hidden: !part.salable
},
{
name: 'test_templates',
label: t`Test Templates`,
icon: <IconTestPipe size="18" />,
content: <Text>part test templates go here</Text>,
hidden: !part.trackable
},
{
name: 'related_parts',
label: t`Related Parts`,
icon: <IconLayersLinked size="18" />,
content: <Text>part related parts go here</Text>
},
{
name: 'attachments',
label: t`Attachments`,
icon: <IconPaperclip size="18" />,
content: <Text>part attachments go here</Text>
},
{
name: 'notes',
label: t`Notes`,
icon: <IconNotes size="18" />,
content: <Text>part notes go here</Text>
}
];
}, [part]);
// Query hook for fetching part data
const partQuery = useQuery(['part', id], async () => {
let url = `/part/${id}/`;
return api
.get(url)
.then((response) => {
setPart(response.data);
return response.data;
})
.catch((error) => {
setPart({});
return null;
});
});
function partStockTab(): React.ReactNode {
return (
<StockItemTable
params={{
part: part.pk ?? -1
}}
/>
);
}
return (
<>
<Stack spacing="xs">
<LoadingOverlay visible={partQuery.isFetching} />
<Group position="apart">
<Group position="left">
<Text size="lg">Part Detail</Text>
<Text>{part.name}</Text>
<Text size="sm">{part.description}</Text>
</Group>
<Space />
<Text>In Stock: {part.total_in_stock}</Text>
<Button
variant="outline"
color="blue"
onClick={() =>
part.pk &&
editPart({
part_id: part.pk,
callback: () => {
partQuery.refetch();
}
})
}
>
Edit
</Button>
</Group>
<PanelGroup panels={partPanels} />
</Stack>
</>
);
}

View File

@ -0,0 +1,52 @@
import { Trans, t } from '@lingui/macro';
import { Stack } from '@mantine/core';
import {
IconCategory,
IconListDetails,
IconSitemap
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { PlaceholderPill } from '../../components/items/Placeholder';
import { StylishText } from '../../components/items/StylishText';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { PartListTable } from '../../components/tables/part/PartTable';
/**
* Part index page
*/
export default function PartIndex() {
const panels: PanelType[] = useMemo(() => {
return [
{
name: 'parts',
label: t`Parts`,
icon: <IconCategory size="18" />,
content: <PartListTable />
},
{
name: 'categories',
label: t`Categories`,
icon: <IconSitemap size="18" />,
content: <PlaceholderPill />
},
{
name: 'parameters',
label: t`Parameters`,
icon: <IconListDetails size="18" />,
content: <PlaceholderPill />
}
];
}, []);
return (
<>
<Stack spacing="xs">
<StylishText>
<Trans>Parts</Trans>
</StylishText>
<PanelGroup panels={panels} />
</Stack>
</>
);
}

View File

@ -11,7 +11,7 @@ export const Home = Loadable(lazy(() => import('./pages/Index/Home')));
export const Playground = Loadable(
lazy(() => import('./pages/Index/Playground'))
);
export const Parts = Loadable(lazy(() => import('./pages/Index/Part')));
export const PartIndex = Loadable(lazy(() => import('./pages/part/PartIndex')));
export const Stock = Loadable(lazy(() => import('./pages/Index/Stock')));
export const Build = Loadable(lazy(() => import('./pages/Index/Build')));
@ -22,6 +22,11 @@ export const ErrorPage = Loadable(lazy(() => import('./pages/ErrorPage')));
export const Profile = Loadable(
lazy(() => import('./pages/Index/Profile/Profile'))
);
export const PartDetail = Loadable(
lazy(() => import('./pages/part/PartDetail'))
);
export const NotFound = Loadable(lazy(() => import('./pages/NotFound')));
export const Login = Loadable(lazy(() => import('./pages/Auth/Login')));
export const Logged_In = Loadable(lazy(() => import('./pages/Auth/Logged-In')));
@ -60,8 +65,12 @@ export const router = createBrowserRouter(
element: <Playground />
},
{
path: 'parts/',
element: <Parts />
path: 'part/',
element: <PartIndex />
},
{
path: 'part/:id',
element: <PartDetail />
},
{
path: 'stock/',