Merge branch 'master' into matmair/issue11460

This commit is contained in:
Matthias Mair 2026-06-09 20:47:03 +02:00 committed by GitHub
commit 60467c7f39
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
68 changed files with 957 additions and 324 deletions

View File

@ -138,7 +138,7 @@ jobs:
- name: Install dependencies
run: invoke int.frontend-compile --extract
- name: Cache Playwright browsers
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # pin@v4.3.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # pin@v5.0.5
id: playwright-cache
with:
path: ~/.cache/ms-playwright
@ -217,7 +217,7 @@ jobs:
- name: Install dependencies
run: invoke int.frontend-compile --extract
- name: Cache Playwright browsers
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # pin@v4.3.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # pin@v5.0.5
id: playwright-cache
with:
path: ~/.cache/ms-playwright

View File

@ -222,7 +222,7 @@ jobs:
echo "Downloaded api.yaml"
- name: Running OpenAPI Spec diff action
id: breaking_changes
uses: oasdiff/oasdiff-action/diff@50e6a3413e5aa9c3ae4d8393c34745be44288b46 # pin@main
uses: oasdiff/oasdiff-action/diff@f30668f65075c93440bd59ce2de73ce9e78751f4 # pin@main
with:
base: "api.yaml"
revision: "src/backend/InvenTree/schema.yml"
@ -363,7 +363,7 @@ jobs:
pip install .
if: needs.paths-filter.outputs.submit-performance == 'true'
- name: Performance Reporting
uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # pin@v4.15.1
uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # pin@v4.17.0
# check if we are in inventree/inventree - reporting only works in that OIDC context
if: github.repository == 'inventree/InvenTree' && needs.paths-filter.outputs.submit-performance == 'true'
with:
@ -454,7 +454,7 @@ jobs:
env:
node_version: '>=24'
- name: Performance Reporting
uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # pin@v4.15.1
uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # pin@v4.17.0
with:
mode: walltime
run: inv dev.test --pytest

View File

