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
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."""
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
- Fixes for the BuildLine API endpoint

View File

@ -1022,7 +1022,14 @@ def get_objectreference(
ret = {}
if 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')

View File

@ -2,12 +2,13 @@ import { t } from '@lingui/macro';
import {
ActionIcon,
Alert,
Anchor,
Center,
Divider,
Drawer,
Group,
Loader,
Space,
Paper,
Stack,
Text,
Tooltip
@ -15,14 +16,84 @@ import {
import { IconArrowRight, IconBellCheck } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { navigateToLink } from '../../functions/navigation';
import { getDetailUrl } from '../../functions/urls';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { Boundary } from '../Boundary';
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.
@ -46,7 +117,8 @@ export function NotificationDrawer({
.get(apiUrl(ApiEndpoints.notifications_list), {
params: {
read: false,
limit: 10
limit: 10,
ordering: '-creation'
}
})
.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 (
<Drawer
opened={opened}
@ -117,67 +202,29 @@ export function NotificationDrawer({
</Group>
}
>
<Stack gap="xs">
<Divider />
{!hasNotifications && (
<Alert color="green">
<Text size="sm">{t`You have no unread notifications.`}</Text>
</Alert>
)}
{hasNotifications &&
notificationQuery.data?.results?.map((notification: any) => (
<Group justify="space-between" key={notification.pk}>
<Stack gap="3">
{notification?.target?.link ? (
<Text
size="sm"
component={Link}
to={notification?.target?.link}
target="_blank"
>
{notification.target?.name ??
notification.name ??
t`Notification`}
</Text>
) : (
<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>
<Boundary label="NotificationDrawer">
<Stack gap="xs">
<Divider />
{!hasNotifications && (
<Alert color="green">
<Text size="sm">{t`You have no unread notifications.`}</Text>
</Alert>
)}
{hasNotifications &&
notificationQuery.data?.results?.map((notification: any) => (
<NotificationEntry
key={`notification-${notification.pk}`}
notification={notification}
onRead={() => markAsRead(notification)}
/>
))}
{notificationQuery.isFetching && (
<Center>
<Loader size="sm" />
</Center>
)}
</Stack>
</Boundary>
</Drawer>
);
}

View File

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

View File

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

View File

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

View File

@ -252,6 +252,13 @@ export const ModelInformationDict: ModelDict = {
label: () => t`Content Type`,
label_multiple: () => t`Content Types`,
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',
labeltemplate = 'labeltemplate',
pluginconfig = 'pluginconfig',
contenttype = 'contenttype'
contenttype = 'contenttype',
error = 'error'
}

View File

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

View File

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