diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index dd6fb87de1..78d979af3b 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -726,6 +726,11 @@ jobs: - name: Install Playwright OS dependencies if: steps.playwright-cache.outputs.cache-hit == 'true' run: cd src/frontend && npx playwright install-deps + - name: Install Sample Plugin + run: | + pip install -U inventree-plugin-creator + create-inventree-plugin --default + cd MyCustomPlugin && pip install -e . && cd frontend && npm install && npm run translate && npm run build - name: Run Playwright tests id: tests run: | diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index f13f695f0c..6997504b6f 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 498 +INVENTREE_API_VERSION = 499 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v499 -> 2026-06-01 : https://github.com/inventree/InvenTree/pull/12057 + - Fixes search field issues on the BarcodeScanHistory API endpoint + v498 -> 2026-05-31 : https://github.com/inventree/InvenTree/pull/12055 - Updates the "status_text" field for models which support custom status values diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 0d210161a1..44f138e5fa 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -3539,6 +3539,11 @@ class EmailThread(InvenTree.models.InvenTreeMetadataModel): unique_together = [['key', 'global_id']] ordering = ['-updated'] + @staticmethod + def get_api_url(): + """Return the API URL associated with the EmailThread model.""" + return reverse('api-email-list') + key = models.CharField( max_length=250, verbose_name=_('Key'), diff --git a/src/backend/InvenTree/generic/states/test_transition.py b/src/backend/InvenTree/generic/states/test_transition.py index b8cb3c371d..37561ceb56 100644 --- a/src/backend/InvenTree/generic/states/test_transition.py +++ b/src/backend/InvenTree/generic/states/test_transition.py @@ -121,3 +121,7 @@ class TransitionTests(InvenTreeTestCase): self.assertIn( "ValueError('This is a broken transition plugin!')", str(cm.output[0]) ) + + # Ensure the plugin is now disabled + registry.set_plugin_state('sample-transition', False) + registry.set_plugin_state('sample-broken-transition', False) diff --git a/src/backend/InvenTree/generic/states/transition.py b/src/backend/InvenTree/generic/states/transition.py index 80ae2f7f3c..785ac16600 100644 --- a/src/backend/InvenTree/generic/states/transition.py +++ b/src/backend/InvenTree/generic/states/transition.py @@ -60,7 +60,7 @@ class TransitionMethod: **kwargs: Additional keyword arguments for custom logic. Returns: - result: bool - True if the transition method was successful, False otherwise. + result: bool - True if the transition method was successful (and no further transitions are attempted), False otherwise. Raises: ValidationError: Alert the user that the transition failed diff --git a/src/backend/InvenTree/plugin/base/barcodes/api.py b/src/backend/InvenTree/plugin/base/barcodes/api.py index f561319f6e..cf9b0d8dd6 100644 --- a/src/backend/InvenTree/plugin/base/barcodes/api.py +++ b/src/backend/InvenTree/plugin/base/barcodes/api.py @@ -853,11 +853,11 @@ class BarcodeScanResultList(BarcodeScanResultMixin, BulkDeleteMixin, ListAPI): filterset_class = BarcodeScanResultFilter filter_backends = SEARCH_ORDER_FILTER - ordering_fields = ['user', 'plugin', 'timestamp', 'endpoint', 'result'] + ordering_fields = ['user', 'timestamp', 'endpoint', 'result'] ordering = '-timestamp' - search_fields = ['plugin'] + search_fields = ['data'] class BarcodeScanResultDetail(BarcodeScanResultMixin, RetrieveDestroyAPI): diff --git a/src/backend/InvenTree/plugin/base/integration/AppMixin.py b/src/backend/InvenTree/plugin/base/integration/AppMixin.py index b3690afee1..507d188ccc 100644 --- a/src/backend/InvenTree/plugin/base/integration/AppMixin.py +++ b/src/backend/InvenTree/plugin/base/integration/AppMixin.py @@ -51,7 +51,9 @@ class AppMixin: """ from common.settings import get_global_setting - if settings.PLUGIN_TESTING or get_global_setting('ENABLE_PLUGINS_APP'): + if settings.PLUGIN_TESTING or get_global_setting( + 'ENABLE_PLUGINS_APP', create=False + ): logger.info('Registering IntegrationPlugin apps') apps_changed = False diff --git a/src/frontend/lib/types/Forms.tsx b/src/frontend/lib/types/Forms.tsx index 83e78a8e1a..b918205b97 100644 --- a/src/frontend/lib/types/Forms.tsx +++ b/src/frontend/lib/types/Forms.tsx @@ -2,6 +2,7 @@ import type { DefaultMantineColor, MantineStyleProp } from '@mantine/core'; import type { UseFormReturnType } from '@mantine/form'; import type { JSX, ReactNode } from 'react'; import type { FieldValues, UseFormReturn } from 'react-hook-form'; +import type { NavigateFunction } from 'react-router-dom'; import type { ApiEndpoints } from '../enums/ApiEndpoints'; import type { ModelType } from '../enums/ModelType'; import type { PathParams, UiSizeType } from './Core'; @@ -160,6 +161,7 @@ export type ApiFormFieldSet = Record; * @param modelType : Define a model type for this form * @param follow : Boolean, follow the result of the form (if possible) * @param table : Table to update on success (if provided) + * @param navigate : Optional navigate function to use for following results (if follow is true) */ export interface ApiFormProps { url: ApiEndpoints | string; @@ -189,6 +191,7 @@ export interface ApiFormProps { follow?: boolean; actions?: ApiFormAction[]; timeout?: number; + navigate?: NavigateFunction; keepOpenOption?: boolean; onKeepOpenChange?: (keepOpen: boolean) => void; } diff --git a/src/frontend/src/components/dashboard/DashboardWidget.tsx b/src/frontend/src/components/dashboard/DashboardWidget.tsx index cc806d01b9..b6809bcc08 100644 --- a/src/frontend/src/components/dashboard/DashboardWidget.tsx +++ b/src/frontend/src/components/dashboard/DashboardWidget.tsx @@ -5,6 +5,7 @@ import { IconX } from '@tabler/icons-react'; import { Boundary } from '@lib/components/Boundary'; import type { ModelType } from '@lib/index'; +import type { InvenTreeIconType } from '@lib/types/Icons'; import type { JSX } from 'react'; /** @@ -22,6 +23,7 @@ export interface DashboardWidgetProps { minWidth?: number; minHeight?: number; modelType?: ModelType; + icon?: keyof InvenTreeIconType; render: () => JSX.Element; visible?: () => boolean; } diff --git a/src/frontend/src/components/dashboard/DashboardWidgetDrawer.tsx b/src/frontend/src/components/dashboard/DashboardWidgetDrawer.tsx index 397111102e..b4ad089471 100644 --- a/src/frontend/src/components/dashboard/DashboardWidgetDrawer.tsx +++ b/src/frontend/src/components/dashboard/DashboardWidgetDrawer.tsx @@ -12,10 +12,11 @@ import { Tooltip } from '@mantine/core'; import { useDebouncedValue } from '@mantine/hooks'; -import { IconBackspace, IconLayoutGridAdd } from '@tabler/icons-react'; +import { IconBackspace } from '@tabler/icons-react'; import { useMemo, useState } from 'react'; import { StylishText } from '@lib/components/StylishText'; +import { InvenTreeIcon } from '../../functions/icons'; import { useDashboardItems } from '../../hooks/UseDashboardItems'; /** @@ -105,7 +106,7 @@ export default function DashboardWidgetDrawer({ onAddWidget(widget.label); }} > - + diff --git a/src/frontend/src/components/dashboard/DashboardWidgetLibrary.tsx b/src/frontend/src/components/dashboard/DashboardWidgetLibrary.tsx index 5ef7b5f04f..76ada27f8d 100644 --- a/src/frontend/src/components/dashboard/DashboardWidgetLibrary.tsx +++ b/src/frontend/src/components/dashboard/DashboardWidgetLibrary.tsx @@ -8,6 +8,7 @@ import ColorToggleDashboardWidget from './widgets/ColorToggleWidget'; import GetStartedWidget from './widgets/GetStartedWidget'; import LanguageSelectDashboardWidget from './widgets/LanguageSelectWidget'; import NewsWidget from './widgets/NewsWidget'; +import OrderHistoryWidget from './widgets/OrderHistoryWidget'; import QueryCountDashboardWidget from './widgets/QueryCountDashboardWidget'; import QueryDashboardWidget from './widgets/QueryDashboardWidget'; import StocktakeDashboardWidget from './widgets/StocktakeDashboardWidget'; @@ -43,6 +44,7 @@ function BuiltinQueryCountWidgets(): DashboardWidgetProps[] { title: t`Invalid BOMs`, description: t`Assemblies requiring bill of materials validation`, modelType: ModelType.part, + icon: 'exclamation', params: { active: true, // Only show active parts assembly: true, // Only show parts which are assemblies @@ -94,7 +96,8 @@ function BuiltinQueryCountWidgets(): DashboardWidgetProps[] { description: t`Show the number of stock items which have expired`, modelType: ModelType.stockitem, params: { expired: true }, - enabled: globalSettings.isSet('STOCK_ENABLE_EXPIRY') + enabled: globalSettings.isSet('STOCK_ENABLE_EXPIRY'), + icon: 'overdue' }), QueryCountDashboardWidget({ title: t`Stale Stock Items`, @@ -116,14 +119,16 @@ function BuiltinQueryCountWidgets(): DashboardWidgetProps[] { label: 'ovr-bo', description: t`Show the number of build orders which are overdue`, modelType: ModelType.build, - params: { overdue: true } + params: { overdue: true }, + icon: 'overdue' }), QueryCountDashboardWidget({ title: t`Assigned Build Orders`, label: 'asn-bo', description: t`Show the number of build orders which are assigned to you`, modelType: ModelType.build, - params: { assigned_to_me: true, outstanding: true } + params: { assigned_to_me: true, outstanding: true }, + icon: 'responsible' }), QueryCountDashboardWidget({ title: t`Active Sales Orders`, @@ -137,14 +142,16 @@ function BuiltinQueryCountWidgets(): DashboardWidgetProps[] { label: 'ovr-so', description: t`Show the number of sales orders which are overdue`, modelType: ModelType.salesorder, - params: { overdue: true } + params: { overdue: true }, + icon: 'overdue' }), QueryCountDashboardWidget({ title: t`Assigned Sales Orders`, label: 'asn-so', description: t`Show the number of sales orders which are assigned to you`, modelType: ModelType.salesorder, - params: { assigned_to_me: true, outstanding: true } + params: { assigned_to_me: true, outstanding: true }, + icon: 'responsible' }), QueryCountDashboardWidget({ title: t`Pending Shipments`, @@ -165,14 +172,16 @@ function BuiltinQueryCountWidgets(): DashboardWidgetProps[] { label: 'ovr-po', description: t`Show the number of purchase orders which are overdue`, modelType: ModelType.purchaseorder, - params: { overdue: true } + params: { overdue: true }, + icon: 'overdue' }), QueryCountDashboardWidget({ title: t`Assigned Purchase Orders`, label: 'asn-po', description: t`Show the number of purchase orders which are assigned to you`, modelType: ModelType.purchaseorder, - params: { assigned_to_me: true, outstanding: true } + params: { assigned_to_me: true, outstanding: true }, + icon: 'responsible' }), QueryCountDashboardWidget({ title: t`Active Return Orders`, @@ -186,14 +195,16 @@ function BuiltinQueryCountWidgets(): DashboardWidgetProps[] { label: 'ovr-ro', description: t`Show the number of return orders which are overdue`, modelType: ModelType.returnorder, - params: { overdue: true } + params: { overdue: true }, + icon: 'overdue' }), QueryCountDashboardWidget({ title: t`Assigned Return Orders`, label: 'asn-ro', description: t`Show the number of return orders which are assigned to you`, modelType: ModelType.returnorder, - params: { assigned_to_me: true, outstanding: true } + params: { assigned_to_me: true, outstanding: true }, + icon: 'responsible' }) ]; @@ -207,6 +218,26 @@ function BuiltinQueryCountWidgets(): DashboardWidgetProps[] { }); } +function BuiltinHistoryWidgets(): DashboardWidgetProps[] { + return [ + OrderHistoryWidget({ + modelType: ModelType.build + }), + OrderHistoryWidget({ + modelType: ModelType.salesorder + }), + OrderHistoryWidget({ + modelType: ModelType.purchaseorder + }), + OrderHistoryWidget({ + modelType: ModelType.returnorder + }), + OrderHistoryWidget({ + modelType: ModelType.transferorder + }) + ]; +} + function BuiltinGettingStartedWidgets(): DashboardWidgetProps[] { return [ { @@ -215,6 +246,7 @@ function BuiltinGettingStartedWidgets(): DashboardWidgetProps[] { description: t`Getting started with InvenTree`, minWidth: 5, minHeight: 4, + icon: 'info', render: () => }, { @@ -223,6 +255,7 @@ function BuiltinGettingStartedWidgets(): DashboardWidgetProps[] { description: t`The latest news from InvenTree`, minWidth: 5, minHeight: 4, + icon: 'news', render: () => } ]; @@ -243,6 +276,7 @@ function BuiltinActionWidgets(): DashboardWidgetProps[] { export default function DashboardWidgetLibrary(): DashboardWidgetProps[] { return [ ...BuiltinQueryCountWidgets(), + ...BuiltinHistoryWidgets(), ...BuiltinGettingStartedWidgets(), ...BuiltinSettingsWidgets(), ...BuiltinActionWidgets() diff --git a/src/frontend/src/components/dashboard/widgets/OrderHistoryWidget.tsx b/src/frontend/src/components/dashboard/widgets/OrderHistoryWidget.tsx new file mode 100644 index 0000000000..0823a720c6 --- /dev/null +++ b/src/frontend/src/components/dashboard/widgets/OrderHistoryWidget.tsx @@ -0,0 +1,131 @@ +import { ModelInformationDict } from '@lib/enums/ModelInformation'; +import type { ModelType } from '@lib/enums/ModelType'; +import { apiUrl } from '@lib/functions/Api'; +import { StylishText } from '@lib/index'; +import { t } from '@lingui/core/macro'; +import { BarChart } from '@mantine/charts'; +import { Stack } from '@mantine/core'; +import { useDocumentVisibility } from '@mantine/hooks'; +import { useQuery } from '@tanstack/react-query'; +import dayjs from 'dayjs'; +import { useMemo } from 'react'; +import { useApi } from '../../../contexts/ApiContext'; +import { useUserState } from '../../../states/UserState'; +import type { DashboardWidgetProps } from '../DashboardWidget'; + +function OrderHistoryComponent({ + modelType, + title +}: { + modelType: ModelType; + title: string; +}) { + const modelInfo = useMemo(() => { + return ModelInformationDict[modelType]; + }, [modelType]); + + const url = useMemo(() => { + return apiUrl(modelInfo.api_endpoint); + }, [modelInfo]); + + const api = useApi(); + const visibility = useDocumentVisibility(); + + const endDate = dayjs().add(1, 'day').format('YYYY-MM-DD'); + const startDate = dayjs() + .subtract(12, 'month') + .subtract(1, 'day') + .format('YYYY-MM-DD'); + + const params = { + completed_after: startDate, + completed_before: endDate + }; + + const query = useQuery({ + queryKey: ['dashboard-order-summary', modelType, params, visibility], + enabled: visibility === 'visible', + refetchOnWindowFocus: true, + refetchOnMount: true, + refetchInterval: 10 * 60 * 1000, // 10 minute refetch interval + staleTime: 5 * 60 * 1000, // 5 minute stale time + queryFn: () => { + if (visibility !== 'visible') { + return []; + } + + return api.get(url, { params }).then((res) => { + return res.data ?? []; + }); + } + }); + + const chartData = useMemo(() => { + const months = Array.from({ length: 13 }, (_, i) => ({ + month: dayjs() + .subtract(12 - i, 'month') + .format('MMM YY'), + count: 0 + })); + + for (const order of query.data || []) { + // Build orders use `completion_date`; all other order types use `complete_date` + const dateStr = + order.complete_date ?? order.completion_date ?? order.shipment_date; + if (!dateStr) continue; + const label = dayjs(dateStr).format('MMM YY'); + const entry = months.find((m) => m.month === label); + if (entry) entry.count++; + } + + return months; + }, [query.data]); + + return ( + + {title} + + + ); +} + +/** + * Display a simple chart of the number of completed orders per month, for the last 12 months. + */ +export default function OrderHistoryWidget({ + modelType +}: { + modelType: ModelType; +}): DashboardWidgetProps { + const user = useUserState(); + + const modelInfo = useMemo(() => { + return ModelInformationDict[modelType]; + }, [modelType]); + + // Extract translated model labels + const models = modelInfo.label_multiple(); + + return { + label: `${modelType}-history`, + title: t`Completed ${models}`, + description: t`Display number of completed ${models} per month`, + minHeight: 2, + minWidth: 3, + modelType: modelType, + icon: 'chart_bar', + visible: () => user.hasViewPermission(modelType), + render: () => ( + + ) + }; +} diff --git a/src/frontend/src/components/dashboard/widgets/QueryCountDashboardWidget.tsx b/src/frontend/src/components/dashboard/widgets/QueryCountDashboardWidget.tsx index 5e6b0afc0b..bee2c7b431 100644 --- a/src/frontend/src/components/dashboard/widgets/QueryCountDashboardWidget.tsx +++ b/src/frontend/src/components/dashboard/widgets/QueryCountDashboardWidget.tsx @@ -129,6 +129,7 @@ export default function QueryCountDashboardWidget({ title, description, modelType, + icon, enabled = true, params }: { @@ -136,6 +137,7 @@ export default function QueryCountDashboardWidget({ title: string; description: string; modelType: ModelType; + icon?: keyof InvenTreeIconType; enabled?: boolean; params: any; }): DashboardWidgetProps { @@ -147,6 +149,7 @@ export default function QueryCountDashboardWidget({ modelType: modelType, minWidth: 2, minHeight: 1, + icon: icon, render: () => ( ) diff --git a/src/frontend/src/components/dashboard/widgets/StocktakeDashboardWidget.tsx b/src/frontend/src/components/dashboard/widgets/StocktakeDashboardWidget.tsx index 03e94af071..b4e3ae9c60 100644 --- a/src/frontend/src/components/dashboard/widgets/StocktakeDashboardWidget.tsx +++ b/src/frontend/src/components/dashboard/widgets/StocktakeDashboardWidget.tsx @@ -66,6 +66,7 @@ export default function StocktakeDashboardWidget(): DashboardWidgetProps { minHeight: 1, minWidth: 2, render: () => , + icon: 'stocktake', enabled: user.hasAddRole(UserRoles.part) }; } diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index 7531e9b927..df6c56e5ad 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -20,7 +20,7 @@ import { type SubmitHandler, useForm } from 'react-hook-form'; -import { type NavigateFunction, useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import { Boundary } from '@lib/components/Boundary'; import { isTrue } from '@lib/functions/Conversion'; @@ -176,14 +176,15 @@ export function ApiForm({ props.onKeepOpenChange?.(v); }; - // Accessor for the navigation function (which is used to redirect the user) - let navigate: NavigateFunction | null = null; + let navigate = props.navigate || null; - try { - navigate = useNavigate(); - } catch (_error) { - // Note: If we launch a form within a plugin context, useNavigate() may not be available - navigate = null; + if (!navigate) { + try { + navigate = useNavigate(); + } catch (_error) { + // Note: If we launch a form within a plugin context, useNavigate() may not be available + navigate = null; + } } const [fields, setFields] = useState( @@ -658,6 +659,7 @@ export function ApiForm({ definition={field} control={form.control} url={url} + navigate={navigate} setFields={setFields} onKeyDown={(value) => { if ( diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index cd53a45f1d..32acc3bdad 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -6,6 +6,7 @@ import { type Control, type FieldValues, useController } from 'react-hook-form'; import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms'; import { IconFileUpload } from '@tabler/icons-react'; +import type { NavigateFunction } from 'react-router-dom'; import DateTimeField from '../DateTimeField'; import { BooleanField } from './BooleanField'; import { ChoiceField } from './ChoiceField'; @@ -26,6 +27,7 @@ export function ApiFormField({ definition, control, hideLabels, + navigate, url, setFields, onKeyDown @@ -34,6 +36,7 @@ export function ApiFormField({ definition: ApiFormFieldType; control: Control; hideLabels?: boolean; + navigate?: NavigateFunction | null; url?: string; setFields?: React.Dispatch>; onKeyDown?: (value: any) => void; @@ -119,9 +122,10 @@ export function ApiFormField({ case 'related field': return ( ); case 'email': diff --git a/src/frontend/src/components/forms/fields/RelatedModelField.tsx b/src/frontend/src/components/forms/fields/RelatedModelField.tsx index 70e427549e..bb88471c0c 100644 --- a/src/frontend/src/components/forms/fields/RelatedModelField.tsx +++ b/src/frontend/src/components/forms/fields/RelatedModelField.tsx @@ -32,6 +32,7 @@ import { import { apiUrl } from '@lib/functions/Api'; import type { ApiFormFieldType } from '@lib/types/Forms'; import { IconPlus } from '@tabler/icons-react'; +import type { NavigateFunction } from 'react-router-dom'; import { useApi } from '../../../contexts/ApiContext'; import { useCreateApiFormModal } from '../../../hooks/UseForm'; import { @@ -50,11 +51,13 @@ export function RelatedModelField({ controller, fieldName, definition, + navigate, limit = 10 }: Readonly<{ controller: UseControllerReturn; definition: ApiFormFieldType; fieldName: string; + navigate?: NavigateFunction | null; limit?: number; }>) { const api = useApi(); diff --git a/src/frontend/src/components/render/Instance.tsx b/src/frontend/src/components/render/Instance.tsx index 150c45034e..258f800c58 100644 --- a/src/frontend/src/components/render/Instance.tsx +++ b/src/frontend/src/components/render/Instance.tsx @@ -28,9 +28,13 @@ import type { } from '@lib/types/Rendering'; export type { InstanceRenderInterface } from '@lib/types/Rendering'; -import { getBaseUrl, navigateToLink, shortenString } from '@lib/index'; +import { + getBaseUrl, + getDetailUrl, + navigateToLink, + shortenString +} from '@lib/index'; import { IconLink } from '@tabler/icons-react'; -import { useNavigate } from 'react-router-dom'; import { useApi } from '../../contexts/ApiContext'; import { usePluginState } from '../../states/PluginState'; import { useUserSettingsState } from '../../states/SettingsStates'; @@ -132,7 +136,6 @@ export function RenderInstance(props: RenderInstanceProps): ReactNode { props.custom_model ?? props.model ?? '' ); - const navigate = useNavigate(); const userSettings = useUserSettingsState(); // Extract model information from the defined model type @@ -170,12 +173,12 @@ export function RenderInstance(props: RenderInstanceProps): ReactNode { }, [modelInfo, props.instance]); const detailUrl = useMemo(() => { - if (!modelInfo || !modelId || !modelInfo.url_detail) { + if (!props.model) { return undefined; } - return modelInfo.url_detail.replace(':pk', modelId.toString()); - }, [modelInfo, modelId]); + return getDetailUrl(props.model, modelId, true); + }, [props.model]); return ( navigateToLink(detailUrl, navigate, event)} + onClick={(event) => { + if (props.navigate) { + navigateToLink(detailUrl, props.navigate, event); + } + }} > diff --git a/src/frontend/src/functions/icons.tsx b/src/frontend/src/functions/icons.tsx index e169f89e79..bdc1826e89 100644 --- a/src/frontend/src/functions/icons.tsx +++ b/src/frontend/src/functions/icons.tsx @@ -15,10 +15,13 @@ import { IconCalendar, IconCalendarCheck, IconCalendarDot, + IconCalendarExclamation, IconCalendarStats, IconCalendarTime, IconCalendarX, IconCancel, + IconChartBar, + IconChartLine, IconCheck, IconCircleCheck, IconCircleDashedCheck, @@ -63,6 +66,7 @@ import { IconMapPin, IconMapPinHeart, IconMinusVertical, + IconNews, IconNotes, IconNumber123, IconNumbers, @@ -158,6 +162,7 @@ const icons: InvenTreeIconType = { transfer_orders: IconTransfer, sales_orders: IconTruckDelivery, scheduling: IconCalendarStats, + overdue: IconCalendarExclamation, scrap: IconCircleX, shipment: IconCubeSend, test_templates: IconTestPipe, @@ -267,7 +272,11 @@ const icons: InvenTreeIconType = { plugin: IconPlug, history: IconHistory, dashboard: IconLayoutDashboard, - search: IconSearch + search: IconSearch, + + chart_bar: IconChartBar, + chart_line: IconChartLine, + news: IconNews }; /** diff --git a/src/frontend/src/tables/settings/BarcodeScanHistoryTable.tsx b/src/frontend/src/tables/settings/BarcodeScanHistoryTable.tsx index 26c04bf547..80b06490f6 100644 --- a/src/frontend/src/tables/settings/BarcodeScanHistoryTable.tsx +++ b/src/frontend/src/tables/settings/BarcodeScanHistoryTable.tsx @@ -258,23 +258,28 @@ export default function BarcodeScanHistoryTable() { icon={} title={t`Logging Disabled`} > - {t`Barcode logging is not enabled`} + + {t`Barcode logging is not enabled.`}{' '} + {t`No barcode scan history will be recorded.`} + )} - { - setSelectedResult(row); - open(); - } - }} - /> + {globalSettings.isSet('BARCODE_STORE_RESULTS') && ( + { + setSelectedResult(row); + open(); + } + }} + /> + )} ); diff --git a/src/frontend/tests/pages/pui_stock.spec.ts b/src/frontend/tests/pages/pui_stock.spec.ts index 9f9c345eeb..c9d5699d1c 100644 --- a/src/frontend/tests/pages/pui_stock.spec.ts +++ b/src/frontend/tests/pages/pui_stock.spec.ts @@ -168,9 +168,14 @@ test('Stock - Filters', async ({ browser }) => { // Filter by custom status code await clearTableFilters(page); await setTableChoiceFilter(page, 'Status', 'Incoming goods inspection'); - await page.getByText('1 - 8 / 8').waitFor(); await page.getByRole('cell', { name: '1551AGY' }).first().waitFor(); + + await page.getByPlaceholder('Search').clear(); + await page.getByPlaceholder('Search').fill('blue'); await page.getByRole('cell', { name: 'widget.blue' }).first().waitFor(); + + await page.getByPlaceholder('Search').clear(); + await page.getByPlaceholder('Search').fill('002.01'); await page.getByRole('cell', { name: '002.01-PCBA' }).first().waitFor(); await clearTableFilters(page); @@ -324,10 +329,20 @@ test('Stock - Stock Actions', async ({ browser }) => { await page.getByRole('option', { name: status }).click(); }; + // Duplicate the stock item first - prevent impacting other tests + await page + .getByRole('button', { name: 'action-menu-stock-item-actions' }) + .click(); + await page + .getByRole('menuitem', { name: 'action-menu-stock-item-actions-duplicate' }) + .click(); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.waitForLoadState('networkidle'); + // Check for required values await page.getByText('Status', { exact: true }).waitFor(); await page.getByText('Custom Status', { exact: true }).waitFor(); - await page.getByText('Attention needed').waitFor(); + await page.getByText('Attention needed').first().waitFor(); await page .getByLabel('Stock Details') .getByText('Incoming goods inspection') @@ -705,6 +720,7 @@ test('Transfer Order - Allocate and Transfer', async ({ browser }) => { await page.getByRole('button', { name: 'Issue Order' }).click(); await page.getByRole('button', { name: 'Submit' }).click(); await page.getByText('Order issued').waitFor(); + await page.getByText('Issued', { exact: true }).first().waitFor(); await loadTab(page, 'Line Items'); diff --git a/src/frontend/tests/pui_forms.spec.ts b/src/frontend/tests/pui_forms.spec.ts index c86a490720..a812d42f05 100644 --- a/src/frontend/tests/pui_forms.spec.ts +++ b/src/frontend/tests/pui_forms.spec.ts @@ -1,9 +1,44 @@ /** Unit tests for form validation, rendering, etc */ import { expect, test } from 'playwright/test'; +import { createApi } from './api'; import { stevenuser } from './defaults'; import { navigate } from './helpers'; import { doCachedLogin } from './login'; +// Test hover form action in related fields +test('Forms - Hover', async ({ browser }) => { + const page = await doCachedLogin(browser, { + user: stevenuser, + url: 'purchasing/index/purchaseorders' + }); + + // Patch user settings to ensure we can see "extra model info" on hover + const api = await createApi({ + username: stevenuser.username, + password: stevenuser.testcred + }); + + const response = await api.patch('settings/user/SHOW_EXTRA_MODEL_INFO/', { + data: { + value: 'true' + } + }); + + expect(response.status()).toBe(200); + + await page + .getByRole('button', { name: 'action-button-add-purchase-' }) + .click(); + await page.getByLabel('related-field-supplier').fill('mou'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(250); + await page.getByRole('option', { name: 'Mouser Electronics' }).hover(); + + // Check for hover info + await page.getByText('Company[ID: 2]').waitFor(); + await page.getByRole('link', { name: 'View details' }).waitFor(); +}); + test('Forms - Stock Item Validation', async ({ browser }) => { const page = await doCachedLogin(browser, { user: stevenuser, diff --git a/src/frontend/tests/pui_plugins.spec.ts b/src/frontend/tests/pui_plugins.spec.ts index d42187ec85..a4bc753303 100644 --- a/src/frontend/tests/pui_plugins.spec.ts +++ b/src/frontend/tests/pui_plugins.spec.ts @@ -253,3 +253,60 @@ test('Plugins - Locate Item', async ({ browser }) => { await page.getByRole('button', { name: 'Submit' }).click(); await page.getByText('Item location requested').waitFor(); }); + +/** + * Perform a full run through of validating a UI plugin: + * + * - Activate the plugin + * - Check that a custom panel is added + * - Check that expected translated text is added + * - Check that expected UI elements can be operated + * + * Note: This tests assumes that: + * + * - The inventree-plugin-creator tool has been installed + * - The default plugin has been created, build and installed + */ +test('Plugins - Creator', async ({ browser }) => { + const page = await doCachedLogin(browser, { + user: adminuser + }); + + // Ensure that the SampleIntegration plugin is enabled + await setPluginState({ + plugin: 'my-custom-plugin', + state: true + }); + + // Allow time for installation of plugin static files, etc + await page.waitForTimeout(2500); + + await navigate(page, 'part/106/details/'); + await loadTab(page, 'My Custom Plugin'); + + // Check for correctly translated code + await page.getByText('Translated text, provided by custom code!').waitFor(); + + // Check for incrementing counter value + for (let i = 0; i < 5; i++) { + await page.getByText(`Counter: ${i}`).waitFor(); + await page.getByRole('button', { name: 'Increment Counter' }).click(); + } + + // Edit part form + await page.getByRole('button', { name: 'Edit Part' }).click(); + await page + .getByText('This is a custom form launched from within a plugin!') + .waitFor(); + await page + .getByRole('textbox', { name: 'text-field-name' }) + .fill('New part name'); + await page.getByRole('button', { name: 'Cancel' }).click(); + + // View a custom table + await page.getByRole('button', { name: 'Custom Table Example' }).click(); + await page.getByRole('textbox', { name: 'table-search-input' }).fill('red'); + await page.waitForLoadState('networkidle'); + await page.getByRole('cell', { name: 'Red Square Table' }).first().waitFor(); +}); +// Ensure that the sample full run plugin is enabled diff --git a/src/frontend/tests/pui_settings.spec.ts b/src/frontend/tests/pui_settings.spec.ts index c594f003f6..4cfa94632c 100644 --- a/src/frontend/tests/pui_settings.spec.ts +++ b/src/frontend/tests/pui_settings.spec.ts @@ -385,10 +385,20 @@ test('Settings - Admin - Barcode History', async ({ browser }) => { await page.waitForTimeout(500); - // Barcode history is displayed in table - barcodes.forEach(async (barcode) => { + const checkBarcode = async (barcode: string) => { + await page.getByRole('textbox', { name: 'table-search-input' }).clear(); + await page + .getByRole('textbox', { name: 'table-search-input' }) + .fill(barcode); + await page.waitForLoadState('networkidle'); await page.getByText(barcode).first().waitFor(); - }); + }; + + for (const barcode of barcodes) { + await checkBarcode(barcode); + } + + await page.waitForTimeout(2500); }); test('Settings - Admin - Parameter', async ({ browser }) => {