@ -9,11 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Breaking Changes
- [#9604](https://github.com/inventree/InvenTree/pull/9604) - refactors user API endpoint to be less ambiguous
- [#12107](https://github.com/inventree/InvenTree/pull/12107) makes a breaking change to the `SalesOrderStatusGroups` enum, fixing a bug where the "shipped" status was not included in the "active" group. This change may affect any external client applications which make use of the `SalesOrderStatusGroups` enum, as the "shipped" status will now be included in the "active" group instead of the "complete" group. If you are using this enum in an external client application, you will need to update your application to account for this change.
- [#9604](https://github.com/inventree/InvenTree/pull/9604) refactors user API endpoint to be less ambiguous
- [#11893](https://github.com/inventree/InvenTree/pull/11893) bumps Node environment to version 24 LTS - this is only relevant if you build the frontend assets yourself
### Added
- [#12103](https://github.com/inventree/InvenTree/pull/12103) adds column-based filtering to table views in the user interface. This extends the existing table filtering functionality by allowing users to apply filters directly to individual columns.
- [#12093](https://github.com/inventree/InvenTree/pull/12093) adds "read_only" attribute to PluginSetting API endpoint, which indicates whether a particular plugin setting is read-only (i.e. cannot be modified via the API)
- [#12079](https://github.com/inventree/InvenTree/pull/12079) adds the ability to save filter groups for table and calendar views in the user interface. This allows users to save and reuse commonly used filter configurations, improving the usability and efficiency of the interface.
- [#12077](https://github.com/inventree/InvenTree/pull/12077) adds "tags" fields to multiple new model types and a /api/tag/ endpoint for fetching tags. Also adds the ability to filter various model types by tags.

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

@ -158,6 +158,23 @@ Select the "table filters" button to open the filter selection menu
Table filters are saved across browser sessions, allowing users to maintain their preferred filter settings when returning to the particular table view.
#### Column Filters
Many table columns expose an inline filter icon directly in the column header, providing a quick way to filter by that column without opening the full filter drawer. Columns that support filtering display a small filter icon alongside the column title. The icon is highlighted when a filter for that column is currently active, giving an at-a-glance indication of which columns have active filters.
Clicking the icon opens a compact popover anchored to the column header:
{{ image("concepts/ui_table_column_filter_popover.png", "Column Filter Popover") }}
**Single-filter columns** — for columns linked to one filter (e.g. *Active*, *Has IPN*, *Status*), selecting a value immediately applies the filter and the popover closes automatically.
**Range columns** — for columns that represent a range concept (e.g. *Start Date*, *Target Date*, *Creation Date*), the popover stays open and presents multiple controls — for example *before* and *after* date pickers — so both bounds can be set in a single interaction.
Once a filter is active, the popover shows a badge with the current value and a remove button (red ×) instead of the value picker. Clicking the × clears only that column's filter.
!!! info "Column filters and the filter drawer share the same state"
Filters applied via a column popover appear immediately in the filter drawer's active-filter list, and filters added through the drawer are reflected in the column icons. Clearing all filters from the drawer also removes any filters set via column popovers.
#### Saved Filter Groups
Frequently used combinations of filters can be saved as a named *filter group*, allowing them to be quickly recalled later without having to re-add each filter individually.

View File

@ -256,7 +256,6 @@ Each part object has access to a lot of context variables about the part. The fo
| expiry_date | Expiry date of the [StockItem](#stock-item) (optional) |
| stocktake_date | Date of last stocktake for this item |
| stocktake_user | User that performed the most recent stocktake |
| review_needed | Flag if [StockItem](#stock-item) needs review |
| delete_on_deplete | If True, [StockItem](#stock-item) will be deleted when the stock level gets to zero |
| status | Status of this [StockItem](#stock-item) (ref: InvenTree.status_codes.StockStatus) |
| status_label | Textual representation of the status e.g. "OK" |
@ -342,7 +341,6 @@ Each part object has access to a lot of context variables about the part. The fo
| note | Longer form note field |
| base_cost | Base charge added to order independent of quantity e.g. "Reeling Fee" |
| multiple | Multiple that the part is provided in |
| lead_time | Supplier lead time |
| packaging | packaging that the part is supplied in, e.g. "Reel" |
| pretty_name | The IPN, supplier name, supplier SKU and (if not null) manufacturer string joined by `|`. Ex. `P00037 | Company | 000021` |
| unit_pricing | The price for one unit. |

View File

@ -621,7 +621,6 @@ class SupplierPart(
note: Longer form note field
base_cost: Base charge added to order independent of quantity e.g. "Reeling Fee"
multiple: Multiple that the part is provided in
lead_time: Supplier lead time
packaging: packaging that the part is supplied in, e.g. "Reel"
pack_quantity: Quantity of item supplied in a single pack (e.g. 30ml in a single tube)
pack_quantity_native: Pack quantity, converted to "native" units of the referenced part

View File

@ -38,6 +38,14 @@
customer: 5
status: 60 # Returned
- model: order.salesorder
pk: 6
fields:
reference: 'ABC128'
description: "One sales order, please"
customer: 5
status: 30 # Complete
# 1 x R_4K7_0603
- model: order.salesorderlineitem
pk: 1

View File

@ -1191,6 +1191,10 @@ class PurchaseOrder(TotalPriceMixin, Order):
for item in new_items:
item.set_status(status, custom_values=custom_stock_status_values)
# run validation for serialized items plugin.validate_batch_code
item.validate_batch_code()
# run validation for serialized items plugin.validate_model_instance
item.run_plugin_validation()
stock_items.append(item)
else:
@ -1213,6 +1217,13 @@ class PurchaseOrder(TotalPriceMixin, Order):
# Bulk create new stock items
if len(bulk_create_items) > 0:
# bulk_create() bypasses save()/clean() methods, so manual validation is required for each item
for item in bulk_create_items:
# run validation for items plugin.validate_batch_code
item.validate_batch_code()
# run validation for items plugin.validate_model_instance
item.run_plugin_validation()
stock.models.StockItem.objects.bulk_create(
bulk_create_items, batch_size=250
)

View File

@ -63,10 +63,11 @@ class SalesOrderStatusGroups:
SalesOrderStatus.PENDING.value,
SalesOrderStatus.ON_HOLD.value,
SalesOrderStatus.IN_PROGRESS.value,
SalesOrderStatus.SHIPPED.value,
]
# Completed orders
COMPLETE = [SalesOrderStatus.SHIPPED.value, SalesOrderStatus.COMPLETE.value]
COMPLETE = [SalesOrderStatus.COMPLETE.value]
class ReturnOrderStatus(StatusCode):

View File

@ -1458,19 +1458,21 @@ class SalesOrderTest(OrderTest):
def test_so_list(self):
"""Test the SalesOrder list API endpoint."""
# All orders
self.filter({}, 5)
self.filter({}, 6)
# Filter by customer
self.filter({'customer': 4}, 3)
self.filter({'customer': 5}, 2)
self.filter({'customer': 5}, 3)
# Filter by outstanding
self.filter({'outstanding': True}, 3)
self.filter({'outstanding': True}, 4)
self.filter({'outstanding': False}, 2)
# Filter by status
self.filter({'status': SalesOrderStatus.PENDING.value}, 3) # PENDING
self.filter({'status': SalesOrderStatus.SHIPPED.value}, 1) # SHIPPED
self.filter({'status': SalesOrderStatus.PENDING.value}, 3)
self.filter({'status': SalesOrderStatus.SHIPPED.value}, 1)
self.filter({'status': SalesOrderStatus.COMPLETE.value}, 1)
self.filter({'status': SalesOrderStatus.CANCELLED.value}, 0)
self.filter({'status': 99}, 0) # Invalid
# Filter by "reference"
@ -1479,7 +1481,7 @@ class SalesOrderTest(OrderTest):
# Filter by "assigned_to_me"
self.filter({'assigned_to_me': 1}, 0)
self.filter({'assigned_to_me': 0}, 5)
self.filter({'assigned_to_me': 0}, 6)
def test_total_price(self):
"""Unit tests for the 'total_price' field."""
@ -1553,7 +1555,7 @@ class SalesOrderTest(OrderTest):
def test_overdue(self):
"""Test "overdue" status."""
self.filter({'overdue': True}, 0)
self.filter({'overdue': False}, 5)
self.filter({'overdue': False}, 6)
for pk in [1, 2]:
order = models.SalesOrder.objects.get(pk=pk)
@ -1561,7 +1563,7 @@ class SalesOrderTest(OrderTest):
order.save()
self.filter({'overdue': True}, 2)
self.filter({'overdue': False}, 3)
self.filter({'overdue': False}, 4)
def test_so_detail(self):
"""Test the SalesOrder detail endpoint."""

View File

@ -2,6 +2,36 @@
This file contains historical changelog information for the InvenTree UI components library.
### 1.4.6 - June 2026
Adds `useInvenTreeHotkeys` hook that mirrors mantine's `useHotkeys` hook, but adds visibility of the hotkeys to the user interface.
### 1.4.5 - June 2026
Fixes callback signature for `<LocalizedComponent>` to allow for an optional `loadLocale` function to be passed in, which is used to dynamically load locale messages for the plugin.
### 1.4.4 - June 2026
Fixes bundling issues associated with the `InventreeHmrPlugin` plugin function.
### 1.4.3 - June 2026
Expose the `InventreeHmrPlugin` on a different path (`@inventreedb/ui/vite`) to avoid vite bundling issues.
### 1.4.2 - June 2026
Fixes a bug in the `LocalizedComponent` function
### 1.4.1 - June 2026
### HMR Support
Adds support for React Fast Refresh in plugin development. This allows for a much smoother development experience when working on UI plugins, as changes to React components will now trigger a component-level update rather than a full page reload.
### Localized Components
Exposes a new `LocalizedComponent` function, which can be used to create React components that are automatically localized using the InvenTree server's localization system.
### 1.4.0 - May 2026
#### Version Numbering

View File

@ -23,7 +23,7 @@ export function TableColumnSelect({
<Menu.Label>{t`Select Columns`}</Menu.Label>
<Divider />
{columns
.filter((col) => col.switchable ?? true)
.filter((col) => (col.switchable ?? true) && !col.propHidden)
.map((col) => (
<Menu.Item key={col.accessor}>
<Checkbox

View File

@ -1,6 +1,37 @@
import { useHotkeys } from '@mantine/hooks';
import type { HotkeyItemOptions } from '@mantine/hooks';
import { useEffect } from 'react';
import { useLocalLibState } from '..';
// Helper function to cancel event propagation
export function cancelEvent(event: any) {
event?.preventDefault();
event?.stopPropagation();
event?.nativeEvent?.stopImmediatePropagation();
}
export type InvenTreeHotkeyItem = [
string,
string,
(event: KeyboardEvent) => void,
HotkeyItemOptions?
];
export function useInvenTreeHotkeys(hotkeys: InvenTreeHotkeyItem[]) {
// Register the hotkeys using the Mantine hook
useHotkeys(
hotkeys.map(([key, _, handler, options]) => [key, handler, options])
);
// register to helper state to store hotkeys
// This allows us to display the hotkeys in the UI
const keyelems: [string, string][] = hotkeys.map(([key, description]) => [
key,
description
]);
useEffect(() => {
useLocalLibState.getState().addHotkeys(keyelems);
return () =>
useLocalLibState.getState().removeHotkeys(keyelems.map(([key]) => key));
}, []);
}

View File

@ -139,6 +139,8 @@ export {
type TableStateExtraProps
} from './hooks/UseTable';
export { useInvenTreeHotkeys } from './functions/Events';
export {
type DrawerProps,
DetailDrawer,
@ -153,3 +155,9 @@ export {
useStoredTableState
} from './states/StoredTableState';
export { useLocalLibState } from './states/LocalLibState';
// Plugin development utilities and hooks
export {
default as LocalizedComponent,
type LocaleLoader
} from './plugin/LocalizedComponent';

View File

@ -0,0 +1,40 @@
import type { Plugin } from 'vite';
/**
* Vite plugin which enables hot module replacement (HMR) for InvenTree plugin development.
*
* This is for use with the InvenTree plugin creator tool,
* allowing frontend plugin code to be "live reloaded" during development.
*/
export default function InventreeHmrPlugin(): Plugin {
const fileRegex = /\.(js|jsx|ts|tsx)(\?|$)/;
const hmrBlock = [
'',
'// __inventree_hmr_injected__',
'if (import.meta.hot) {',
' import.meta.hot.accept((newModule) => {',
' const key = new URL(import.meta.url).origin + new URL(import.meta.url).pathname;',
' window.__plugin_hmr_callbacks?.[key]?.forEach(callback => {',
' callback(newModule);',
' });',
' })',
'}'
];
return {
name: 'inventree-hmr-plugin',
enforce: 'post',
transform(code, id) {
if (!fileRegex.test(id)) return;
if (id.includes('node_modules')) return;
if (code.includes('__inventree_hmr_injected__')) return;
return {
code: code + hmrBlock.join('\n'),
map: null
};
}
};
}

View File

@ -0,0 +1,113 @@
import type { I18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import { Skeleton } from '@mantine/core';
import { useEffect, useState } from 'react';
/*
* To dynamically load locale messages from a plugin context,
* the plugin MUST supply a callback function which can be used to load the locale messages for the plugin.
* This is because the plugin frontend code is built separately from the main frontend,
* and so cannot directly import locale messages from the main frontend.
*
* Refer to the inventree-plugin-creator tool for an example of how to use this component in a plugin context.
*/
export type LocaleLoader = (locale: string) => Promise<any>;
async function tryLoadLocale(
locale: string,
loader: LocaleLoader
): Promise<any> {
try {
return await loader(locale);
} catch (error) {
console.warn(`Failed to load locale ${locale}`);
return null;
}
}
/**
* @param i18n - The i18n instance from the plugin context
* @param locale - The current locale to load
* @param loader - The callback function to load the locale messages for the plugin
* @returns A React component which will load the locale messages and render the children once loaded
*/
async function loadPluginLocale(
i18n: I18n,
locale: string,
loader: LocaleLoader
) {
let messages = null;
messages = await tryLoadLocale(locale, loader);
if (!messages && locale.includes('-')) {
const fallbackLocale = locale.split('-')[0];
console.debug(
`Locale ${locale} not found, trying fallback locale ${fallbackLocale}`
);
messages = await tryLoadLocale(fallbackLocale, loader);
}
if (!messages && locale.includes('_')) {
const fallbackLocale = locale.split('_')[0];
console.debug(
`Locale ${locale} not found, trying fallback locale ${fallbackLocale}`
);
messages = await tryLoadLocale(fallbackLocale, loader);
}
if (!messages && locale !== 'en') {
console.debug(`Locale ${locale} not found, trying fallback locale en`);
messages = await tryLoadLocale('en', loader);
}
if (messages?.messages) {
i18n.load(locale, messages.messages);
i18n.activate(locale);
} else {
console.error(`Failed to load any locale for ${locale}`);
}
}
// A default locale loader which can be used if the plugin does not supply its own loader function
// Note: This will return null, as the plugin is expected to supply its own loader function which can load the locale messages for the plugin
const defaultLocaleLoader: LocaleLoader = async (_locale: string) => null;
/**
* Wrapper function for a plugin-defined component which needs to support dynamic locale loading.
*
* This is primarily designed for usage by the InvenTree plugin creator tool
*
* @param i18n - The i18n instance from the plugin context
* @param locale - The current locale to load
* @param loadLocale - The callback function to load the locale messages for the plugin
* @param children - The child components to render once the locale is loaded
*/
export default function LocalizedComponent({
i18n,
locale,
loadLocale,
children
}: {
i18n: I18n;
locale: string;
loadLocale?: LocaleLoader;
children: React.ReactNode;
}) {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
setLoaded(false);
loadPluginLocale(i18n, locale, loadLocale ?? defaultLocaleLoader).then(
() => {
setLoaded(true);
}
);
}, [i18n, locale, loadLocale]);
return loaded ? (
<I18nProvider i18n={i18n}>{children}</I18nProvider>
) : (
<Skeleton w='100%' animate />
);
}

View File

@ -10,8 +10,24 @@ export const useLocalLibState = create<LocalLibStateProps>()(
detailDrawerStack:
value === false ? 0 : get().detailDrawerStack + value
});
},
hotkeys: {},
addHotkeys: (hotkeys) => {
const newHotkeys = { ...get().hotkeys };
for (const [ref, details] of hotkeys) {
newHotkeys[ref] = details;
}
set({ hotkeys: newHotkeys });
},
removeHotkeys: (hotkeys) => {
const newHotkeys = { ...get().hotkeys };
for (const ref of hotkeys) {
delete newHotkeys[ref];
}
set({ hotkeys: newHotkeys });
}
}),
{
name: 'session-settings-inventreedb_lib'
}
@ -20,4 +36,7 @@ export const useLocalLibState = create<LocalLibStateProps>()(
export interface LocalLibStateProps {
detailDrawerStack: number;
addDetailDrawer: (value: number | false) => void;
hotkeys: Record<string, string>;
addHotkeys: (hotkeys: [string, string][]) => void;
removeHotkeys: (hotkeys: string[]) => void;
}

View File

@ -84,7 +84,7 @@ export type TableState = {
* @param editable - Whether the value of this column can be edited
* @param definition - Optional field definition for the column
* @param render - A custom render function
* @param filter - A custom filter function
* @param filter - Filter name (string) to look up from tableFilters and attach an inline icon, or a custom render function for the filter popover
* @param filtering - Whether the column is filterable
* @param width - The width of the column
* @param minWidth - The minimum width of the column
@ -109,7 +109,10 @@ export type TableColumnProps<T = any> = {
editable?: boolean;
definition?: ApiFormFieldType;
render?: (record: T, index?: number) => any;
filter?: any;
filter?:
| string
| string[]
| (({ close }: { close: () => void }) => ReactNode);
filtering?: boolean;
width?: number;
minWidth?: string | number;

View File

@ -1,7 +1,7 @@
{
"name": "@inventreedb/ui",
"description": "UI components for the InvenTree project",
"version": "1.4.0",
"version": "1.4.6",
"private": false,
"type": "module",
"license": "MIT",
@ -11,7 +11,8 @@
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./dist/index.js"
".": "./dist/index.js",
"./vite": "./dist/plugin/InventreeHmrPlugin.js"
},
"files": [
"dist",

View File

@ -1,6 +1,7 @@
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import type { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import { useInvenTreeHotkeys } from '@lib/functions/Events';
import type { ApiFormFieldSet } from '@lib/types/Forms';
import { t } from '@lingui/core/macro';
import { IconPrinter, IconReport, IconTags } from '@tabler/icons-react';
@ -34,6 +35,33 @@ export function PrintingActions({
const enabled = useMemo(() => items.length > 0, [items]);
useInvenTreeHotkeys([
[
'mod+P',
t`Open Print Report dialog`,
(event) => {
if (event.repeat) {
return;
}
if (enabled && !hidden) {
reportModal.open();
}
}
],
[
'mod+L',
t`Open Print Label dialog`,
(event) => {
if (event.repeat) {
return;
}
if (enabled && !hidden) {
labelModal.open();
}
}
]
]);
const defaultLabelPlugin = useMemo(
() => userSettings.getSetting('LABEL_DEFAULT_PRINTER'),
[userSettings]

View File

@ -1,3 +1,4 @@
import { useInvenTreeHotkeys } from '@lib/functions/Events';
import type { ModelType } from '@lib/index';
import { t } from '@lingui/core/macro';
import { ActionIcon, Tooltip } from '@mantine/core';
@ -14,14 +15,28 @@ import BarcodeScanDialog, {
export function ScanButton({
modelType,
callback,
onScanSuccess
onScanSuccess,
hotkey = false
}: {
modelType?: ModelType;
callback?: BarcodeScanCallback;
onScanSuccess?: BarcodeScanSuccessCallback;
hotkey?: boolean;
}) {
const [opened, { open, close }] = useDisclosure(false);
if (hotkey) {
useInvenTreeHotkeys([
[
'mod+b',
t`Open barcode scanner`,
() => {
open();
}
]
]);
}
return (
<>
<Tooltip position='bottom-end' label={t`Scan Barcode`}>

View File

@ -2,12 +2,18 @@ import { t } from '@lingui/core/macro';
import { ActionIcon, Tooltip } from '@mantine/core';
import { IconCommand } from '@tabler/icons-react';
import { firstSpotlight } from '../nav/Layout';
import { useLocalLibState } from '@lib/states/LocalLibState';
import { firstSpotlight, searchShortcutKey } from '../nav/Layout';
/**
* A button which opens the quick command modal
*/
export function SpotlightButton() {
export function SpotlightButton({ hotkey = false }: { hotkey?: boolean }) {
if (hotkey) {
useLocalLibState
.getState()
.addHotkeys([[searchShortcutKey, t`Open spotlight`]]);
}
return (
<Tooltip position='bottom-end' label={t`Open spotlight`}>
<ActionIcon

View File

@ -47,12 +47,14 @@ export default function OrderCalendar({
role,
params,
filters,
initialFilters,
tooltip
}: {
model: ModelType;
role: UserRoles;
params: Record<string, any>;
filters?: TableFilter[];
initialFilters?: TableFilter[];
tooltip?: (event: EventContentArg) => React.ReactNode;
}) {
const navigate = useNavigate();
@ -94,7 +96,8 @@ export default function OrderCalendar({
const calendarState = useCalendar({
endpoint: modelInfo.api_endpoint,
name: model.toString(),
queryParams: params
queryParams: params,
initialFilters: initialFilters
});
// Build the events

View File

@ -8,11 +8,12 @@ import {
Space,
Text
} from '@mantine/core';
import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { useDisclosure } from '@mantine/hooks';
import { IconExclamationCircle, IconInfoCircle } from '@tabler/icons-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { type Layout, Responsive, WidthProvider } from 'react-grid-layout';
import { useInvenTreeHotkeys } from '@lib/functions/Events';
import { useShallow } from 'zustand/react/shallow';
import { useDashboardItems } from '../../hooks/UseDashboardItems';
import { useLocalState } from '../../states/LocalState';
@ -61,9 +62,10 @@ export default function DashboardLayout() {
const [loaded, setLoaded] = useState(false);
// Keyboard shortcut for editing the dashboard layout
useHotkeys([
useInvenTreeHotkeys([
[
'mod+E',
t`Toggle dashboard edit mode`,
() => {
setEditing.toggle();
}

View File

@ -8,7 +8,6 @@ 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';
@ -218,26 +217,6 @@ 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 [
{
@ -276,7 +255,6 @@ function BuiltinActionWidgets(): DashboardWidgetProps[] {
export default function DashboardWidgetLibrary(): DashboardWidgetProps[] {
return [
...BuiltinQueryCountWidgets(),
...BuiltinHistoryWidgets(),
...BuiltinGettingStartedWidgets(),
...BuiltinSettingsWidgets(),
...BuiltinActionWidgets()

View File

@ -1,135 +0,0 @@
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 { Box, LoadingOverlay, 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>
<Box>
<LoadingOverlay visible={query.isLoading || query.isFetching} />
<BarChart
h={200}
data={chartData}
dataKey='month'
series={[{ name: 'count', label: t`Completed`, color: 'blue.6' }]}
withYAxis={false}
yAxisProps={{ domain: [0, 'auto'] }}
/>
</Box>
</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

@ -0,0 +1,66 @@
import { useLocalLibState } from '@lib/states/LocalLibState';
import { Kbd, Table } from '@mantine/core';
import { type UseOSReturnValue, useOs } from '@mantine/hooks';
import type { ContextModalProps } from '@mantine/modals';
import { Fragment, useMemo } from 'react';
function modRenderer(value: string, os: UseOSReturnValue) {
if (os === 'macos') {
return value.replace('mod', '⌘');
}
return value.replace('mod', 'Ctrl');
}
function kbdRenderer(value: string, os: UseOSReturnValue) {
const parts = value.split('+');
if (parts.length > 1) {
return (
<>
{parts.map((part, idx) => (
<Fragment key={idx}>
<Kbd>{modRenderer(part, os)}</Kbd>
{idx < parts.length - 1 && ' + '}
</Fragment>
))}
</>
);
}
return <Kbd>{modRenderer(value, os)}</Kbd>;
}
export function HotkeyModal({
context,
id
}: ContextModalProps<{ modalBody: string }>) {
const os = useOs();
const hotkeys = useMemo(() => {
const keys = Object.entries(useLocalLibState.getState().hotkeys).map(
([hotkey, description]) => {
return {
key: hotkey,
dec: description
};
}
);
keys.sort((a, b) => a.key.localeCompare(b.key));
return keys;
}, []);
const data = useMemo(() => {
return {
head: ['Hotkey', 'Action'],
body: [...hotkeys.map((item) => [kbdRenderer(item.key, os), item.dec])]
};
}, [os, hotkeys]);
return (
<Table
striped
highlightOnHover
withColumnBorders
horizontalSpacing='md'
verticalSpacing='xs'
data={data}
/>
);
}

View File

@ -10,11 +10,7 @@ import {
Tooltip,
UnstyledButton
} from '@mantine/core';
import {
useDisclosure,
useDocumentVisibility,
useHotkeys
} from '@mantine/hooks';
import { useDisclosure, useDocumentVisibility } from '@mantine/hooks';
import { IconBell, IconSearch, IconUserBolt } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
@ -22,6 +18,7 @@ import { useMatch, useNavigate } from 'react-router-dom';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import { useInvenTreeHotkeys } from '@lib/functions/Events';
import { getBaseUrl } from '@lib/functions/Navigation';
import { navigateToLink } from '@lib/functions/Navigation';
import { t } from '@lingui/core/macro';
@ -60,15 +57,17 @@ export function Header() {
{ open: openSearchDrawer, close: closeSearchDrawer }
] = useDisclosure(false);
useHotkeys([
useInvenTreeHotkeys([
[
'/',
t`Open search`,
() => {
openSearchDrawer();
}
],
[
'mod+/',
t`Open search`,
() => {
openSearchDrawer();
}
@ -198,8 +197,8 @@ export function Header() {
<IconSearch />
</ActionIcon>
</Tooltip>
{userSettings.isSet('SHOW_SPOTLIGHT') && <SpotlightButton />}
{globalSettings.isSet('BARCODE_ENABLE') && <ScanButton />}
{userSettings.isSet('SHOW_SPOTLIGHT') && <SpotlightButton hotkey />}
{globalSettings.isSet('BARCODE_ENABLE') && <ScanButton hotkey />}
<Indicator
radius='lg'
size='18'

View File

@ -54,6 +54,8 @@ export const ProtectedRoute = ({ children }: { children: JSX.Element }) => {
export const [firstStore, firstSpotlight] = createSpotlight();
export const searchShortcutKey = 'mod+K';
export default function LayoutComponent() {
const navigate = useNavigate();
const location = useLocation();
@ -140,7 +142,7 @@ export default function LayoutComponent() {
leftSection: <IconSearch size='1.2rem' />,
placeholder: t`Search...`
}}
shortcut={['mod + K']}
shortcut={[searchShortcutKey]}
nothingFound={t`Nothing found...`}
/>
)}

View File

@ -1,8 +1,9 @@
import { Group, Paper, Space, Stack, Text } from '@mantine/core';
import { useHotkeys } from '@mantine/hooks';
import { StylishText } from '@lib/components/StylishText';
import { useInvenTreeHotkeys } from '@lib/functions/Events';
import { shortenString } from '@lib/functions/String';
import { t } from '@lingui/core/macro';
import { Fragment, type ReactNode, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { usePluginUIFeature } from '../../hooks/UsePluginUIFeature';
@ -52,9 +53,11 @@ export function PageDetail({
const userSettings = useUserSettingsState();
const navigate = useNavigate();
const location = useLocation();
useHotkeys([
useInvenTreeHotkeys([
[
'mod+E',
title ? t`Edit ${title}` : t`Edit`,
(event) => {
if (event.repeat) {
return;

View File

@ -11,6 +11,7 @@ import { ContextMenuProvider } from 'mantine-contextmenu';
import type { JSX } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { AboutInvenTreeModal } from '../components/modals/AboutInvenTreeModal';
import { HotkeyModal } from '../components/modals/HotkeyModal';
import { LicenseModal } from '../components/modals/LicenseModal';
import { QrModal } from '../components/modals/QrModal';
import { ServerInfoModal } from '../components/modals/ServerInfoModal';
@ -59,7 +60,8 @@ export function ThemeContext({
info: ServerInfoModal,
about: AboutInvenTreeModal,
license: LicenseModal,
qr: QrModal
qr: QrModal,
hotkey: HotkeyModal
}}
>
<Notifications />

View File

@ -14,7 +14,8 @@ import {
import type { NavigateFunction } from 'react-router-dom';
import { ModelInformationDict } from '@lib/enums/ModelInformation';
import { ModelType, UserRoles } from '@lib/index';
import { ModelType, StylishText, UserRoles } from '@lib/index';
import { Trans } from '@lingui/react/macro';
import { openContextModal } from '@mantine/modals';
import { useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
@ -23,13 +24,26 @@ import { useGlobalSettingsState } from '../states/SettingsStates';
import { useUserState } from '../states/UserState';
import { aboutInvenTree, docLinks, licenseInfo, serverInfo } from './links';
export function openQrModal(navigate: NavigateFunction) {
function openQrModal(navigate: NavigateFunction) {
return openContextModal({
modal: 'qr',
innerProps: { navigate: navigate }
});
}
function openHotkeys() {
return openContextModal({
modal: 'hotkey',
title: (
<StylishText size='xl'>
<Trans>Hotkeys</Trans>
</StylishText>
),
size: 'xl',
innerProps: {}
});
}
export function getActions(navigate: NavigateFunction) {
const setNavigationOpen = useLocalState(
useShallow((state) => state.setNavigationOpen)
@ -91,6 +105,13 @@ export function getActions(navigate: NavigateFunction) {
description: t`Go to your user settings`,
onClick: () => navigate('/settings/user'),
leftSection: <IconUserCog size='1.2rem' />
},
{
id: 'hotkeys',
label: t`Hotkeys`,
description: t`View a list of available hotkeys`,
onClick: () => openHotkeys(),
leftSection: <IconSettings size='1.2rem' />
}
];

View File

@ -2,7 +2,7 @@ import type FullCalendar from '@fullcalendar/react';
import type { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import useFilterSet from '@lib/hooks/UseFilterSet';
import type { FilterSetState } from '@lib/types/Filters';
import type { FilterSetState, TableFilter } from '@lib/types/Filters';
import type { UseModalReturn } from '@lib/types/Modals';
import type { DateValue } from '@mantine/dates';
import { type UseQueryResult, useQuery } from '@tanstack/react-query';
@ -54,15 +54,17 @@ export type CalendarState = {
export default function useCalendar({
name,
endpoint,
queryParams
queryParams,
initialFilters
}: {
name: string;
endpoint: ApiEndpoints;
queryParams?: any;
initialFilters?: TableFilter[];
}): CalendarState {
const ref = useRef<FullCalendar>(null as any);
const filterSet = useFilterSet(`calendar-${name}`);
const filterSet = useFilterSet(`calendar-${name}`, initialFilters);
const [searchTerm, setSearchTerm] = useState<string>('');

View File

@ -48,8 +48,9 @@ function BuildOrderCalendar() {
<OrderCalendar
model={ModelType.build}
role={UserRoles.build}
params={{ outstanding: true, part_detail: true }}
params={{ part_detail: true }}
filters={calendarFilters}
initialFilters={[{ name: 'outstanding', value: 'true' }]}
tooltip={renderTooltip}
/>
);

View File

@ -51,8 +51,9 @@ function PurchaseOrderCalendar() {
<OrderCalendar
model={ModelType.purchaseorder}
role={UserRoles.purchase_order}
params={{ outstanding: true, supplier_detail: true }}
params={{ supplier_detail: true }}
filters={calendarFilters}
initialFilters={[{ name: 'outstanding', value: 'true' }]}
tooltip={renderTooltip}
/>
);

View File

@ -49,8 +49,9 @@ function SalesOrderCalendar() {
<OrderCalendar
model={ModelType.salesorder}
role={UserRoles.sales_order}
params={{ outstanding: true, customer_detail: true }}
params={{ customer_detail: true }}
filters={calendarFilters}
initialFilters={[{ name: 'outstanding', value: 'true' }]}
tooltip={renderTooltip}
/>
);
@ -73,8 +74,9 @@ const ReturnOrderCalendar = () => {
<OrderCalendar
model={ModelType.returnorder}
role={UserRoles.return_order}
params={{ outstanding: true, customer_detail: true }}
params={{ customer_detail: true }}
filters={calendarFilters}
initialFilters={[{ name: 'outstanding', value: 'true' }]}
tooltip={renderTooltip}
/>
);

View File

@ -70,8 +70,9 @@ function TransferOrderCalendar() {
<OrderCalendar
model={ModelType.transferorder}
role={UserRoles.transfer_order}
params={{ outstanding: true }}
params={{}}
filters={calendarFilters}
initialFilters={[{ name: 'outstanding', value: 'true' }]}
/>
);
}

View File

@ -117,6 +117,7 @@ export function IPNColumn(props: TableColumnProps): TableColumn {
switchable: true,
title: t`IPN`,
copyable: true,
filter: 'has_ipn',
...props
};
}
@ -433,6 +434,7 @@ export function BooleanColumn(props: TableColumn): TableColumn {
sortable: true,
switchable: true,
minWidth: '75px',
filter: props.filter ?? props.accessor,
render: (record: any) => (
<Center>
<YesNoButton value={resolveItem(record, props.accessor ?? '')} />
@ -564,6 +566,7 @@ export function ProjectCodeColumn(props: TableColumnProps): TableColumn {
sortable: true,
title: t`Project Code`,
hidden: !enabled,
filter: 'project_code',
render: (record: any) => {
const project_code = resolveItem(
record,
@ -584,6 +587,7 @@ export function StatusColumn(props: StatusColumnProps): TableColumn {
return {
accessor: 'status',
filter: 'status',
sortable: true,
switchable: true,
minWidth: '50px',
@ -636,6 +640,7 @@ export function CreatedByColumn(props: TableColumnProps): TableColumn {
accessor: 'created_by',
ordering: 'created_by',
title: t`Created By`,
filter: 'created_by',
...props
});
}
@ -665,6 +670,7 @@ export function ResponsibleColumn(props: TableColumnProps): TableColumn {
accessor: 'responsible_detail',
ordering: 'responsible',
title: t`Responsible`,
filter: 'assigned_to',
...props
});
}
@ -688,6 +694,7 @@ export function StartDateColumn(props: TableColumnProps): TableColumn {
return DateColumn({
accessor: 'start_date',
title: t`Start Date`,
filter: ['has_start_date', 'start_date_before', 'start_date_after'],
...props
});
}
@ -696,6 +703,7 @@ export function TargetDateColumn(props: TableColumnProps): TableColumn {
return DateColumn({
accessor: 'target_date',
title: t`Target Date`,
filter: ['has_target_date', 'target_date_before', 'target_date_after'],
...props
});
}
@ -704,6 +712,7 @@ export function CreationDateColumn(props: TableColumnProps): TableColumn {
return DateColumn({
accessor: 'creation_date',
title: t`Creation Date`,
filter: ['created_before', 'created_after'],
...props
});
}
@ -712,6 +721,7 @@ export function CompletionDateColumn(props: TableColumnProps): TableColumn {
return DateColumn({
accessor: 'completion_date',
title: t`Completion Date`,
filter: ['completed_before', 'completed_after'],
...props
});
}
@ -720,6 +730,7 @@ export function ShipmentDateColumn(props: TableColumnProps): TableColumn {
return DateColumn({
accessor: 'shipment_date',
title: t`Shipment Date`,
filter: ['shipment_date_before', 'shipment_date_after'],
...props
});
}
@ -729,6 +740,7 @@ export function UpdatedAtColumn(props: TableColumnProps): TableColumn {
accessor: 'updated_at',
title: t`Updated`,
defaultVisible: false,
filter: ['updated_before', 'updated_after'],
extra: { showTime: true },
...props
});

View File

@ -182,15 +182,17 @@ function MultiApiFilterElement({
);
}
function FilterElement({
export function FilterElement({
filterName,
filterProps,
valueOptions,
brief = false,
onValueChange
}: {
filterName: string;
filterProps: TableFilter;
valueOptions: TableFilterChoice[];
brief?: boolean;
onValueChange: (value: string | null, displayValue?: any) => void;
}) {
const setDateValue = useCallback(
@ -226,7 +228,7 @@ function FilterElement({
filters: filterProps.apiFilter,
placeholder: t`Select filter value`,
model: filterProps.model,
label: t`Select filter value`,
label: brief ? undefined : t`Select filter value`,
onValueChange: (value: any, instance: any) => {
if (filterProps.transform) {
const choice = filterProps.transform(instance);
@ -244,9 +246,10 @@ function FilterElement({
case 'text':
return (
<TextInput
label={t`Value`}
label={brief ? undefined : t`Value`}
value={textValue}
placeholder={t`Enter filter value`}
aria-label={`text-filter-${filterName}`}
rightSection={
<ActionIcon
aria-label='apply-text-filter'
@ -256,8 +259,10 @@ function FilterElement({
<IconCheck />
</ActionIcon>
}
onChange={(e) => setTextValue(e.currentTarget.value)}
onKeyDown={(e) => {
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setTextValue(e.currentTarget.value)
}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onValueChange(textValue);
}
@ -267,9 +272,11 @@ function FilterElement({
case 'date':
return (
<DateInput
label={t`Value`}
aria-label={`date-filter-${filterName}`}
label={brief ? undefined : t`Value`}
placeholder={t`Select date value`}
onChange={setDateValue}
popoverProps={{ withinPortal: false }}
/>
);
case 'choice':
@ -278,8 +285,9 @@ function FilterElement({
return (
<Select
data={valueOptions}
aria-label={`choice-filter-${filterName}`}
searchable={filterProps.type == 'choice'}
label={t`Value`}
label={brief ? undefined : t`Value`}
withScrollArea={false}
placeholder={t`Select filter value`}
onChange={(value: string | null) => onValueChange(value)}
@ -289,6 +297,13 @@ function FilterElement({
}
}
function getFilterType(filter: TableFilter): TableFilterType {
if (filter.type) return filter.type;
if (filter.apiUrl && filter.model) return 'api';
if (filter.choices || filter.choiceFunction) return 'choice';
return 'boolean';
}
function FilterAddGroup({
filterSet,
availableFilters
@ -331,19 +346,6 @@ function FilterAddGroup({
return getTableFilterOptions(filter);
}, [selectedFilter]);
// Determine the filter "type" - if it is not supplied
const getFilterType = (filter: TableFilter): TableFilterType => {
if (filter.type) {
return filter.type;
} else if (filter.apiUrl && filter.model) {
return 'api';
} else if (filter.choices || filter.choiceFunction) {
return 'choice';
} else {
return 'boolean';
}
};
// Extract filter definition
const filterProps: TableFilter | undefined = useMemo(() => {
const filter = availableFilters?.find((flt) => flt.name === selectedFilter);
@ -478,6 +480,127 @@ function SavedFilterSets({
);
}
/*
* Renders a single filter's title, active-value badge, remove button, and
* value-picker. Used as a building block inside ColumnFilterPopover.
*/
function FilterSection({
filter,
filterSet,
closeOnApply,
close
}: Readonly<{
filter: TableFilter;
filterSet: FilterSetState;
closeOnApply: boolean;
close: () => void;
}>) {
const activeFilter = useMemo(
() => filterSet.activeFilters.find((f) => f.name === filter.name),
[filter.name, filterSet.activeFilters]
);
const filterProps = useMemo(
() => ({ ...filter, type: getFilterType(filter) }),
[filter]
);
const valueOptions = useMemo(
() => getTableFilterOptions(filterProps),
[filterProps]
);
const onValueChange = useCallback(
(value: string | null, displayValue?: any) => {
if (!value) return;
const newFilter: TableFilter = {
...filterProps,
value,
displayValue:
displayValue ?? valueOptions.find((v) => v.value === value)?.label
};
const others = filterSet.activeFilters.filter(
(f) => f.name !== filter.name
);
filterSet.setActiveFilters([...others, newFilter]);
if (closeOnApply) close();
},
[filter.name, filterProps, filterSet, valueOptions, closeOnApply, close]
);
const removeFilter = useCallback(() => {
filterSet.setActiveFilters(
filterSet.activeFilters.filter((f) => f.name !== filter.name)
);
close();
}, [filter.name, filterSet, close]);
return (
<Stack gap='xs'>
<Text size='sm' fw={600}>
{filter.label ?? filter.name}
</Text>
{activeFilter ? (
<Group justify='space-between' wrap='nowrap'>
<Badge color='blue'>
{activeFilter.displayValue ?? activeFilter.value}
</Badge>
<ActionIcon
color='red'
variant='transparent'
size='sm'
onClick={removeFilter}
>
<IconX />
</ActionIcon>
</Group>
) : (
<FilterElement
filterName={filter.name}
filterProps={filterProps}
valueOptions={valueOptions}
brief={true}
onValueChange={onValueChange}
/>
)}
</Stack>
);
}
/*
* Popover content rendered when the user clicks a column's inline filter icon.
* Renders one FilterSection per matched filter, each with its own title, active
* value, and value-picker. Auto-closes on apply only when there is a single
* filter (e.g. date-range columns stay open so both bounds can be set at once).
*/
export function ColumnFilterPopover({
filters,
filterSet,
close
}: Readonly<{
filters: TableFilter[];
filterSet: FilterSetState;
close: () => void;
}>) {
const closeOnApply = filters.length === 1;
return (
<Stack gap={5} p={3}>
{filters.map((filter, index) => (
<Stack gap='xs' key={filter.name}>
{index > 0 && <Divider />}
<FilterSection
filter={filter}
filterSet={filterSet}
closeOnApply={closeOnApply}
close={close}
/>
</Stack>
))}
</Stack>
);
}
export function FilterSelectDrawer({
title,
availableFilters,
@ -584,8 +707,10 @@ export function FilterSelectDrawer({
aria-label='filter-group-name'
placeholder={t`Group name`}
value={saveName}
onChange={(e) => setSaveName(e.currentTarget.value)}
onKeyDown={(e) => {
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setSaveName(e.currentTarget.value)
}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') confirmSave();
if (e.key === 'Escape') setSaving(false);
}}

View File

@ -38,6 +38,7 @@ import { extractAvailableFields } from '../functions/forms';
import { showApiErrorMessage } from '../functions/notifications';
import { useLocalState } from '../states/LocalState';
import { useUserSettingsState } from '../states/SettingsStates';
import { ColumnFilterPopover } from './FilterSelectDrawer';
import InvenTreeTableHeader from './InvenTreeTableHeader';
const ACTIONS_COLUMN_ACCESSOR: string = '--actions--';
@ -265,12 +266,12 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
// Prop-level hidden takes priority (e.g. hidden: !hasTrackedItems).
// For switchable columns, visibility is driven by tableState.hiddenColumns.
// Non-switchable columns are always visible (unless hidden by props).
const hidden: boolean =
col.hidden === true
? true
: col.switchable == false
? false
: (tableState.hiddenColumns?.includes(col.accessor) ?? false);
const propHidden: boolean = col.hidden === true;
const hidden: boolean = propHidden
? true
: col.switchable == false
? false
: (tableState.hiddenColumns?.includes(col.accessor) ?? false);
// Wrap the render function with CopyableCell if copyable is enabled
const originalRender = col.render;
@ -300,12 +301,55 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
};
}
// col.filter can be:
// string → single filter name to look up in tableFilters
// string[] → multiple filter names; all matches shown in one popover
// function → direct mantine-datatable render function (e.g. parametric columns)
// undefined → no column filter
const filterNames: string[] =
typeof col.filter === 'string'
? [col.filter]
: Array.isArray(col.filter)
? col.filter
: [];
const namedFilters: TableFilter[] =
filterNames.length > 0
? filters.filter((f) => filterNames.includes(f.name))
: [];
const namedFiltersActive =
namedFilters.length > 0 &&
namedFilters.some((nf) =>
tableState.filterSet.activeFilters.some((af) => af.name === nf.name)
);
// Resolve the final filter prop:
// named string(s) with matches → build popover render function
// named string(s) with no match → undefined (suppress icon)
// function → pass through unchanged (e.g. parametric columns)
const resolvedFilter =
namedFilters.length > 0
? ({ close }: { close: () => void }) => (
<ColumnFilterPopover
filters={namedFilters}
filterSet={tableState.filterSet}
close={close}
/>
)
: filterNames.length > 0
? undefined
: col.filter;
return {
...col,
hidden: hidden,
resizable: col.resizable ?? true,
title: col.title ?? fieldNames[col.accessor] ?? `${col.accessor}`,
render: wrappedRender,
filter: resolvedFilter,
filtering: namedFilters.length > 0 ? namedFiltersActive : col.filtering,
propHidden: propHidden,
cellsStyle: (record: any, index: number) => {
const width = (col as any).minWidth ?? 100;
return {
@ -347,10 +391,12 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
return cols;
}, [
columns,
filters,
fieldNames,
tableProps.rowActions,
tableState.hiddenColumns,
tableState.selectedRecords
tableState.selectedRecords,
tableState.filterSet.activeFilters
]);
// Callback when column visibility is toggled

View File

@ -171,11 +171,6 @@ export function BomTable({
DescriptionColumn({
accessor: 'sub_part_detail.description'
}),
BooleanColumn({
accessor: 'sub_part_detail.virtual',
defaultVisible: false,
title: t`Virtual Part`
}),
ReferenceColumn({
switchable: true
}),
@ -274,6 +269,12 @@ export function BomTable({
);
}
},
BooleanColumn({
accessor: 'sub_part_detail.virtual',
filter: 'sub_part_virtual',
defaultVisible: false,
title: t`Virtual Part`
}),
BooleanColumn({
accessor: 'optional',
defaultVisible: false

View File

@ -44,7 +44,8 @@ export function UsedInTable({
return [
PartColumn({
title: t`Assembly`,
part: 'part_detail'
part: 'part_detail',
filter: ['part_active', 'part_locked']
}),
IPNColumn({
sortable: true

View File

@ -1,3 +1,14 @@
import { ActionButton } from '@lib/components/ActionButton';
import { ProgressBar } from '@lib/components/ProgressBar';
import { RowEditAction, RowViewAction } from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { formatDecimal } from '@lib/functions/Formatting';
import useTable from '@lib/hooks/UseTable';
import type { TableFilter } from '@lib/types/Filters';
import type { RowAction, TableColumn } from '@lib/types/Tables';
import { t } from '@lingui/core/macro';
import { Alert, Group, Paper, Text } from '@mantine/core';
import {
@ -13,18 +24,6 @@ import {
import type { DataTableRowExpansionProps } from 'mantine-datatable';
import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ActionButton } from '@lib/components/ActionButton';
import { ProgressBar } from '@lib/components/ProgressBar';
import { RowEditAction, RowViewAction } from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { formatDecimal } from '@lib/functions/Formatting';
import useTable from '@lib/hooks/UseTable';
import type { TableFilter } from '@lib/types/Filters';
import type { RowAction, TableColumn } from '@lib/types/Tables';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import {
useAllocateStockToBuildForm,
@ -322,6 +321,7 @@ export default function BuildLineTable({
ordering: 'part',
sortable: true,
switchable: false,
filter: ['assembly', 'testable', 'tracked'],
render: (record: any) => {
const hasAllocatedItems = record.allocatedQuantity > 0;
@ -356,12 +356,14 @@ export default function BuildLineTable({
BooleanColumn({
accessor: 'bom_item_detail.optional',
ordering: 'optional',
filter: 'optional',
hidden: hasOutput,
defaultVisible: false
}),
BooleanColumn({
accessor: 'bom_item_detail.consumable',
ordering: 'consumable',
filter: 'consumable',
hidden: hasOutput,
defaultVisible: false
}),
@ -381,6 +383,7 @@ export default function BuildLineTable({
BooleanColumn({
accessor: 'part_detail.trackable',
ordering: 'trackable',
filter: 'tracked',
hidden: hasOutput,
defaultVisible: false
}),
@ -457,6 +460,7 @@ export default function BuildLineTable({
{
accessor: 'available_stock',
sortable: true,
filter: 'available',
switchable: false,
render: renderAvailableColumn
},
@ -481,12 +485,14 @@ export default function BuildLineTable({
DecimalColumn({
accessor: 'on_order',
defaultVisible: false,
filter: 'on_order',
sortable: true
}),
{
accessor: 'allocated',
switchable: false,
sortable: true,
filter: 'allocated',
hidden: !isActive,
minWidth: 125,
render: (record: any) => {
@ -532,6 +538,7 @@ export default function BuildLineTable({
{
accessor: 'consumed',
sortable: true,
filter: 'consumed',
hidden: !!output?.pk,
minWidth: 125,
render: (record: any) => {
@ -761,7 +768,9 @@ export default function BuildLineTable({
!consumable &&
user.hasChangeRole(UserRoles.build) &&
required > 0 &&
record.trackable == hasOutput;
(hasOutput ? trackable : true);
const disableAllocation = !hasOutput && trackable;
// Can de-allocate
const canDeallocate =
@ -784,6 +793,10 @@ export default function BuildLineTable({
icon: <IconArrowRight />,
title: t`Allocate Stock`,
hidden: !canAllocate,
disabled: disableAllocation,
tooltip: disableAllocation
? t`Trackable parts must be allocated via the Build Outputs tab`
: t`Allocate Stock`,
color: 'green',
onClick: () => {
setSelectedRows([record]);

View File

@ -15,8 +15,8 @@ import { useGlobalSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
import {
BooleanColumn,
CompletionDateColumn,
CreationDateColumn,
DateColumn,
DescriptionColumn,
IPNColumn,
LinkColumn,
@ -104,6 +104,7 @@ export function BuildOrderTable({
BooleanColumn({
accessor: 'external',
title: t`External`,
filter: 'external',
sortable: true,
switchable: true,
hidden: !globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS')
@ -115,11 +116,7 @@ export function BuildOrderTable({
defaultVisible: false
}),
TargetDateColumn({}),
DateColumn({
accessor: 'completion_date',
title: t`Completion Date`,
sortable: true
}),
CompletionDateColumn({}),
UserColumn({
accessor: 'issued_by_detail',
ordering: 'issued_by',

View File

@ -64,6 +64,7 @@ export function CompanyTable({
DescriptionColumn({}),
BooleanColumn({
accessor: 'active',
filter: 'active',
title: t`Active`,
sortable: true,
switchable: true

View File

@ -54,7 +54,8 @@ export function ParameterTable({
accessor: 'template_detail.name',
switchable: false,
sortable: true,
ordering: 'name'
ordering: 'name',
filter: 'enabled'
},
DescriptionColumn({
accessor: 'template_detail.description'
@ -105,6 +106,7 @@ export function ParameterTable({
UserColumn({
accessor: 'updated_by_detail',
ordering: 'updated_by',
filter: 'updated_by',
title: t`Updated By`
})
];

View File

@ -5,7 +5,6 @@ import { useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '@lib/components/AddItemButton';
import { type RowAction, RowEditAction } from '@lib/components/RowActions';
import { YesNoButton } from '@lib/components/YesNoButton';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
@ -23,7 +22,7 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useUserState } from '../../states/UserState';
import { DescriptionColumn } from '../ColumnRenderers';
import { BooleanColumn, DescriptionColumn } from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
/**
@ -64,14 +63,11 @@ export function PartCategoryTable({ parentId }: Readonly<{ parentId?: any }>) {
copyable: true,
sortable: true
},
{
BooleanColumn({
accessor: 'structural',
sortable: true,
defaultVisible: false,
render: (record: any) => {
return <YesNoButton value={record.structural} />;
}
},
defaultVisible: false
}),
{
accessor: 'part_count',
sortable: true

View File

@ -29,6 +29,7 @@ export default function PartPurchaseOrdersTable({
ordering: 'order',
sortable: true,
switchable: false,
filter: ['pending', 'received'],
title: t`Purchase Order`
}),
StatusColumn({
@ -36,6 +37,7 @@ export default function PartPurchaseOrdersTable({
sortable: true,
ordering: 'status',
title: t`Status`,
filter: 'order_status',
model: ModelType.purchaseorder
}),
{

View File

@ -37,6 +37,7 @@ import { useImporterState } from '../../states/ImporterState';
import { useGlobalSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
import {
BooleanColumn,
CategoryColumn,
DefaultLocationColumn,
DescriptionColumn,
@ -55,7 +56,8 @@ function partTableColumns(): TableColumn[] {
return [
PartColumn({
part: '',
accessor: 'name'
accessor: 'name',
filter: ['active', 'locked', 'starred']
}),
IPNColumn({
accessor: 'IPN'
@ -67,7 +69,8 @@ function partTableColumns(): TableColumn[] {
{
accessor: 'units',
sortable: true,
copyable: true
copyable: true,
filter: 'has_units'
},
DescriptionColumn({}),
CategoryColumn({
@ -79,7 +82,7 @@ function partTableColumns(): TableColumn[] {
{
accessor: 'total_in_stock',
sortable: true,
filter: ['has_stock', 'low_stock', 'high_stock'],
render: (record) => {
if (record.virtual) {
return (
@ -197,10 +200,19 @@ function partTableColumns(): TableColumn[] {
title: t`Price Range`,
sortable: true,
ordering: 'pricing_max',
filter: 'has_pricing',
defaultVisible: false,
render: (record: any) =>
formatPriceRange(record.pricing_min, record.pricing_max)
},
BooleanColumn({
accessor: 'assembly',
defaultVisible: false
}),
BooleanColumn({
accessor: 'virtual',
defaultVisible: false
}),
LinkColumn({})
];
}

View File

@ -26,7 +26,7 @@ import { useApi } from '../../contexts/ApiContext';
import { formatDate } from '../../defaults/formatters';
import { useTestResultFields } from '../../forms/StockForms';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { LocationColumn, PartColumn } from '../ColumnRenderers';
import { LocationColumn, PartColumn, StatusColumn } from '../ColumnRenderers';
import {
BatchFilter,
HasBatchCodeFilter,
@ -256,6 +256,7 @@ export default function PartTestResultTable({
title: t`Stock Item`,
sortable: true,
switchable: false,
filter: ['in_stock', 'is_building'],
render: (record: any) => {
if (record.serial) {
return `# ${record.serial}`;
@ -284,10 +285,15 @@ export default function PartTestResultTable({
}
}
},
StatusColumn({
accessor: 'status',
model: ModelType.stockitem
}),
{
accessor: 'batch',
title: t`Batch Code`,
sortable: true,
filter: ['batch', 'has_batch_code'],
switchable: true,
copyable: true
},

View File

@ -86,12 +86,14 @@ export function ManufacturerPartTable({
const tableColumns: TableColumn[] = useMemo(() => {
return [
PartColumn({
switchable: !!partId
switchable: !!partId,
filter: 'part_active'
}),
IPNColumn({}),
{
accessor: 'manufacturer',
sortable: true,
filter: 'manufacturer_active',
render: (record: any) => (
<CompanyColumn company={record?.manufacturer_detail} />
)

View File

@ -93,11 +93,13 @@ export function SupplierPartTable({
return [
PartColumn({
switchable: !!partId,
part: 'part_detail'
part: 'part_detail',
filter: ['part_active']
}),
IPNColumn({}),
{
accessor: 'supplier',
filter: 'supplier_active',
sortable: true,
render: (record: any) => (
<CompanyColumn company={record?.supplier_detail} />
@ -179,6 +181,7 @@ export function SupplierPartTable({
accessor: 'available',
sortable: true,
defaultVisible: false,
filter: 'has_stock',
render: (record: any) => {
const extra = [];

View File

@ -159,6 +159,7 @@ export default function SalesOrderShipmentTable({
{
accessor: 'checked',
title: t`Checked`,
filter: 'checked',
switchable: true,
sortable: false,
render: (record: any) => <YesNoButton value={!!record.checked_by} />
@ -168,12 +169,14 @@ export default function SalesOrderShipmentTable({
title: t`Shipped`,
switchable: true,
sortable: false,
filter: 'shipped',
render: (record: any) => <YesNoButton value={!!record.shipment_date} />
},
{
accessor: 'delivered',
title: t`Delivered`,
switchable: true,
filter: 'delivered',
sortable: false,
render: (record: any) => <YesNoButton value={!!record.delivery_date} />
},

View File

@ -63,7 +63,8 @@ function stockItemTableColumns({
return [
PartColumn({
accessor: 'part',
part: 'part_detail'
part: 'part_detail',
filter: ['active']
}),
IPNColumn({}),
{
@ -79,13 +80,21 @@ function stockItemTableColumns({
accessor: '',
title: t`Stock`,
sortable: true,
ordering: 'stock'
ordering: 'stock',
filter: [
'available',
'allocated',
'consumed',
'installed',
'sent_to_customer'
]
}),
StatusColumn({ model: ModelType.stockitem }),
{
accessor: 'batch',
sortable: true,
copyable: true
copyable: true,
filter: ['has_batch_code', 'batch']
},
LocationColumn({
hidden: !showLocation,
@ -151,22 +160,26 @@ function stockItemTableColumns({
DateColumn({
title: t`Created`,
accessor: 'creation_date',
sortable: true
sortable: true,
filter: ['created_before', 'created_after']
}),
DateColumn({
title: t`Last Updated`,
accessor: 'updated'
accessor: 'updated',
filter: ['updated_before', 'updated_after']
}),
DateColumn({
title: t`Expiry Date`,
accessor: 'expiry_date',
hidden: !useGlobalSettingsState.getState().isSet('STOCK_ENABLE_EXPIRY'),
defaultVisible: false
defaultVisible: false,
filter: ['stale', 'expiry_before', 'expiry_after']
}),
DateColumn({
accessor: 'stocktake_date',
title: t`Stocktake Date`,
sortable: true
sortable: true,
filter: ['has_stocktake', 'stocktake_before', 'stocktake_after']
})
];
}

View File

@ -144,6 +144,7 @@ export default function StockItemTestResultTable({
title: t`Test`,
switchable: false,
sortable: true,
filter: ['enabled', 'required'],
render: (record: any) => {
const enabled = record.enabled ?? record.template_detail?.enabled;
const installed =

View File

@ -97,6 +97,7 @@ export function StockLocationTable({ parentId }: Readonly<{ parentId?: any }>) {
{
accessor: 'location_type',
sortable: false,
filter: ['has_location_type', 'location_type'],
render: (record: any) => record.location_type_detail?.name
}
];

View File

@ -210,3 +210,23 @@ export const expectTableColumnCount = async (page: Page, count: number) => {
const columns = page.locator('table thead tr th');
await expect(columns).toHaveCount(count);
};
// Open an "action" associated with the detail view of a page
export const openDetailAction = async (
page: Page,
menuName: string,
actionName: string
) => {
// Ensure idle state first
await page.waitForLoadState('networkidle');
// Click on the "actions" menu
await page.getByRole('button', { name: `action-menu-${menuName}` }).click();
// Click on the specified action
await page
.getByRole('menuitem', {
name: `action-menu-${menuName}-actions-${actionName}`
})
.click();
};

View File

@ -7,6 +7,7 @@ import {
getRowFromCell,
loadTab,
navigate,
openDetailAction,
setTableChoiceFilter,
showCalendarView,
showParametricView,
@ -40,7 +41,6 @@ test('Build Order - Basic Tests', async ({ browser }) => {
// We have now loaded the "Build Order" table. Check for some expected texts
await page.getByPlaceholder('Search').fill('7');
await page.getByText('On Hold').first().waitFor();
await page.getByText('Pending').first().waitFor();
// Load a particular build order
await page.getByRole('cell', { name: 'BO0017' }).click();
@ -418,6 +418,18 @@ test('Build Order - Allocation', async ({ browser }) => {
await row.getByText(/150 \/ 150/).waitFor();
// Open the allocation menu for the red widget
const mainRedWidget = await page.getByRole('cell', { name: 'Red Widget' });
const mainRedRow = await getRowFromCell(mainRedWidget);
await mainRedRow.getByLabel(/row-action-menu-/i).click();
await page
.getByRole('menuitem', { name: 'Allocate Stock', exact: true })
.waitFor();
await page.keyboard.press('Escape');
// Expand this row
await cell.click();
await page.getByRole('cell', { name: '2022-4-27', exact: true }).waitFor();
@ -494,9 +506,13 @@ test('Build Order - Allocation', async ({ browser }) => {
const redRow = await getRowFromCell(redWidget);
await redRow.getByLabel(/row-action-menu-/i).click();
await page
.getByRole('menuitem', { name: 'Allocate Stock', exact: true })
.waitFor();
const allocateStockBtn = page.getByRole('menuitem', {
name: 'Allocate Stock',
exact: true
});
await expect(allocateStockBtn).toBeEnabled();
await page
.getByRole('menuitem', { name: 'Deallocate Stock', exact: true })
.waitFor();
@ -756,8 +772,8 @@ test('Build Order - Duplicate', async ({ browser }) => {
const page = await doCachedLogin(browser);
await navigate(page, 'manufacturing/build-order/24/details');
await page.getByLabel('action-menu-build-order-').click();
await page.getByLabel('action-menu-build-order-actions-duplicate').click();
await openDetailAction(page, 'build-order', 'duplicate');
// Ensure a new reference is suggested
await expect(

View File

@ -5,12 +5,13 @@ import {
clickOnParamFilter,
loadTab,
navigate,
openDetailAction,
setTableChoiceFilter,
showParametricView
} from '../helpers.js';
import { doCachedLogin } from '../login.js';
test('Company', async ({ browser }) => {
test('Company - Basic Tests', async ({ browser }) => {
const page = await doCachedLogin(browser);
await navigate(page, 'company/1/details');
@ -42,8 +43,7 @@ test('Company', async ({ browser }) => {
await loadTab(page, 'Notes');
// Let's edit the company details
await page.getByLabel('action-menu-company-actions').click();
await page.getByLabel('action-menu-company-actions-edit').click();
await openDetailAction(page, 'company', 'edit');
await page.getByLabel('text-field-name', { exact: true }).fill('');
await page

View File

@ -9,6 +9,7 @@ import {
getRowFromCell,
loadTab,
navigate,
openDetailAction,
setTableChoiceFilter,
showParametricView,
showTableView
@ -69,6 +70,9 @@ test('Parts - Tabs', async ({ browser }) => {
test('Parts - Image Selection', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/911/details' });
await page.waitForLoadState('networkidle');
await page.waitForTimeout(250);
// Select a new image from the available images
await page
.getByRole('tabpanel', { name: 'Part Details' })
@ -1015,8 +1019,8 @@ test('Parts - Bulk Edit', async ({ browser }) => {
// Edit the category for multiple parts
await page.getByLabel('Select record 1', { exact: true }).click();
await page.getByLabel('Select record 2', { exact: true }).click();
await page.getByLabel('action-menu-part-actions').click();
await page.getByLabel('action-menu-part-actions-set-category').click();
await openDetailAction(page, 'part', 'set-category');
await page.getByLabel('related-field-category').fill('rnitu');
await page.waitForTimeout(250);
@ -1032,9 +1036,7 @@ test('Parts - Duplicate', async ({ browser }) => {
});
// Open "duplicate part" dialog
await page.getByLabel('action-menu-part-actions').click();
await page.getByLabel('action-menu-part-actions-duplicate').click();
await openDetailAction(page, 'part', 'duplicate');
// Check for expected fields
await page.getByText('Copy Image', { exact: true }).waitFor();

View File

@ -9,6 +9,7 @@ import {
clickOnRowMenu,
loadTab,
navigate,
openDetailAction,
openFilterDrawer,
setTableChoiceFilter,
showCalendarView,
@ -292,8 +293,7 @@ test('Purchase Orders - Barcodes', async ({ browser }) => {
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
// Display QR code
await page.getByLabel('action-menu-barcode-actions').click();
await page.getByLabel('action-menu-barcode-actions-view').click();
await openDetailAction(page, 'barcode', 'view');
await page.getByRole('img', { name: 'QR Code' }).waitFor();
await page.getByRole('banner').getByRole('button').click();
@ -314,8 +314,7 @@ test('Purchase Orders - Barcodes', async ({ browser }) => {
}
// Link to barcode
await page.getByLabel('action-menu-barcode-actions', { exact: true }).click();
await page.getByLabel('action-menu-barcode-actions-link-barcode').click();
await openDetailAction(page, 'barcode', 'link-barcode');
await page.getByLabel('barcode-input-scanner').click();
@ -338,8 +337,7 @@ test('Purchase Orders - Barcodes', async ({ browser }) => {
await page.getByText('Purchase Order: PO0013', { exact: true }).waitFor();
// Unlink barcode
await page.getByLabel('action-menu-barcode-actions').click();
await page.getByLabel('action-menu-barcode-actions-unlink-barcode').click();
await openDetailAction(page, 'barcode', 'unlink-barcode');
await page.getByRole('heading', { name: 'Unlink Barcode' }).waitFor();
await page.getByText('This will remove the link to').waitFor();
await page.getByRole('button', { name: 'Unlink Barcode' }).click();
@ -431,8 +429,8 @@ test('Purchase Orders - Order Parts', async ({ browser }) => {
await page.getByLabel(`Select record ${ii}`, { exact: true }).click();
}
await page.getByLabel('action-menu-part-actions').click();
await page.getByLabel('action-menu-part-actions-order-parts').click();
await openDetailAction(page, 'part', 'order-parts');
await page
.getByRole('heading', { name: 'Order Parts' })
.locator('div')
@ -456,8 +454,7 @@ test('Purchase Orders - Order Parts', async ({ browser }) => {
await navigate(page, 'part/69/');
await page.waitForURL('**/part/69/**');
await page.getByLabel('action-menu-stock-actions').click();
await page.getByLabel('action-menu-stock-actions-order').click();
await openDetailAction(page, 'stock', 'order');
// Select supplier part
await page.getByLabel('related-field-supplier_part').click();
@ -630,8 +627,7 @@ test('Purchase Orders - Duplicate', async ({ browser }) => {
url: 'purchasing/purchase-order/13/detail'
});
await page.getByLabel('action-menu-order-actions').click();
await page.getByLabel('action-menu-order-actions-duplicate').click();
await openDetailAction(page, 'order', 'duplicate');
// Ensure a new reference is suggested
await expect(

View File

@ -625,9 +625,9 @@ test('Transfer Order - Reference', async ({ browser }) => {
.click();
// Ensure a new reference is suggested
await expect(
page.getByLabel('text-field-reference', { exact: true })
).not.toBeEmpty();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(250);
// Grab the Transfer Order reference
const reference: string = await page
.getByRole('textbox', { name: 'text-field-reference' })

View File

@ -2,7 +2,7 @@ import { createApi } from './api';
/** Unit tests for form validation, rendering, etc */
import { expect, test } from './baseFixtures';
import { stevenuser } from './defaults';
import { navigate } from './helpers';
import { navigate, openDetailAction } from './helpers';
import { doCachedLogin } from './login';
// Test hover form action in related fields
@ -84,8 +84,7 @@ test('Forms - Stock Item Validation', async ({ browser }) => {
await page.getByRole('button', { name: 'Submit' }).click();
// Edit the resulting stock item
await page.getByLabel('action-menu-stock-item-actions').click();
await page.getByLabel('action-menu-stock-item-actions-edit').click();
await openDetailAction(page, 'stock-item', 'edit');
await page.getByLabel('number-field-purchase_price').fill('-1');

View File

@ -1,6 +1,6 @@
import { expect, test } from './baseFixtures.js';
import { logoutUrl, noaccessuser } from './defaults.js';
import { navigate } from './helpers.js';
import { navigate, openDetailAction } from './helpers.js';
import { doLogin } from './login.js';
import { TOTP } from 'otpauth';
@ -54,8 +54,8 @@ test('Login - Change Password', async ({ page }) => {
// Navigate to the 'change password' page
await navigate(page, 'settings/user/account', { waitUntil: 'networkidle' });
await page.getByLabel('action-menu-account-actions').click();
await page.getByLabel('action-menu-account-actions-change-password').click();
await openDetailAction(page, 'account', 'change-password');
// First attempt with some errors
await page.getByLabel('password', { exact: true }).fill('youshallnotpass');

View File

@ -9,6 +9,41 @@ import {
} from './helpers.js';
import { doCachedLogin } from './login.js';
// Test filtering by "quick filter" actions (against table columns)
test('Tables - Quick Filters', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: 'part/category/index/parts/'
});
await clearTableFilters(page);
await page
.getByRole('button', { name: 'Part Not sorted' })
.getByRole('button')
.first()
.click();
await page.getByRole('combobox', { name: 'choice-filter-active' }).click();
await page.getByRole('option', { name: 'Yes' }).click();
await page
.getByRole('button', { name: 'Part Not sorted' })
.getByRole('button')
.first()
.click();
await page.getByRole('combobox', { name: 'choice-filter-locked' }).click();
await page.getByRole('option', { name: 'No' }).click();
await page
.getByRole('button', { name: 'IPN Not sorted' })
.getByRole('button')
.first()
.click();
await page.getByRole('combobox', { name: 'choice-filter-has_ipn' }).click();
await page.getByRole('option', { name: 'Yes' }).click();
await page.getByRole('cell', { name: 'ENCAB' }).first().waitFor();
});
test('Tables - Filters', async ({ browser }) => {
// Head to the "build order list" page
const page = await doCachedLogin(browser, { url: 'manufacturing/index/' });
@ -46,7 +81,10 @@ test('Tables - Filters', async ({ browser }) => {
await page.getByRole('button', { name: 'Add Filter' }).click();
await page.getByRole('combobox', { name: 'Filter' }).click();
await page.getByRole('option', { name: 'Outstanding' }).click();
await page.getByRole('combobox', { name: 'Value' }).click();
await page
.getByRole('combobox', { name: 'choice-filter-outstanding' })
.click();
await page.getByRole('option', { name: 'Yes' }).click();
// Save the filter group

View File

@ -52,7 +52,11 @@ export default defineConfig((cfg) =>
},
lib: {
entry: {
index: resolve(__dirname, 'lib/index.ts')
index: resolve(__dirname, 'lib/index.ts'),
'plugin/InventreeHmrPlugin': resolve(
__dirname,
'lib/plugin/InventreeHmrPlugin.tsx'
)
},
name: 'InvenTree',
formats: ['es']

View File

@ -5,6 +5,7 @@ import json
import os
import pathlib
import re
import shlex
import shutil
import subprocess
import sys
@ -17,7 +18,7 @@ from typing import Optional
import invoke
from invoke import Collection, task
from invoke.exceptions import UnexpectedExit
from invoke.exceptions import Exit, UnexpectedExit
def safe_value(fnc):
@ -497,6 +498,43 @@ def manage(c, cmd, pty: bool = False, env=None, verbose: bool = False, **kwargs)
)
def manage_interactive(cmd: str, env=None, verbose: bool = False):
"""Run a Django management command with inherited stdio.
This bypasses Invoke PTY mediation and mirrors direct shell usage, which is
required for some interactive commands in Docker environments.
Args:
cmd: Django management command and arguments.
env: Optional environment variables to add for command execution.
verbose: If True, print the resolved command before execution.
Raises:
Exit: If the subprocess returns a non-zero exit code.
"""
args = ['python3', 'manage.py', *shlex.split(cmd)]
# Keep behavior aligned with `manage`: default to quiet output.
if '-v' not in cmd and '--verbosity' not in cmd:
args.extend(['-v', '1' if verbose else '0'])
if verbose:
info(f'Running interactive command: {" ".join(args)}')
cmd_env = dict(os.environ)
if env:
cmd_env.update(env)
# Avoid Invoke's PTY stdin mediation for interactive commands; run with
# inherited stdio to match direct `manage.py` behavior in Docker TTYs.
result = subprocess.run(args, cwd=manage_py_dir(), env=cmd_env, check=False)
if result.returncode != 0:
error(f"ERROR: InvenTree command failed: '{' '.join(args)}'")
warning('- Refer to the error messages in the log above for more information')
raise Exit(code=result.returncode)
def installed_apps(c) -> list[str]:
"""Returns a list of all installed apps, including plugins."""
result = manage(c, 'list_apps', pty=False, hide=True)
@ -762,7 +800,7 @@ def shell(c):
@task
def superuser(c):
"""Create a superuser/admin account for the database."""
manage(c, 'createsuperuser', pty=True)
manage_interactive('createsuperuser')
@task
@ -1456,7 +1494,10 @@ def delete_data(c, force: bool = False, migrate: bool = False, verbose: bool = F
if migrate:
manage(c, 'migrate --run-syncdb', verbose=verbose)
manage(c, f'flush{" --noinput" if force else ""}', verbose=verbose)
if force:
manage(c, 'flush --noinput', verbose=verbose)
else:
manage_interactive('flush', verbose=verbose)
success('Existing data deleted')