[PUI] Error pages (#7554)

* Add <PermissionDenied /> page

* Check permissions for admin center

* Wrap <PartDetail> page in an error handler

- Display client or server errors

* Add error handlers to other detail pages

* Refactor error pages

* Add playwright tests

* Refactor component locations

* Get test to work
This commit is contained in:
Oliver 2024-07-04 00:34:52 +10:00 committed by GitHub
parent 0c293fa896
commit b15eb35273
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 591 additions and 355 deletions

View File

@ -0,0 +1,28 @@
import { t } from '@lingui/macro';
import GenericErrorPage from './GenericErrorPage';
import NotAuthenticated from './NotAuthenticated';
import NotFound from './NotFound';
import PermissionDenied from './PermissionDenied';
export default function ClientError({ status }: { status?: number }) {
switch (status) {
case 401:
return <NotAuthenticated />;
case 403:
return <PermissionDenied />;
case 404:
return <NotFound />;
default:
break;
}
// Generic client error
return (
<GenericErrorPage
title={t`Client Error`}
message={t`Client error occurred`}
status={status}
/>
);
}

View File

@ -0,0 +1,74 @@
import { Trans } from '@lingui/macro';
import {
ActionIcon,
Alert,
Button,
Card,
Center,
Container,
Divider,
Group,
Stack,
Text
} from '@mantine/core';
import { IconArrowBack, IconExclamationCircle } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { LanguageContext } from '../../contexts/LanguageContext';
export default function ErrorPage({
title,
message,
status
}: {
title: string;
message: string;
status?: number;
redirectMessage?: string;
redirectTarget?: string;
}) {
const navigate = useNavigate();
return (
<LanguageContext>
<Center>
<Container w="md" miw={400}>
<Card withBorder shadow="xs" padding="xl" radius="sm">
<Card.Section p="lg">
<Group gap="xs">
<ActionIcon color="red" variant="transparent" size="xl">
<IconExclamationCircle />
</ActionIcon>
<Text size="xl">{title}</Text>
</Group>
</Card.Section>
<Divider />
<Card.Section p="lg">
<Stack gap="md">
<Text size="lg">{message}</Text>
{status && (
<Text>
<Trans>Status Code</Trans>: {status}
</Text>
)}
</Stack>
</Card.Section>
<Divider />
<Card.Section p="lg">
<Center>
<Button
variant="outline"
color="green"
onClick={() => navigate('/')}
>
<Trans>Return to the index page</Trans>
<IconArrowBack />
</Button>
</Center>
</Card.Section>
</Card>
</Container>
</Center>
</LanguageContext>
);
}

View File

@ -0,0 +1,12 @@
import { t } from '@lingui/macro';
import GenericErrorPage from './GenericErrorPage';
export default function NotAuthenticated() {
return (
<GenericErrorPage
title={t`Not Authenticated`}
message={t`You are not logged in.`}
/>
);
}

View File

@ -0,0 +1,12 @@
import { t } from '@lingui/macro';
import GenericErrorPage from './GenericErrorPage';
export default function NotFound() {
return (
<GenericErrorPage
title={t`Page Not Found`}
message={t`This page does not exist`}
/>
);
}

View File

@ -0,0 +1,12 @@
import { t } from '@lingui/macro';
import GenericErrorPage from './GenericErrorPage';
export default function PermissionDenied() {
return (
<GenericErrorPage
title={t`Permission Denied`}
message={t`You do not have permission to view this page.`}
/>
);
}

View File

@ -0,0 +1,13 @@
import { t } from '@lingui/macro';
import GenericErrorPage from './GenericErrorPage';
export default function ServerError({ status }: { status?: number }) {
return (
<GenericErrorPage
title={t`Server Error`}
message={t`A server error occurred`}
status={status}
/>
);
}

View File

@ -308,7 +308,7 @@ export function ApiForm({
return response;
} catch (error) {
console.error('Error fetching initial data:', error);
console.error('ERR: Error fetching initial data:', error);
// Re-throw error to allow react-query to handle error
throw error;
}

View File

@ -0,0 +1,28 @@
import { LoadingOverlay } from '@mantine/core';
import ClientError from '../errors/ClientError';
import ServerError from '../errors/ServerError';
export default function InstanceDetail({
status,
loading,
children
}: {
status: number;
loading: boolean;
children: React.ReactNode;
}) {
if (loading) {
return <LoadingOverlay />;
}
if (status >= 500) {
return <ServerError status={status} />;
}
if (status >= 400) {
return <ClientError status={status} />;
}
return <>{children}</>;
}

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { useCallback, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { api } from '../App';
import { ApiEndpoints } from '../enums/ApiEndpoints';
@ -39,6 +39,8 @@ export function useInstance<T = any>({
}) {
const [instance, setInstance] = useState<T | undefined>(defaultValue);
const [requestStatus, setRequestStatus] = useState<number>(0);
const instanceQuery = useQuery<T>({
queryKey: ['instance', endpoint, pk, params, pathParams],
queryFn: async () => {
@ -62,6 +64,7 @@ export function useInstance<T = any>({
params: params
})
.then((response) => {
setRequestStatus(response.status);
switch (response.status) {
case 200:
setInstance(response.data);
@ -72,8 +75,9 @@ export function useInstance<T = any>({
}
})
.catch((error) => {
setRequestStatus(error.response?.status || 0);
setInstance(defaultValue);
console.error(`Error fetching instance ${url}:`, error);
console.error(`ERR: Error fetching instance ${url}:`, error);
if (throwError) throw error;
@ -89,5 +93,5 @@ export function useInstance<T = any>({
instanceQuery.refetch();
}, []);
return { instance, refreshInstance, instanceQuery };
return { instance, refreshInstance, instanceQuery, requestStatus };
}

View File

@ -1,10 +1,9 @@
import { Trans, t } from '@lingui/macro';
import { Container, Text, Title } from '@mantine/core';
import { t } from '@lingui/macro';
import { useDocumentTitle } from '@mantine/hooks';
import { useEffect, useState } from 'react';
import { useRouteError } from 'react-router-dom';
import { LanguageContext } from '../contexts/LanguageContext';
import GenericErrorPage from '../components/errors/GenericErrorPage';
import { ErrorResponse } from '../states/states';
export default function ErrorPage() {
@ -19,18 +18,9 @@ export default function ErrorPage() {
}, [error]);
return (
<LanguageContext>
<Container>
<Title>
<Trans>Error</Trans>
</Title>
<Text>
<Trans>Sorry, an unexpected error has occurred.</Trans>
</Text>
<Text>
<i>{error.statusText || error.message}</i>
</Text>
</Container>
</LanguageContext>
<GenericErrorPage
title={title}
message={t`An unexpected error has occurred`}
/>
);
}

View File

@ -17,11 +17,13 @@ import {
} from '@tabler/icons-react';
import { lazy, useMemo } from 'react';
import PermissionDenied from '../../../../components/errors/PermissionDenied';
import { PlaceholderPill } from '../../../../components/items/Placeholder';
import { PanelGroup, PanelType } from '../../../../components/nav/PanelGroup';
import { SettingsHeader } from '../../../../components/nav/SettingsHeader';
import { GlobalSettingList } from '../../../../components/settings/SettingList';
import { Loadable } from '../../../../functions/loading';
import { useUserState } from '../../../../states/UserState';
const ReportTemplatePanel = Loadable(
lazy(() => import('./ReportTemplatePanel'))
@ -74,6 +76,8 @@ const CurrencyTable = Loadable(
);
export default function AdminCenter() {
const user = useUserState();
const adminCenterPanels: PanelType[] = useMemo(() => {
return [
{
@ -187,19 +191,25 @@ export default function AdminCenter() {
);
return (
<Stack gap="xs">
<SettingsHeader
title={t`Admin Center`}
subtitle={t`Advanced Options`}
switch_link="/settings/system"
switch_text="System Settings"
/>
<QuickAction />
<PanelGroup
pageKey="admin-center"
panels={adminCenterPanels}
collapsible={true}
/>
</Stack>
<>
{user.isStaff() ? (
<Stack gap="xs">
<SettingsHeader
title={t`Admin Center`}
subtitle={t`Advanced Options`}
switch_link="/settings/system"
switch_text="System Settings"
/>
<QuickAction />
<PanelGroup
pageKey="admin-center"
panels={adminCenterPanels}
collapsible={true}
/>
</Stack>
) : (
<PermissionDenied />
)}
</>
);
}

View File

@ -19,11 +19,13 @@ import {
} from '@tabler/icons-react';
import { useMemo } from 'react';
import PermissionDenied from '../../../components/errors/PermissionDenied';
import { PlaceholderPanel } from '../../../components/items/Placeholder';
import { PanelGroup, PanelType } from '../../../components/nav/PanelGroup';
import { SettingsHeader } from '../../../components/nav/SettingsHeader';
import { GlobalSettingList } from '../../../components/settings/SettingList';
import { useServerApiState } from '../../../states/ApiState';
import { useUserState } from '../../../states/UserState';
/**
* System settings page
@ -295,19 +297,26 @@ export default function SystemSettings() {
}
];
}, []);
const user = useUserState();
const [server] = useServerApiState((state) => [state.server]);
return (
<>
<Stack gap="xs">
<SettingsHeader
title={t`System Settings`}
subtitle={server.instance || ''}
switch_link="/settings/user"
switch_text={<Trans>Switch to User Setting</Trans>}
/>
<PanelGroup pageKey="system-settings" panels={systemSettingsPanels} />
</Stack>
{user.isStaff() ? (
<Stack gap="xs">
<SettingsHeader
title={t`System Settings`}
subtitle={server.instance || ''}
switch_link="/settings/user"
switch_text={<Trans>Switch to User Setting</Trans>}
/>
<PanelGroup pageKey="system-settings" panels={systemSettingsPanels} />
</Stack>
) : (
<PermissionDenied />
)}
</>
);
}

View File

@ -1,35 +0,0 @@
import { Trans } from '@lingui/macro';
import { Button, Center, Container, Stack, Text, Title } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { LanguageContext } from '../contexts/LanguageContext';
export default function NotFound() {
const navigate = useNavigate();
return (
<LanguageContext>
<Center mih="100vh">
<Container w="md" miw={400}>
<Stack>
<Title>
<Trans>Not Found</Trans>
</Title>
<Text>
<Trans>Sorry, this page is not known or was moved.</Trans>
</Text>
<Button
variant="outline"
color="green"
onClick={() => navigate('/')}
>
<Trans>Go to the start page</Trans>
<IconArrowBack />
</Button>
</Stack>
</Container>
</Center>
</LanguageContext>
);
}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import { Grid, Skeleton, Stack } from '@mantine/core';
import {
IconClipboardCheck,
IconClipboardList,
@ -30,6 +30,7 @@ import {
UnlinkBarcodeAction,
ViewBarcodeAction
} from '../../components/items/ActionDropdown';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { StatusRenderer } from '../../components/render/StatusRenderer';
@ -61,7 +62,8 @@ export default function BuildDetail() {
const {
instance: build,
refreshInstance,
instanceQuery
instanceQuery,
requestStatus
} = useInstance({
endpoint: ApiEndpoints.build_order_list,
pk: id,
@ -410,21 +412,22 @@ export default function BuildDetail() {
{editBuild.modal}
{duplicateBuild.modal}
{cancelBuild.modal}
<Stack gap="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />
<PageDetail
title={build.reference}
subtitle={build.title}
badges={buildBadges}
imageUrl={build.part_detail?.image ?? build.part_detail?.thumbnail}
breadcrumbs={[
{ name: t`Build Orders`, url: '/build' },
{ name: build.reference, url: `/build/${build.pk}` }
]}
actions={buildActions}
/>
<PanelGroup pageKey="build" panels={buildPanels} />
</Stack>
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap="xs">
<PageDetail
title={build.reference}
subtitle={build.title}
badges={buildBadges}
imageUrl={build.part_detail?.image ?? build.part_detail?.thumbnail}
breadcrumbs={[
{ name: t`Build Orders`, url: '/build' },
{ name: build.reference, url: `/build/${build.pk}` }
]}
actions={buildActions}
/>
<PanelGroup pageKey="build" panels={buildPanels} />
</Stack>
</InstanceDetail>
</>
);
}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import { Grid, Skeleton, Stack } from '@mantine/core';
import {
IconBuildingFactory2,
IconBuildingWarehouse,
@ -30,6 +30,7 @@ import {
EditItemAction
} from '../../components/items/ActionDropdown';
import { Breadcrumb } from '../../components/nav/BreadcrumbList';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
@ -66,7 +67,8 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
const {
instance: company,
refreshInstance,
instanceQuery
instanceQuery,
requestStatus
} = useInstance({
endpoint: ApiEndpoints.company_list,
pk: id,
@ -320,18 +322,19 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
return (
<>
{editCompany.modal}
<Stack gap="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />
<PageDetail
title={t`Company` + `: ${company.name}`}
subtitle={company.description}
actions={companyActions}
imageUrl={company.image}
breadcrumbs={props.breadcrumbs}
badges={badges}
/>
<PanelGroup pageKey="company" panels={companyPanels} />
</Stack>
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap="xs">
<PageDetail
title={t`Company` + `: ${company.name}`}
subtitle={company.description}
actions={companyActions}
imageUrl={company.image}
breadcrumbs={props.breadcrumbs}
badges={badges}
/>
<PanelGroup pageKey="company" panels={companyPanels} />
</Stack>
</InstanceDetail>
</>
);
}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import { Grid, Skeleton, Stack } from '@mantine/core';
import {
IconBuildingWarehouse,
IconDots,
@ -20,6 +20,7 @@ import {
DuplicateItemAction,
EditItemAction
} from '../../components/items/ActionDropdown';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
@ -44,7 +45,8 @@ export default function ManufacturerPartDetail() {
const {
instance: manufacturerPart,
instanceQuery,
refreshInstance
refreshInstance,
requestStatus
} = useInstance({
endpoint: ApiEndpoints.manufacturer_part_list,
pk: id,
@ -244,17 +246,18 @@ export default function ManufacturerPartDetail() {
return (
<>
{editManufacturerPart.modal}
<Stack gap="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />
<PageDetail
title={t`ManufacturerPart`}
subtitle={`${manufacturerPart.MPN} - ${manufacturerPart.part_detail?.name}`}
breadcrumbs={breadcrumbs}
actions={manufacturerPartActions}
imageUrl={manufacturerPart?.part_detail?.thumbnail}
/>
<PanelGroup pageKey="manufacturerpart" panels={panels} />
</Stack>
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap="xs">
<PageDetail
title={t`ManufacturerPart`}
subtitle={`${manufacturerPart.MPN} - ${manufacturerPart.part_detail?.name}`}
breadcrumbs={breadcrumbs}
actions={manufacturerPartActions}
imageUrl={manufacturerPart?.part_detail?.thumbnail}
/>
<PanelGroup pageKey="manufacturerpart" panels={panels} />
</Stack>
</InstanceDetail>
</>
);
}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import { Grid, Skeleton, Stack } from '@mantine/core';
import {
IconCurrencyDollar,
IconDots,
@ -21,6 +21,7 @@ import {
DuplicateItemAction,
EditItemAction
} from '../../components/items/ActionDropdown';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
@ -46,7 +47,8 @@ export default function SupplierPartDetail() {
const {
instance: supplierPart,
instanceQuery,
refreshInstance
refreshInstance,
requestStatus
} = useInstance({
endpoint: ApiEndpoints.supplier_part_list,
pk: id,
@ -312,18 +314,19 @@ export default function SupplierPartDetail() {
return (
<>
{editSuppliertPart.modal}
<Stack gap="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />
<PageDetail
title={t`Supplier Part`}
subtitle={`${supplierPart.SKU} - ${supplierPart?.part_detail?.name}`}
breadcrumbs={breadcrumbs}
badges={badges}
actions={supplierPartActions}
imageUrl={supplierPart?.part_detail?.thumbnail}
/>
<PanelGroup pageKey="supplierpart" panels={panels} />
</Stack>
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap="xs">
<PageDetail
title={t`Supplier Part`}
subtitle={`${supplierPart.SKU} - ${supplierPart?.part_detail?.name}`}
breadcrumbs={breadcrumbs}
badges={badges}
actions={supplierPartActions}
imageUrl={supplierPart?.part_detail?.thumbnail}
/>
<PanelGroup pageKey="supplierpart" panels={panels} />
</Stack>
</InstanceDetail>
</>
);
}

View File

@ -18,6 +18,7 @@ import {
DeleteItemAction,
EditItemAction
} from '../../components/items/ActionDropdown';
import InstanceDetail from '../../components/nav/InstanceDetail';
import NavigationTree from '../../components/nav/NavigationTree';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
@ -56,7 +57,8 @@ export default function CategoryDetail({}: {}) {
const {
instance: category,
refreshInstance,
instanceQuery
instanceQuery,
requestStatus
} = useInstance({
endpoint: ApiEndpoints.category_list,
hasPrimaryKey: true,
@ -275,29 +277,34 @@ export default function CategoryDetail({}: {}) {
<>
{editCategory.modal}
{deleteCategory.modal}
<Stack gap="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />
<NavigationTree
modelType={ModelType.partcategory}
title={t`Part Categories`}
endpoint={ApiEndpoints.category_tree}
opened={treeOpen}
onClose={() => {
setTreeOpen(false);
}}
selectedId={category?.pk}
/>
<PageDetail
title={t`Part Category`}
subtitle={category?.name}
breadcrumbs={breadcrumbs}
breadcrumbAction={() => {
setTreeOpen(true);
}}
actions={categoryActions}
/>
<PanelGroup pageKey="partcategory" panels={categoryPanels} />
</Stack>
<InstanceDetail
status={requestStatus}
loading={id ? instanceQuery.isFetching : false}
>
<Stack gap="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />
<NavigationTree
modelType={ModelType.partcategory}
title={t`Part Categories`}
endpoint={ApiEndpoints.category_tree}
opened={treeOpen}
onClose={() => {
setTreeOpen(false);
}}
selectedId={category?.pk}
/>
<PageDetail
title={t`Part Category`}
subtitle={category?.name}
breadcrumbs={breadcrumbs}
breadcrumbAction={() => {
setTreeOpen(true);
}}
actions={categoryActions}
/>
<PanelGroup pageKey="partcategory" panels={categoryPanels} />
</Stack>
</InstanceDetail>
</>
);
}

View File

@ -1,13 +1,5 @@
import { t } from '@lingui/macro';
import {
Alert,
Divider,
Grid,
LoadingOverlay,
Skeleton,
Stack,
Table
} from '@mantine/core';
import { Alert, Grid, Skeleton, Stack, Table } from '@mantine/core';
import {
IconBookmarks,
IconBuilding,
@ -31,7 +23,7 @@ import {
IconVersions
} from '@tabler/icons-react';
import { useSuspenseQuery } from '@tanstack/react-query';
import { ReactNode, useMemo, useState } from 'react';
import { ReactNode, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { api } from '../../App';
@ -54,6 +46,7 @@ import {
ViewBarcodeAction
} from '../../components/items/ActionDropdown';
import { PlaceholderPanel } from '../../components/items/Placeholder';
import InstanceDetail from '../../components/nav/InstanceDetail';
import NavigationTree from '../../components/nav/NavigationTree';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
@ -105,7 +98,8 @@ export default function PartDetail() {
const {
instance: part,
refreshInstance,
instanceQuery
instanceQuery,
requestStatus
} = useInstance({
endpoint: ApiEndpoints.part_list,
pk: id,
@ -821,33 +815,34 @@ export default function PartDetail() {
{duplicatePart.modal}
{editPart.modal}
{deletePart.modal}
<Stack gap="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />
<NavigationTree
title={t`Part Categories`}
modelType={ModelType.partcategory}
endpoint={ApiEndpoints.category_tree}
opened={treeOpen}
onClose={() => {
setTreeOpen(false);
}}
selectedId={part?.category}
/>
<PageDetail
title={t`Part` + ': ' + part.full_name}
subtitle={part.description}
imageUrl={part.image}
badges={badges}
breadcrumbs={breadcrumbs}
breadcrumbAction={() => {
setTreeOpen(true);
}}
actions={partActions}
/>
<PanelGroup pageKey="part" panels={partPanels} />
{transferStockItems.modal}
{countStockItems.modal}
</Stack>
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap="xs">
<NavigationTree
title={t`Part Categories`}
modelType={ModelType.partcategory}
endpoint={ApiEndpoints.category_tree}
opened={treeOpen}
onClose={() => {
setTreeOpen(false);
}}
selectedId={part?.category}
/>
<PageDetail
title={t`Part` + ': ' + part.full_name}
subtitle={part.description}
imageUrl={part.image}
badges={badges}
breadcrumbs={breadcrumbs}
breadcrumbAction={() => {
setTreeOpen(true);
}}
actions={partActions}
/>
<PanelGroup pageKey="part" panels={partPanels} />
{transferStockItems.modal}
{countStockItems.modal}
</Stack>
</InstanceDetail>
</>
);
}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import { Grid, Skeleton, Stack } from '@mantine/core';
import {
IconDots,
IconInfoCircle,
@ -27,6 +27,7 @@ import {
UnlinkBarcodeAction,
ViewBarcodeAction
} from '../../components/items/ActionDropdown';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { StatusRenderer } from '../../components/render/StatusRenderer';
@ -40,7 +41,6 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
import { PurchaseOrderLineItemTable } from '../../tables/purchasing/PurchaseOrderLineItemTable';
@ -57,7 +57,8 @@ export default function PurchaseOrderDetail() {
const {
instance: order,
instanceQuery,
refreshInstance
refreshInstance,
requestStatus
} = useInstance({
endpoint: ApiEndpoints.purchase_order_list,
pk: id,
@ -355,18 +356,19 @@ export default function PurchaseOrderDetail() {
return (
<>
{editPurchaseOrder.modal}
<Stack gap="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />
<PageDetail
title={t`Purchase Order` + `: ${order.reference}`}
subtitle={order.description}
imageUrl={order.supplier_detail?.image}
breadcrumbs={[{ name: t`Purchasing`, url: '/purchasing/' }]}
actions={poActions}
badges={orderBadges}
/>
<PanelGroup pageKey="purchaseorder" panels={orderPanels} />
</Stack>
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap="xs">
<PageDetail
title={t`Purchase Order` + `: ${order.reference}`}
subtitle={order.description}
imageUrl={order.supplier_detail?.image}
breadcrumbs={[{ name: t`Purchasing`, url: '/purchasing/' }]}
actions={poActions}
badges={orderBadges}
/>
<PanelGroup pageKey="purchaseorder" panels={orderPanels} />
</Stack>
</InstanceDetail>
</>
);
}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import { Grid, Skeleton, Stack } from '@mantine/core';
import {
IconDots,
IconInfoCircle,
@ -23,6 +23,7 @@ import {
EditItemAction
} from '../../components/items/ActionDropdown';
import { PlaceholderPanel } from '../../components/items/Placeholder';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { StatusRenderer } from '../../components/render/StatusRenderer';
@ -36,7 +37,6 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
@ -51,7 +51,8 @@ export default function ReturnOrderDetail() {
const {
instance: order,
instanceQuery,
refreshInstance
refreshInstance,
requestStatus
} = useInstance({
endpoint: ApiEndpoints.return_order_list,
pk: id,
@ -320,18 +321,19 @@ export default function ReturnOrderDetail() {
<>
{editReturnOrder.modal}
{duplicateReturnOrder.modal}
<Stack gap="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />
<PageDetail
title={t`Return Order` + `: ${order.reference}`}
subtitle={order.description}
imageUrl={order.customer_detail?.image}
badges={orderBadges}
actions={orderActions}
breadcrumbs={[{ name: t`Sales`, url: '/sales/' }]}
/>
<PanelGroup pageKey="returnorder" panels={orderPanels} />
</Stack>
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap="xs">
<PageDetail
title={t`Return Order` + `: ${order.reference}`}
subtitle={order.description}
imageUrl={order.customer_detail?.image}
badges={orderBadges}
actions={orderActions}
breadcrumbs={[{ name: t`Sales`, url: '/sales/' }]}
/>
<PanelGroup pageKey="returnorder" panels={orderPanels} />
</Stack>
</InstanceDetail>
</>
);
}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import { Grid, Skeleton, Stack } from '@mantine/core';
import {
IconDots,
IconInfoCircle,
@ -26,6 +26,7 @@ import {
EditItemAction
} from '../../components/items/ActionDropdown';
import { PlaceholderPanel } from '../../components/items/Placeholder';
import InstanceDetail from '../../components/nav/InstanceDetail';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { StatusRenderer } from '../../components/render/StatusRenderer';
@ -39,7 +40,6 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
@ -55,7 +55,8 @@ export default function SalesOrderDetail() {
const {
instance: order,
instanceQuery,
refreshInstance
refreshInstance,
requestStatus
} = useInstance({
endpoint: ApiEndpoints.sales_order_list,
pk: id,
@ -344,18 +345,19 @@ export default function SalesOrderDetail() {
return (
<>
{editSalesOrder.modal}
<Stack gap="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />
<PageDetail
title={t`Sales Order` + `: ${order.reference}`}
subtitle={order.description}
imageUrl={order.customer_detail?.image}
badges={orderBadges}
actions={soActions}
breadcrumbs={[{ name: t`Sales`, url: '/sales/' }]}
/>
<PanelGroup pageKey="salesorder" panels={orderPanels} />
</Stack>
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack gap="xs">
<PageDetail
title={t`Sales Order` + `: ${order.reference}`}
subtitle={order.description}
imageUrl={order.customer_detail?.image}
badges={orderBadges}
actions={soActions}
breadcrumbs={[{ name: t`Sales`, url: '/sales/' }]}
/>
<PanelGroup pageKey="salesorder" panels={orderPanels} />
</Stack>
</InstanceDetail>
</>
);
}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { LoadingOverlay, Skeleton, Stack, Text } from '@mantine/core';
import { Skeleton, Stack, Text } from '@mantine/core';
import {
IconDots,
IconInfoCircle,
@ -23,6 +23,7 @@ import {
UnlinkBarcodeAction,
ViewBarcodeAction
} from '../../components/items/ActionDropdown';
import InstanceDetail from '../../components/nav/InstanceDetail';
import NavigationTree from '../../components/nav/NavigationTree';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
@ -63,7 +64,8 @@ export default function Stock() {
const {
instance: location,
refreshInstance,
instanceQuery
instanceQuery,
requestStatus
} = useInstance({
endpoint: ApiEndpoints.stock_location_list,
hasPrimaryKey: true,
@ -355,29 +357,33 @@ export default function Stock() {
<>
{editLocation.modal}
{deleteLocation.modal}
<Stack>
<LoadingOverlay visible={instanceQuery.isFetching} />
<NavigationTree
title={t`Stock Locations`}
modelType={ModelType.stocklocation}
endpoint={ApiEndpoints.stock_location_tree}
opened={treeOpen}
onClose={() => setTreeOpen(false)}
selectedId={location?.pk}
/>
<PageDetail
title={t`Stock Items`}
subtitle={location?.name}
actions={locationActions}
breadcrumbs={breadcrumbs}
breadcrumbAction={() => {
setTreeOpen(true);
}}
/>
<PanelGroup pageKey="stocklocation" panels={locationPanels} />
{transferStockItems.modal}
{countStockItems.modal}
</Stack>
<InstanceDetail
status={requestStatus}
loading={id ? instanceQuery.isFetching : false}
>
<Stack>
<NavigationTree
title={t`Stock Locations`}
modelType={ModelType.stocklocation}
endpoint={ApiEndpoints.stock_location_tree}
opened={treeOpen}
onClose={() => setTreeOpen(false)}
selectedId={location?.pk}
/>
<PageDetail
title={t`Stock Items`}
subtitle={location?.name}
actions={locationActions}
breadcrumbs={breadcrumbs}
breadcrumbAction={() => {
setTreeOpen(true);
}}
/>
<PanelGroup pageKey="stocklocation" panels={locationPanels} />
{transferStockItems.modal}
{countStockItems.modal}
</Stack>
</InstanceDetail>
</>
);
}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { Grid, LoadingOverlay, Skeleton, Stack } from '@mantine/core';
import { Grid, Skeleton, Stack } from '@mantine/core';
import {
IconBookmark,
IconBoxPadding,
@ -33,6 +33,7 @@ import {
ViewBarcodeAction
} from '../../components/items/ActionDropdown';
import { PlaceholderPanel } from '../../components/items/Placeholder';
import InstanceDetail from '../../components/nav/InstanceDetail';
import NavigationTree from '../../components/nav/NavigationTree';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
@ -76,7 +77,8 @@ export default function StockDetail() {
const {
instance: stockitem,
refreshInstance,
instanceQuery
instanceQuery,
requestStatus
} = useInstance({
endpoint: ApiEndpoints.stock_item_list,
pk: id,
@ -548,35 +550,36 @@ export default function StockDetail() {
}, [stockitem, instanceQuery]);
return (
<Stack>
<LoadingOverlay visible={instanceQuery.isFetching} />
<NavigationTree
title={t`Stock Locations`}
modelType={ModelType.stocklocation}
endpoint={ApiEndpoints.stock_location_tree}
opened={treeOpen}
onClose={() => setTreeOpen(false)}
selectedId={stockitem?.location}
/>
<PageDetail
title={t`Stock Item`}
subtitle={stockitem.part_detail?.full_name}
imageUrl={stockitem.part_detail?.thumbnail}
badges={stockBadges}
breadcrumbs={breadcrumbs}
breadcrumbAction={() => {
setTreeOpen(true);
}}
actions={stockActions}
/>
<PanelGroup pageKey="stockitem" panels={stockPanels} />
{editStockItem.modal}
{duplicateStockItem.modal}
{deleteStockItem.modal}
{countStockItem.modal}
{addStockItem.modal}
{removeStockItem.modal}
{transferStockItem.modal}
</Stack>
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
<Stack>
<NavigationTree
title={t`Stock Locations`}
modelType={ModelType.stocklocation}
endpoint={ApiEndpoints.stock_location_tree}
opened={treeOpen}
onClose={() => setTreeOpen(false)}
selectedId={stockitem?.location}
/>
<PageDetail
title={t`Stock Item`}
subtitle={stockitem.part_detail?.full_name}
imageUrl={stockitem.part_detail?.thumbnail}
badges={stockBadges}
breadcrumbs={breadcrumbs}
breadcrumbAction={() => {
setTreeOpen(true);
}}
actions={stockActions}
/>
<PanelGroup pageKey="stockitem" panels={stockPanels} />
{editStockItem.modal}
{duplicateStockItem.modal}
{deleteStockItem.modal}
{countStockItem.modal}
{addStockItem.modal}
{removeStockItem.modal}
{transferStockItem.modal}
</Stack>
</InstanceDetail>
);
}

View File

@ -101,7 +101,9 @@ export const AdminCenter = Loadable(
lazy(() => import('./pages/Index/Settings/AdminCenter/Index'))
);
export const NotFound = Loadable(lazy(() => import('./pages/NotFound')));
export const NotFound = Loadable(
lazy(() => import('./components/errors/NotFound'))
);
export const Login = Loadable(lazy(() => import('./pages/Auth/Login')));
export const Logout = Loadable(lazy(() => import('./pages/Auth/Logout')));
export const Logged_In = Loadable(lazy(() => import('./pages/Auth/Logged-In')));

View File

@ -64,7 +64,8 @@ export const test = baseTest.extend({
url != 'http://localhost:8000/api/barcode/' &&
url != 'http://localhost:8000/api/news/?search=&offset=0&limit=25' &&
url != 'https://docs.inventree.org/en/versions.json' &&
!url.startsWith('chrome://')
!url.startsWith('chrome://') &&
url.indexOf('99999') < 0
)
messages.push(msg);
});

View File

@ -31,10 +31,14 @@ export const doQuickLogin = async (
password = password ?? user.password;
url = url ?? baseUrl;
// await page.goto(logoutUrl);
await page.goto(`${url}/login/?login=${username}&password=${password}`);
await page.waitForURL('**/platform/home');
await page
.getByRole('heading', { name: 'Welcome to your Dashboard,' })
.waitFor();
};
export const doLogout = async (page) => {
await page.goto(`${baseUrl}/logout/`);
await page.waitForURL('**/platform/login');
};

View File

@ -224,3 +224,13 @@ test('PUI - Pages - Part - Notes', async ({ page }) => {
// Check that the original notes are still present
await page.getByText('This is some data').waitFor();
});
test('PUI - Pages - Part - 404', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/part/99999/`);
await page.getByText('Page Not Found', { exact: true }).waitFor();
// Clear out any console error messages
await page.evaluate(() => console.clear());
});

View File

@ -54,4 +54,5 @@ test('PUI - Quick Login Test', async ({ page }) => {
// Logout (via URL)
await page.goto(`${baseUrl}/logout/`);
await page.waitForURL('**/platform/login');
await page.getByLabel('username');
});

View File

@ -1,6 +1,6 @@
import { test } from './baseFixtures.js';
import { baseUrl } from './defaults.js';
import { doQuickLogin } from './login.js';
import { doLogout, doQuickLogin } from './login.js';
test('PUI - Parts', async ({ page }) => {
await doQuickLogin(page);
@ -141,57 +141,6 @@ test('PUI - Scanning', async ({ page }) => {
await page.getByRole('option', { name: 'Manual input' }).click();
});
test('PUI - Admin', async ({ page }) => {
// Note here we login with admin access
await doQuickLogin(page, 'admin', 'inventree');
// User settings
await page.getByRole('button', { name: 'admin' }).click();
await page.getByRole('menuitem', { name: 'Account settings' }).click();
await page.getByRole('tab', { name: 'Security' }).click();
//await page.getByRole('tab', { name: 'Dashboard' }).click();
await page.getByRole('tab', { name: 'Display Options' }).click();
await page.getByText('Date Format').waitFor();
await page.getByRole('tab', { name: 'Search' }).click();
await page.getByText('Regex Search').waitFor();
await page.getByRole('tab', { name: 'Notifications' }).click();
await page.getByRole('tab', { name: 'Reporting' }).click();
await page.getByText('Inline report display').waitFor();
// System Settings
await page.getByRole('link', { name: 'Switch to System Setting' }).click();
await page.getByText('Base URL', { exact: true }).waitFor();
await page.getByRole('tab', { name: 'Login' }).click();
await page.getByRole('tab', { name: 'Barcodes' }).click();
await page.getByRole('tab', { name: 'Notifications' }).click();
await page.getByRole('tab', { name: 'Pricing' }).click();
await page.getByRole('tab', { name: 'Labels' }).click();
await page.getByRole('tab', { name: 'Reporting' }).click();
await page.getByRole('tab', { name: 'Part Categories' }).click();
//wait page.locator('#mantine-9hqbwrml8-tab-parts').click();
//await page.locator('#mantine-9hqbwrml8-tab-stock').click();
await page.getByRole('tab', { name: 'Stocktake' }).click();
await page.getByRole('tab', { name: 'Build Orders' }).click();
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
await page.getByRole('tab', { name: 'Sales Orders' }).click();
await page.getByRole('tab', { name: 'Return Orders' }).click();
// Admin Center
await page.getByRole('button', { name: 'admin' }).click();
await page.getByRole('menuitem', { name: 'Admin Center' }).click();
await page.getByRole('tab', { name: 'Background Tasks' }).click();
await page.getByRole('tab', { name: 'Error Reports' }).click();
await page.getByRole('tab', { name: 'Currencies' }).click();
await page.getByRole('tab', { name: 'Project Codes' }).click();
await page.getByRole('tab', { name: 'Custom Units' }).click();
await page.getByRole('tab', { name: 'Part Parameters' }).click();
await page.getByRole('tab', { name: 'Category Parameters' }).click();
await page.getByRole('tab', { name: 'Label Templates' }).click();
await page.getByRole('tab', { name: 'Report Templates' }).click();
await page.getByRole('tab', { name: 'Plugins' }).click();
await page.getByRole('tab', { name: 'Machines' }).click();
});
test('PUI - Language / Color', async ({ page }) => {
await doQuickLogin(page);

View File

@ -0,0 +1,83 @@
import { test } from './baseFixtures.js';
import { baseUrl } from './defaults.js';
import { doLogout, doQuickLogin } from './login.js';
test('PUI - Admin', async ({ page }) => {
// Note here we login with admin access
await doQuickLogin(page, 'admin', 'inventree');
// User settings
await page.getByRole('button', { name: 'admin' }).click();
await page.getByRole('menuitem', { name: 'Account settings' }).click();
await page.getByRole('tab', { name: 'Security' }).click();
await page.getByRole('tab', { name: 'Display Options' }).click();
await page.getByText('Date Format').waitFor();
await page.getByRole('tab', { name: 'Search' }).click();
await page.getByText('Regex Search').waitFor();
await page.getByRole('tab', { name: 'Notifications' }).click();
await page.getByRole('tab', { name: 'Reporting' }).click();
await page.getByText('Inline report display').waitFor();
// System Settings
await page.getByRole('link', { name: 'Switch to System Setting' }).click();
await page.getByText('Base URL', { exact: true }).waitFor();
await page.getByRole('tab', { name: 'Login' }).click();
await page.getByRole('tab', { name: 'Barcodes' }).click();
await page.getByRole('tab', { name: 'Notifications' }).click();
await page.getByRole('tab', { name: 'Pricing' }).click();
await page.getByRole('tab', { name: 'Labels' }).click();
await page.getByRole('tab', { name: 'Reporting' }).click();
await page.getByRole('tab', { name: 'Part Categories' }).click();
await page.getByRole('tab', { name: 'Stocktake' }).click();
await page.getByRole('tab', { name: 'Build Orders' }).click();
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
await page.getByRole('tab', { name: 'Sales Orders' }).click();
await page.getByRole('tab', { name: 'Return Orders' }).click();
// Admin Center
await page.getByRole('button', { name: 'admin' }).click();
await page.getByRole('menuitem', { name: 'Admin Center' }).click();
await page.getByRole('tab', { name: 'Background Tasks' }).click();
await page.getByRole('tab', { name: 'Error Reports' }).click();
await page.getByRole('tab', { name: 'Currencies' }).click();
await page.getByRole('tab', { name: 'Project Codes' }).click();
await page.getByRole('tab', { name: 'Custom Units' }).click();
await page.getByRole('tab', { name: 'Part Parameters' }).click();
await page.getByRole('tab', { name: 'Category Parameters' }).click();
await page.getByRole('tab', { name: 'Label Templates' }).click();
await page.getByRole('tab', { name: 'Report Templates' }).click();
await page.getByRole('tab', { name: 'Plugins' }).click();
await page.getByRole('tab', { name: 'Machines' }).click();
});
test('PUI - Admin - Unauthorized', async ({ page }) => {
// Try to access "admin" page with a non-staff user
await doQuickLogin(page, 'allaccess', 'nolimits');
await page.goto(`${baseUrl}/settings/admin/`);
await page.waitForURL('**/settings/admin/**');
// Should get a permission denied message
await page.getByText('Permission Denied').waitFor();
await page
.getByRole('button', { name: 'Return to the index page' })
.waitFor();
// Try to access user settings page (should be accessible)
await page.goto(`${baseUrl}/settings/user/`);
await page.waitForURL('**/settings/user/**');
await page.getByRole('tab', { name: 'Display Options' }).click();
await page.getByRole('tab', { name: 'Account' }).click();
// Try to access global settings page
await page.goto(`${baseUrl}/settings/system/`);
await page.waitForURL('**/settings/system/**');
await page.getByText('Permission Denied').waitFor();
await page
.getByRole('button', { name: 'Return to the index page' })
.waitFor();
});