2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

[PUI] notifications fix (#8392)

* Include actual model name in notification repot

* Include model_id in API

* Refactoring for NotificationDrawer

- Add error boundary
- Separate rendering func for individual entry
- Allow router navigation
- Better link introspection
- Add tooltip for notification description

* Add URL support for errors

* Allow navigation to an individual error in the admin center

* Updates

* Add rendering method for 'error' model type
This commit is contained in:
Oliver 2024-10-29 15:00:34 +11:00 committed by GitHub
parent 40f456fbc9
commit 8a7a2da07b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 196 additions and 90 deletions

View File

@ -1,13 +1,16 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 273 INVENTREE_API_VERSION = 274
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v274 - 2024-10-29 : https://github.com/inventree/InvenTree/pull/8392
- Add more detailed information to NotificationEntry API serializer
v273 - 2024-10-28 : https://github.com/inventree/InvenTree/pull/8376 v273 - 2024-10-28 : https://github.com/inventree/InvenTree/pull/8376
- Fixes for the BuildLine API endpoint - Fixes for the BuildLine API endpoint

View File

@ -1022,7 +1022,14 @@ def get_objectreference(
ret = {} ret = {}
if url_fnc: if url_fnc:
ret['link'] = url_fnc() ret['link'] = url_fnc()
return {'name': str(item), 'model': str(model_cls._meta.verbose_name), **ret}
return {
'name': str(item),
'model_name': str(model_cls._meta.verbose_name),
'model_type': str(model_cls._meta.model_name),
'model_id': getattr(item, 'pk', None),
**ret,
}
Inheritors_T = TypeVar('Inheritors_T') Inheritors_T = TypeVar('Inheritors_T')

View File

@ -2,12 +2,13 @@ import { t } from '@lingui/macro';
import { import {
ActionIcon, ActionIcon,
Alert, Alert,
Anchor,
Center, Center,
Divider, Divider,
Drawer, Drawer,
Group, Group,
Loader, Loader,
Space, Paper,
Stack, Stack,
Text, Text,
Tooltip Tooltip
@ -15,14 +16,84 @@ import {
import { IconArrowRight, IconBellCheck } from '@tabler/icons-react'; import { IconArrowRight, IconBellCheck } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { api } from '../../App'; import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { navigateToLink } from '../../functions/navigation'; import { navigateToLink } from '../../functions/navigation';
import { getDetailUrl } from '../../functions/urls';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import { Boundary } from '../Boundary';
import { StylishText } from '../items/StylishText'; import { StylishText } from '../items/StylishText';
import { ModelInformationDict } from '../render/ModelType';
/**
* Render a single notification entry in the drawer
*/
function NotificationEntry({
notification,
onRead
}: {
notification: any;
onRead: () => void;
}) {
const navigate = useNavigate();
let link = notification.target?.link;
let model_type = notification.target?.model_type;
let model_id = notification.target?.model_id;
// If a valid model type is provided, that overrides the specified link
if (model_type as ModelType) {
let model_info = ModelInformationDict[model_type as ModelType];
if (model_info?.url_detail && model_id) {
link = getDetailUrl(model_type as ModelType, model_id);
} else if (model_info?.url_overview) {
link = model_info.url_overview;
}
}
return (
<Paper p="xs" shadow="xs">
<Group justify="space-between" wrap="nowrap">
<Tooltip
label={notification.message}
position="bottom-end"
hidden={!notification.message}
>
<Stack gap={2}>
<Anchor
href={link}
underline="hover"
target="_blank"
onClick={(event: any) => {
if (link) {
// Mark the notification as read
onRead();
}
if (link.startsWith('/')) {
navigateToLink(link, navigate, event);
}
}}
>
<Text size="sm">{notification.name}</Text>
</Anchor>
<Text size="xs">{notification.age_human}</Text>
</Stack>
</Tooltip>
<Tooltip label={t`Mark as read`} position="bottom-end">
<ActionIcon variant="transparent" onClick={onRead}>
<IconBellCheck />
</ActionIcon>
</Tooltip>
</Group>
</Paper>
);
}
/** /**
* Construct a notification drawer. * Construct a notification drawer.
@ -46,7 +117,8 @@ export function NotificationDrawer({
.get(apiUrl(ApiEndpoints.notifications_list), { .get(apiUrl(ApiEndpoints.notifications_list), {
params: { params: {
read: false, read: false,
limit: 10 limit: 10,
ordering: '-creation'
} }
}) })
.then((response) => response.data) .then((response) => response.data)
@ -73,6 +145,19 @@ export function NotificationDrawer({
}); });
}, []); }, []);
const markAsRead = useCallback((notification: any) => {
api
.patch(apiUrl(ApiEndpoints.notifications_list, notification.pk), {
read: true
})
.then(() => {
notificationQuery.refetch();
})
.catch(() => {
notificationQuery.refetch();
});
}, []);
return ( return (
<Drawer <Drawer
opened={opened} opened={opened}
@ -117,67 +202,29 @@ export function NotificationDrawer({
</Group> </Group>
} }
> >
<Stack gap="xs"> <Boundary label="NotificationDrawer">
<Divider /> <Stack gap="xs">
{!hasNotifications && ( <Divider />
<Alert color="green"> {!hasNotifications && (
<Text size="sm">{t`You have no unread notifications.`}</Text> <Alert color="green">
</Alert> <Text size="sm">{t`You have no unread notifications.`}</Text>
)} </Alert>
{hasNotifications && )}
notificationQuery.data?.results?.map((notification: any) => ( {hasNotifications &&
<Group justify="space-between" key={notification.pk}> notificationQuery.data?.results?.map((notification: any) => (
<Stack gap="3"> <NotificationEntry
{notification?.target?.link ? ( key={`notification-${notification.pk}`}
<Text notification={notification}
size="sm" onRead={() => markAsRead(notification)}
component={Link} />
to={notification?.target?.link} ))}
target="_blank" {notificationQuery.isFetching && (
> <Center>
{notification.target?.name ?? <Loader size="sm" />
notification.name ?? </Center>
t`Notification`} )}
</Text> </Stack>
) : ( </Boundary>
<Text size="sm">
{notification.target?.name ??
notification.name ??
t`Notification`}
</Text>
)}
<Text size="xs">{notification.age_human ?? ''}</Text>
</Stack>
<Space />
<ActionIcon
color="gray"
variant="hover"
onClick={() => {
let url = apiUrl(
ApiEndpoints.notifications_list,
notification.pk
);
api
.patch(url, {
read: true
})
.then((response) => {
notificationQuery.refetch();
});
}}
>
<Tooltip label={t`Mark as read`}>
<IconBellCheck />
</Tooltip>
</ActionIcon>
</Group>
))}
{notificationQuery.isFetching && (
<Center>
<Loader size="sm" />
</Center>
)}
</Stack>
</Drawer> </Drawer>
); );
} }

View File

@ -2,6 +2,7 @@ import { t } from '@lingui/macro';
import { Accordion, Alert, Card, Stack, Text } from '@mantine/core'; import { Accordion, Alert, Card, Stack, Text } from '@mantine/core';
import { IconExclamationCircle } from '@tabler/icons-react'; import { IconExclamationCircle } from '@tabler/icons-react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
@ -18,12 +19,18 @@ export default function PluginDrawer({
pluginKey, pluginKey,
pluginInstance pluginInstance
}: { }: {
pluginKey: string; pluginKey?: string;
pluginInstance: PluginInterface; pluginInstance: PluginInterface;
}) { }) {
const { id } = useParams();
const pluginPrimaryKey: string = useMemo(() => {
return pluginKey || id || '';
}, [pluginKey, id]);
const { instance: pluginAdmin } = useInstance({ const { instance: pluginAdmin } = useInstance({
endpoint: ApiEndpoints.plugin_admin, endpoint: ApiEndpoints.plugin_admin,
pathParams: { key: pluginKey }, pathParams: { key: pluginPrimaryKey },
defaultValue: {}, defaultValue: {},
hasPrimaryKey: false, hasPrimaryKey: false,
refetchOnMount: true refetchOnMount: true
@ -128,7 +135,7 @@ export default function PluginDrawer({
</Accordion.Control> </Accordion.Control>
<Accordion.Panel> <Accordion.Panel>
<Card withBorder> <Card withBorder>
<PluginSettingList pluginKey={pluginKey} /> <PluginSettingList pluginKey={pluginPrimaryKey} />
</Card> </Card>
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>

View File

@ -21,6 +21,12 @@ export function RenderContentType({
return instance && <RenderInlineModel primary={instance.app_labeled_name} />; return instance && <RenderInlineModel primary={instance.app_labeled_name} />;
} }
export function RenderError({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
return instance && <RenderInlineModel primary={instance.name} />;
}
export function RenderImportSession({ export function RenderImportSession({
instance instance
}: { }: {

View File

@ -18,6 +18,7 @@ import {
} from './Company'; } from './Company';
import { import {
RenderContentType, RenderContentType,
RenderError,
RenderImportSession, RenderImportSession,
RenderProjectCode RenderProjectCode
} from './Generic'; } from './Generic';
@ -92,7 +93,8 @@ const RendererLookup: EnumDictionary<
[ModelType.reporttemplate]: RenderReportTemplate, [ModelType.reporttemplate]: RenderReportTemplate,
[ModelType.labeltemplate]: RenderLabelTemplate, [ModelType.labeltemplate]: RenderLabelTemplate,
[ModelType.pluginconfig]: RenderPlugin, [ModelType.pluginconfig]: RenderPlugin,
[ModelType.contenttype]: RenderContentType [ModelType.contenttype]: RenderContentType,
[ModelType.error]: RenderError
}; };
export type RenderInstanceProps = { export type RenderInstanceProps = {

View File

@ -252,6 +252,13 @@ export const ModelInformationDict: ModelDict = {
label: () => t`Content Type`, label: () => t`Content Type`,
label_multiple: () => t`Content Types`, label_multiple: () => t`Content Types`,
api_endpoint: ApiEndpoints.content_type_list api_endpoint: ApiEndpoints.content_type_list
},
error: {
label: () => t`Error`,
label_multiple: () => t`Errors`,
api_endpoint: ApiEndpoints.error_report_list,
url_overview: '/settings/admin/errors',
url_detail: '/settings/admin/errors/:pk/'
} }
}; };

View File

@ -32,5 +32,6 @@ export enum ModelType {
reporttemplate = 'reporttemplate', reporttemplate = 'reporttemplate',
labeltemplate = 'labeltemplate', labeltemplate = 'labeltemplate',
pluginconfig = 'pluginconfig', pluginconfig = 'pluginconfig',
contenttype = 'contenttype' contenttype = 'contenttype',
error = 'error'
} }

View File

@ -5,7 +5,7 @@ import {
IconBellCheck, IconBellCheck,
IconBellExclamation, IconBellExclamation,
IconCircleCheck, IconCircleCheck,
IconCircleX, IconMail,
IconMailOpened, IconMailOpened,
IconTrash IconTrash
} from '@tabler/icons-react'; } from '@tabler/icons-react';
@ -106,7 +106,7 @@ export default function NotificationsPage() {
actions={(record) => [ actions={(record) => [
{ {
title: t`Mark as unread`, title: t`Mark as unread`,
icon: <IconCircleX />, icon: <IconMail />,
onClick: () => { onClick: () => {
let url = apiUrl(ApiEndpoints.notifications_list, record.pk); let url = apiUrl(ApiEndpoints.notifications_list, record.pk);

View File

@ -1,12 +1,13 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Drawer, Group, Stack, Table, Text } from '@mantine/core'; import { Group, Loader, Stack, Table, Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { CopyButton } from '../../components/buttons/CopyButton'; import { CopyButton } from '../../components/buttons/CopyButton';
import { StylishText } from '../../components/items/StylishText'; import { DetailDrawer } from '../../components/nav/DetailDrawer';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { useDeleteApiFormModal } from '../../hooks/UseForm'; import { useDeleteApiFormModal } from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { useTable } from '../../hooks/UseTable'; import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
@ -14,7 +15,33 @@ import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction, RowDeleteAction } from '../RowActions'; import { RowAction, RowDeleteAction } from '../RowActions';
function ErrorDetail({ error }: { error: any }) { function ErrorDetail({ errorId }: { errorId?: number }) {
const { id } = useParams();
const errorPrimaryKey = useMemo(() => {
return errorId ?? id;
}, [errorId, id]);
const errorInstance = useInstance({
endpoint: ApiEndpoints.error_report_list,
pk: errorPrimaryKey,
defaultValue: {},
hasPrimaryKey: true,
refetchOnMount: true
});
const error = useMemo(
() => errorInstance.instance || {},
[errorInstance.instance]
);
if (
errorInstance.instanceQuery.isFetching ||
errorInstance.instanceQuery.isLoading
) {
return <Loader />;
}
return ( return (
<Stack gap="xs"> <Stack gap="xs">
<Table> <Table>
@ -47,7 +74,7 @@ function ErrorDetail({ error }: { error: any }) {
<Table.Tr> <Table.Tr>
<Table.Td colSpan={2}> <Table.Td colSpan={2}>
<Stack gap={3}> <Stack gap={3}>
{error.data.split('\n').map((line: string, index: number) => ( {error.data?.split('\n').map((line: string, index: number) => (
<Text size="xs" key={`error-line-${index}`}> <Text size="xs" key={`error-line-${index}`}>
{line} {line}
</Text> </Text>
@ -67,8 +94,7 @@ function ErrorDetail({ error }: { error: any }) {
export default function ErrorReportTable() { export default function ErrorReportTable() {
const table = useTable('error-report'); const table = useTable('error-report');
const user = useUserState(); const user = useUserState();
const navigate = useNavigate();
const [opened, { open, close }] = useDisclosure(false);
const columns: TableColumn[] = useMemo(() => { const columns: TableColumn[] = useMemo(() => {
return [ return [
@ -116,15 +142,15 @@ export default function ErrorReportTable() {
return ( return (
<> <>
{deleteErrorModal.modal} {deleteErrorModal.modal}
<Drawer <DetailDrawer
opened={opened} title={t`Error Details`}
size="xl" size={'xl'}
position="right" renderContent={(pk) => {
title={<StylishText>{t`Error Details`}</StylishText>} if (!pk) return;
onClose={close}
> return <ErrorDetail errorId={selectedError.pk} />;
<ErrorDetail error={selectedError} /> }}
</Drawer> />
<InvenTreeTable <InvenTreeTable
url={apiUrl(ApiEndpoints.error_report_list)} url={apiUrl(ApiEndpoints.error_report_list)}
tableState={table} tableState={table}
@ -135,7 +161,7 @@ export default function ErrorReportTable() {
rowActions: rowActions, rowActions: rowActions,
onRowClick: (row) => { onRowClick: (row) => {
setSelectedError(row); setSelectedError(row);
open(); navigate(`${row.pk}/`);
} }
}} }}
/> />