diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 4bb10ecf60..5197911efd 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -270,7 +270,7 @@ class CategoryDetail(CategoryMixin, CustomRetrieveUpdateDestroyAPI): if 'starred' in data: starred = str2bool(data.get('starred', False)) - self.get_object().set_starred(request.user, starred) + self.get_object().set_starred(request.user, starred, include_parents=False) response = super().update(request, *args, **kwargs) @@ -1423,7 +1423,9 @@ class PartDetail(PartMixin, RetrieveUpdateDestroyAPI): if 'starred' in data: starred = str2bool(data.get('starred', False)) - self.get_object().set_starred(request.user, starred) + self.get_object().set_starred( + request.user, starred, include_variants=False, include_categories=False + ) response = super().update(request, *args, **kwargs) diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 5088093468..6e20eba45e 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -288,11 +288,10 @@ class PartCategory(InvenTree.models.InvenTreeTree): def get_subscribers(self, include_parents=True): """Return a list of users who subscribe to this PartCategory.""" - cats = self.get_ancestors(include_self=True) - subscribers = set() if include_parents: + cats = self.get_ancestors(include_self=True) queryset = PartCategoryStar.objects.filter(category__in=cats) else: queryset = PartCategoryStar.objects.filter(category=self) @@ -306,12 +305,12 @@ class PartCategory(InvenTree.models.InvenTreeTree): """Returns True if the specified user subscribes to this category.""" return user in self.get_subscribers(**kwargs) - def set_starred(self, user, status: bool) -> None: + def set_starred(self, user, status: bool, **kwargs) -> None: """Set the "subscription" status of this PartCategory against the specified user.""" if not user: return - if self.is_starred_by(user) == status: + if self.is_starred_by(user, **kwargs) == status: return if status: @@ -1451,13 +1450,13 @@ class Part( """Return True if the specified user subscribes to this part.""" return user in self.get_subscribers(**kwargs) - def set_starred(self, user, status): + def set_starred(self, user, status, **kwargs): """Set the "subscription" status of this Part against the specified user.""" if not user: return # Already subscribed? - if self.is_starred_by(user) == status: + if self.is_starred_by(user, **kwargs) == status: return if status: diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index 7146695231..54a30cf3f9 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -61,7 +61,14 @@ export function usePartFields({ salable: {}, virtual: {}, locked: {}, - active: {} + active: {}, + starred: { + field_type: 'boolean', + label: t`Subscribed`, + description: t`Subscribe to notifications for this part`, + disabled: false, + required: false + } }; // Additional fields for creation @@ -111,6 +118,10 @@ export function usePartFields({ delete fields['default_expiry']; } + if (create) { + delete fields['starred']; + } + return fields; }, [create, settings]); } @@ -118,25 +129,44 @@ export function usePartFields({ /** * Construct a set of fields for creating / editing a PartCategory instance */ -export function partCategoryFields(): ApiFormFieldSet { - let fields: ApiFormFieldSet = { - parent: { - description: t`Parent part category`, - required: false - }, - name: {}, - description: {}, - default_location: { - filters: { - structural: false +export function partCategoryFields({ + create +}: { + create?: boolean; +}): ApiFormFieldSet { + let fields: ApiFormFieldSet = useMemo(() => { + let fields: ApiFormFieldSet = { + parent: { + description: t`Parent part category`, + required: false + }, + name: {}, + description: {}, + default_location: { + filters: { + structural: false + } + }, + default_keywords: {}, + structural: {}, + starred: { + field_type: 'boolean', + label: t`Subscribed`, + description: t`Subscribe to notifications for this category`, + disabled: false, + required: false + }, + icon: { + field_type: 'icon' } - }, - default_keywords: {}, - structural: {}, - icon: { - field_type: 'icon' + }; + + if (create) { + delete fields['starred']; } - }; + + return fields; + }, [create]); return fields; } diff --git a/src/frontend/src/functions/icons.tsx b/src/frontend/src/functions/icons.tsx index 192bdbaf62..38f64de3be 100644 --- a/src/frontend/src/functions/icons.tsx +++ b/src/frontend/src/functions/icons.tsx @@ -3,6 +3,7 @@ import { Icon123, IconArrowBigDownLineFilled, IconArrowMerge, + IconBell, IconBinaryTree2, IconBookmarks, IconBox, @@ -159,6 +160,8 @@ const icons = { issue: IconBrandTelegram, complete: IconCircleCheck, deliver: IconTruckDelivery, + bell: IconBell, + notification: IconBell, // Part Icons active: IconCheck, diff --git a/src/frontend/src/pages/Index/Playground.tsx b/src/frontend/src/pages/Index/Playground.tsx index 5df5702c0d..b596f4ecaf 100644 --- a/src/frontend/src/pages/Index/Playground.tsx +++ b/src/frontend/src/pages/Index/Playground.tsx @@ -27,9 +27,10 @@ import { } from '../../hooks/UseForm'; // Generate some example forms using the modal API forms interface -const fields = partCategoryFields(); function ApiFormsPlayground() { + const fields = partCategoryFields({}); + const editCategory = useEditApiFormModal({ url: ApiEndpoints.category_list, pk: 2, diff --git a/src/frontend/src/pages/part/CategoryDetail.tsx b/src/frontend/src/pages/part/CategoryDetail.tsx index b75402c376..b1a4757a91 100644 --- a/src/frontend/src/pages/part/CategoryDetail.tsx +++ b/src/frontend/src/pages/part/CategoryDetail.tsx @@ -109,6 +109,12 @@ export default function CategoryDetail() { label: t`Parent Category`, model: ModelType.partcategory, hidden: !category?.parent + }, + { + type: 'boolean', + name: 'starred', + icon: 'notification', + label: t`Subscribed` } ]; @@ -165,7 +171,7 @@ export default function CategoryDetail() { url: ApiEndpoints.category_list, pk: id, title: t`Edit Part Category`, - fields: partCategoryFields(), + fields: partCategoryFields({}), onFormSuccess: refreshInstance }); diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 9ad5008541..7053e3b89e 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -358,6 +358,12 @@ export default function PartDetail() { type: 'boolean', name: 'virtual', label: t`Virtual Part` + }, + { + type: 'boolean', + name: 'starred', + label: t`Subscribed`, + icon: 'bell' } ]; diff --git a/src/frontend/src/tables/ColumnRenderers.tsx b/src/frontend/src/tables/ColumnRenderers.tsx index a02a3d6fe8..0f53d26b79 100644 --- a/src/frontend/src/tables/ColumnRenderers.tsx +++ b/src/frontend/src/tables/ColumnRenderers.tsx @@ -3,7 +3,7 @@ */ import { t } from '@lingui/macro'; import { Anchor, Group, Skeleton, Text, Tooltip } from '@mantine/core'; -import { IconExclamationCircle, IconLock } from '@tabler/icons-react'; +import { IconBell, IconExclamationCircle, IconLock } from '@tabler/icons-react'; import { YesNoButton } from '../components/buttons/YesNoButton'; import { Thumbnail } from '../components/images/Thumbnail'; @@ -42,6 +42,11 @@ export function PartColumn({ )} + {part?.starred && ( + + + + )} ) : ( diff --git a/src/frontend/src/tables/part/PartCategoryTable.tsx b/src/frontend/src/tables/part/PartCategoryTable.tsx index ee127f4578..a6eba9bef8 100644 --- a/src/frontend/src/tables/part/PartCategoryTable.tsx +++ b/src/frontend/src/tables/part/PartCategoryTable.tsx @@ -1,5 +1,6 @@ import { t } from '@lingui/macro'; -import { Group } from '@mantine/core'; +import { Group, Tooltip } from '@mantine/core'; +import { IconBell } from '@tabler/icons-react'; import { useCallback, useMemo, useState } from 'react'; import { AddItemButton } from '../../components/buttons/AddItemButton'; @@ -36,9 +37,20 @@ export function PartCategoryTable({ parentId }: Readonly<{ parentId?: any }>) { sortable: true, switchable: false, render: (record: any) => ( - - {record.icon && } - {record.name} + + + {record.icon && } + {record.name} + + + {record.starred && ( + + + + )} + ) }, @@ -81,10 +93,12 @@ export function PartCategoryTable({ parentId }: Readonly<{ parentId?: any }>) { ]; }, []); + const newCategoryFields = partCategoryFields({ create: true }); + const newCategory = useCreateApiFormModal({ url: ApiEndpoints.category_list, title: t`New Part Category`, - fields: partCategoryFields(), + fields: newCategoryFields, focus: 'name', initialData: { parent: parentId @@ -96,11 +110,13 @@ export function PartCategoryTable({ parentId }: Readonly<{ parentId?: any }>) { const [selectedCategory, setSelectedCategory] = useState(-1); + const editCategoryFields = partCategoryFields({ create: false }); + const editCategory = useEditApiFormModal({ url: ApiEndpoints.category_list, pk: selectedCategory, title: t`Edit Part Category`, - fields: partCategoryFields(), + fields: editCategoryFields, onFormSuccess: (record: any) => table.updateRecord(record) });