[UI] Context details for plugin actions (#13172)

* [UI] Context details for plugin actions

Adds additional target instance context when registering custom UI actions

* Add CHANGELOG entry

* Improve test
This commit is contained in:
Oliver authored and GitHub committed 2026-11-03 17:22:26 +11:00
1 parent 461186bee9
commit 62d363a99d
26 files changed
+81 -13

No files matched your search

+2
View File
@@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- [#13172](https://github.com/inventree/InvenTree/pull/13172) passes the `target_model` and `target_id` of the current page to plugin primary actions, via the `context` argument of `get_ui_primary_actions`.
### Removed
- [#13101](https://github.com/inventree/InvenTree/pull/13101) removes the StockTracking model and its associated API endpoints. Stock history tracking is now handled by the global audit log.
+8
View File
@@ -187,6 +187,14 @@ The `get_ui_template_previews` feature type can be used to provide custom templa
The `get_ui_primary_actions` method can be used to provide custom primary action, which are rendered in the header of the page, next to the title/name and any status indicators. These primary actions are typically used to provide quick access to common actions related to the current page.
The `context` dictionary provided to this method contains the following keys:
| Key | Description |
| --- | --- |
| `location` | The URL path of the current page |
| `target_model` | The model type of the current page (e.g. `part`), or the page key for index pages (e.g. `purchasing`). Not provided for all pages. |
| `target_id` | The primary key of the instance displayed on the current page. Not provided for all pages. |
::: plugin.base.ui.mixins.UserInterfaceMixin.get_ui_primary_actions
options:
show_bases: False
@@ -217,12 +217,17 @@ class UserInterfaceMixin:
request: HTTPRequest object (including user information)
context: Additional context data provided by the UI (query parameters)
The context data provided by the UI includes:
- location: The URL path of the current page
- target_model: The model type (or page key) of the current page, e.g. 'part' or 'purchasing' (if available)
- target_id: The primary key of the instance displayed on the current page (if available)
Returns:
list: A list of custom primary action buttons to be injected into the UI
"""
# Sample code to render conditional primary action button based on context
# items = []
# if self.my_assert_function(context):
# if context.get('target_model') == 'part' and context.get('target_id'):
# items.append({
# 'key': 'sample-primary-action',
# 'title': 'Sample Primary Action',
+13 -3
View File
@@ -1,6 +1,7 @@
import { Group, Paper, Space, Stack, Text } from '@mantine/core';
import { StylishText } from '@lib/components/StylishText';
import type { ModelType, PluginPanelKey } from '@lib/enums/ModelType';
import { useInvenTreeHotkeys } from '@lib/functions/Events';
import { shortenString } from '@lib/functions/String';
import { t } from '@lingui/core/macro';
@@ -30,13 +31,16 @@ interface PageDetailInterface {
actions?: ReactNode[];
editAction?: () => void;
editEnabled?: boolean;
model?: ModelType | PluginPanelKey;
id?: number | string | null;
}
/**
* Construct a "standard" page detail for common display between pages.
*
* @param breadcrumbs - The breadcrumbs to display (optional)
* @param
* @param model - The model type (or plugin panel key) for this page, passed to plugin primary actions (optional)
* @param id - The primary key of the instance displayed on this page, passed to plugin primary actions (optional)
*/
export function PageDetail({
title,
@@ -50,7 +54,9 @@ export function PageDetail({
breadcrumbAction,
actions,
editAction,
editEnabled
editEnabled,
model,
id
}: Readonly<PageDetailInterface>) {
const userSettings = useUserSettingsState();
const navigate = useNavigate();
@@ -102,7 +108,11 @@ export function PageDetail({
const extraActions = usePluginUIFeature<PrimaryActionUIFeature>({
featureType: 'primary_action',
context: { location: location.pathname }
context: {
location: location.pathname,
target_model: model,
target_id: id
}
});
// action caching
@@ -1,4 +1,4 @@
import type { ModelType } from '@lib/enums/ModelType';
import type { ModelType, PluginPanelKey } from '@lib/enums/ModelType';
import type { InvenTreeIconType } from '@lib/types/Icons';
import type { InvenTreePluginContext } from '@lib/types/Plugins';
import type { TemplateI } from '../../tables/settings/TemplateTable';
@@ -87,7 +87,11 @@ export type NavigationUIFeature = {
export type PrimaryActionUIFeature = {
featureType: 'primary_action';
requestContext: {};
requestContext: {
location: string;
target_model?: ModelType | PluginPanelKey;
target_id?: number | string | null;
};
responseOptions: PluginUIFeature;
featureContext: {};
featureReturnType: undefined;
@@ -605,6 +605,8 @@ export default function BuildDetail() {
<InstanceDetail query={instanceQuery} requiredRole={UserRoles.build}>
<Stack gap='xs'>
<PageDetail
model={ModelType.build}
id={build.pk}
title={`${t`Build Order`}: ${build.reference}`}
subtitle={`${build.quantity} x ${build.part_detail?.full_name}`}
badges={buildBadges}
+5 -1
View File
@@ -118,7 +118,11 @@ export default function BuildIndex() {
return (
<Stack>
<PageDetail title={t`Manufacturing`} actions={[]} />
<PageDetail
model={PluginPanelKey.manufacturing}
title={t`Manufacturing`}
actions={[]}
/>
<PanelGroup
pageKey='build-index'
panels={panels}
@@ -305,6 +305,8 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
>
<Stack gap='xs'>
<PageDetail
model={ModelType.company}
id={company.pk}
title={`${t`Company`}: ${company.name}`}
subtitle={company.description}
actions={companyActions}
@@ -221,6 +221,8 @@ export default function ManufacturerPartDetail() {
>
<Stack gap='xs'>
<PageDetail
model={ModelType.manufacturerpart}
id={manufacturerPart.pk}
title={t`Manufacturer Part`}
subtitle={`${manufacturerPart.MPN} - ${manufacturerPart.part_detail?.name}`}
breadcrumbs={breadcrumbs}
@@ -275,6 +275,8 @@ export default function SupplierPartDetail() {
>
<Stack gap='xs'>
<PageDetail
model={ModelType.supplierpart}
id={supplierPart.pk}
title={t`Supplier Part`}
subtitle={`${supplierPart.SKU} - ${supplierPart?.part_detail?.name}`}
breadcrumbs={breadcrumbs}
+1 -1
View File
@@ -44,7 +44,7 @@ export default function CoreIndex() {
return (
<Stack>
<PageDetail title={t`System Overview`} />
<PageDetail model={PluginPanelKey.core} title={t`System Overview`} />
<PanelGroup
pageKey='core-index'
panels={panels}
@@ -73,6 +73,8 @@ export default function GroupDetail() {
<InstanceDetail query={instanceQuery}>
<Stack gap='xs'>
<PageDetail
model={ModelType.group}
id={instance.pk}
title={`${t`Group`}: ${instance.name}`}
imageUrl={instance?.image}
breadcrumbs={[
@@ -216,6 +216,8 @@ export default function UserDetail() {
<InstanceDetail query={instanceQuery}>
<Stack gap='xs'>
<PageDetail
model={ModelType.user}
id={instance.pk}
title={`${t`User`}: ${instance.username}`}
imageUrl={instance?.image}
badges={userBadges}
@@ -332,6 +332,8 @@ export default function CategoryDetail() {
>
<Stack gap='xs'>
<PageDetail
model={ModelType.partcategory}
id={category.pk ?? null}
title={(category?.name ?? id) ? t`Part Category` : t`Parts`}
subtitle={category?.description}
icon={category?.icon && <ApiIcon name={category?.icon} />}
@@ -834,6 +834,8 @@ export default function PartDetail() {
>
<Stack gap='xs'>
<PageDetail
model={ModelType.part}
id={part.pk}
title={`${t`Part`}: ${part.full_name}`}
icon={
lockingEnabled ? (
@@ -384,6 +384,8 @@ export default function PurchaseOrderDetail() {
>
<Stack gap='xs'>
<PageDetail
model={ModelType.purchaseorder}
id={order.pk}
title={`${t`Purchase Order`}: ${order.reference}`}
subtitle={subtitle}
imageUrl={order.supplier_detail?.image}
@@ -234,7 +234,7 @@ export default function PurchasingIndex() {
return (
<Stack>
<PageDetail title={t`Purchasing`} />
<PageDetail model={PluginPanelKey.purchasing} title={t`Purchasing`} />
<PanelGroup
pageKey='purchasing-index'
panels={panels}
@@ -364,6 +364,8 @@ export default function ReturnOrderDetail() {
>
<Stack gap='xs'>
<PageDetail
model={ModelType.returnorder}
id={order.pk}
title={`${t`Return Order`}: ${order.reference}`}
subtitle={subtitle}
imageUrl={order.customer_detail?.image}
+1 -1
View File
@@ -211,7 +211,7 @@ export default function SalesIndex() {
return (
<Stack>
<PageDetail title={t`Sales`} />
<PageDetail model={PluginPanelKey.sales} title={t`Sales`} />
<PanelGroup
pageKey='sales-index'
panels={panels}
@@ -434,6 +434,8 @@ export default function SalesOrderDetail() {
>
<Stack gap='xs'>
<PageDetail
model={ModelType.salesorder}
id={order.pk}
title={`${t`Sales Order`}: ${order.reference}`}
subtitle={subtitle}
imageUrl={order.customer_detail?.image}
@@ -289,6 +289,8 @@ export default function SalesOrderShipmentDetail() {
>
<Stack gap='xs'>
<PageDetail
model={ModelType.salesordershipment}
id={shipment.pk}
title={`${t`Sales Order Shipment`}: ${shipment.reference}`}
subtitle={`${t`Sales Order`}: ${shipment.order_detail?.reference}`}
breadcrumbs={[
@@ -441,6 +441,8 @@ export default function Stock() {
>
<Stack>
<PageDetail
model={ModelType.stocklocation}
id={location?.pk}
title={(location?.name ?? id) ? t`Stock Location` : t`Stock`}
subtitle={location?.description}
icon={location?.icon && <ApiIcon name={location?.icon} />}
@@ -747,6 +747,8 @@ export default function StockDetail() {
>
<Stack>
<PageDetail
model={ModelType.stockitem}
id={stockitem.pk}
title={t`Stock Item`}
subtitle={stockitem.part_detail?.full_name}
imageUrl={stockitem.part_detail?.thumbnail}
@@ -382,6 +382,8 @@ export default function TransferOrderDetail() {
>
<Stack gap='xs'>
<PageDetail
model={ModelType.transferorder}
id={order.pk}
title={`${t`Transfer Order`}: ${order.reference}`}
subtitle={subtitle}
// What should be the Transfer Order image?
+3 -2
View File
@@ -143,13 +143,14 @@ export const activateCalendarView = async (page: Page) => {
export const tableSearch = async (page: Page, query: string) => {
await page.getByLabel('table-search-input').fill(query);
await page.waitForTimeout(300);
// Wait for longer than the SearchInput debounce (500ms)
await page.waitForTimeout(600);
await page.waitForLoadState('networkidle');
};
export const clearTableSearch = async (page: Page) => {
await page.getByLabel('table-search-input').clear();
await page.waitForTimeout(300);
await page.waitForTimeout(600);
await page.waitForLoadState('networkidle');
};
+5 -1
View File
@@ -423,7 +423,11 @@ test('Sales Orders - Shipment Row Actions', async ({ browser }) => {
// View the parent sales order from the global shipments table
await navigate(page, 'sales/index/shipments');
await clearTableFilters(page);
await page.getByLabel('table-search-input').fill(shipmentReference);
await tableSearch(page, shipmentReference);
// Wait for the (debounced) search to be applied before opening the row menu,
// otherwise the table re-renders underneath the open menu
await expect(page.locator('tbody tr')).toHaveCount(1);
await clickOnRowMenu(page.getByRole('cell', { name: shipmentReference }));
await page.getByRole('menuitem', { name: 'View Sales Order' }).click();
await page.getByText('Sales Order: SO0007').first().waitFor();