mirror of
https://github.com/inventree/InvenTree.git
synced 2026-11-10 02:47:49 +00:00
[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:
1 parent
461186bee9
commit
62d363a99d
26 files changed
+81
-13
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user