Merge commit 'd38af61ba2fb3c87143da57ddd92bc4845f85a7c' into block-notes

This commit is contained in:
Oliver Walters 2026-06-01 11:55:47 +00:00
commit 8bf2152c93
24 changed files with 395 additions and 53 deletions

View File

@ -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: |

View File

@ -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

View File

@ -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'),

View File

@ -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)

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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<string, ApiFormFieldType>;
* @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;
}

View File

@ -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;
}

View File

@ -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);
}}
>
<IconLayoutGridAdd />
<InvenTreeIcon icon={widget.icon ?? 'dashboard'} />
</ActionIcon>
</Tooltip>
</Table.Td>

View File

@ -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: () => <GetStartedWidget />
},
{
@ -223,6 +255,7 @@ function BuiltinGettingStartedWidgets(): DashboardWidgetProps[] {
description: t`The latest news from InvenTree`,
minWidth: 5,
minHeight: 4,
icon: 'news',
render: () => <NewsWidget />
}
];
@ -243,6 +276,7 @@ function BuiltinActionWidgets(): DashboardWidgetProps[] {
export default function DashboardWidgetLibrary(): DashboardWidgetProps[] {
return [
...BuiltinQueryCountWidgets(),
...BuiltinHistoryWidgets(),
...BuiltinGettingStartedWidgets(),
...BuiltinSettingsWidgets(),
...BuiltinActionWidgets()

View File

@ -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 (
<Stack gap='xs'>
<StylishText size='md'>{title}</StylishText>
<BarChart
h={200}
data={chartData}
dataKey='month'
series={[{ name: 'count', label: t`Completed`, color: 'blue.6' }]}
withYAxis={false}
/>
</Stack>
);
}
/**
* 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: () => (
<OrderHistoryComponent
modelType={modelType}
title={t`Completed ${models}`}
/>
)
};
}

View File

@ -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: () => (
<QueryCountWidget modelType={modelType} title={title} params={params} />
)

View File

@ -66,6 +66,7 @@ export default function StocktakeDashboardWidget(): DashboardWidgetProps {
minHeight: 1,
minWidth: 2,
render: () => <StocktakeWidget />,
icon: 'stocktake',
enabled: user.hasAddRole(UserRoles.part)
};
}

View File

@ -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<ApiFormFieldSet>(
@ -658,6 +659,7 @@ export function ApiForm({
definition={field}
control={form.control}
url={url}
navigate={navigate}
setFields={setFields}
onKeyDown={(value) => {
if (

View File

@ -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<FieldValues, any>;
hideLabels?: boolean;
navigate?: NavigateFunction | null;
url?: string;
setFields?: React.Dispatch<React.SetStateAction<ApiFormFieldSet>>;
onKeyDown?: (value: any) => void;
@ -119,9 +122,10 @@ export function ApiFormField({
case 'related field':
return (
<RelatedModelField
controller={controller}
definition={fieldDefinition}
controller={controller}
fieldName={fieldName}
navigate={navigate}
/>
);
case 'email':

View File

@ -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<FieldValues, any>;
definition: ApiFormFieldType;
fieldName: string;
navigate?: NavigateFunction | null;
limit?: number;
}>) {
const api = useApi();

View File

@ -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 (
<HoverCard
@ -207,7 +210,11 @@ export function RenderInstance(props: RenderInstanceProps): ReactNode {
<Anchor
href={detailUrl}
target='_blank'
onClick={(event) => navigateToLink(detailUrl, navigate, event)}
onClick={(event) => {
if (props.navigate) {
navigateToLink(detailUrl, props.navigate, event);
}
}}
>
<Group gap='xs' wrap='nowrap'>
<ActionIcon variant='transparent' size='xs'>

View File

@ -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
};
/**

View File

@ -258,23 +258,28 @@ export default function BarcodeScanHistoryTable() {
icon={<IconExclamationCircle />}
title={t`Logging Disabled`}
>
<Text>{t`Barcode logging is not enabled`}</Text>
<Text>
{t`Barcode logging is not enabled.`}{' '}
{t`No barcode scan history will be recorded.`}
</Text>
</Alert>
)}
<InvenTreeTable
url={apiUrl(ApiEndpoints.barcode_history)}
tableState={table}
columns={tableColumns}
props={{
tableFilters: filters,
enableBulkDelete: canDelete,
rowActions: rowActions,
onRowClick: (row) => {
setSelectedResult(row);
open();
}
}}
/>
{globalSettings.isSet('BARCODE_STORE_RESULTS') && (
<InvenTreeTable
url={apiUrl(ApiEndpoints.barcode_history)}
tableState={table}
columns={tableColumns}
props={{
tableFilters: filters,
enableBulkDelete: canDelete,
rowActions: rowActions,
onRowClick: (row) => {
setSelectedResult(row);
open();
}
}}
/>
)}
</Stack>
</>
);

View File

@ -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');

View File

@ -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,

View File

@ -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

View File

@ -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 }) => {