diff --git a/CHANGELOG.md b/CHANGELOG.md
index dbc6e48ac1..052cdafdcd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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.
diff --git a/docs/docs/assets/images/concepts/ui_table_column_filter_popover.png b/docs/docs/assets/images/concepts/ui_table_column_filter_popover.png
new file mode 100644
index 0000000000..8e979ed999
Binary files /dev/null and b/docs/docs/assets/images/concepts/ui_table_column_filter_popover.png differ
diff --git a/docs/docs/concepts/user_interface.md b/docs/docs/concepts/user_interface.md
index 64255d6145..c9973c514c 100644
--- a/docs/docs/concepts/user_interface.md
+++ b/docs/docs/concepts/user_interface.md
@@ -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.
diff --git a/src/frontend/lib/components/TableColumnSelect.tsx b/src/frontend/lib/components/TableColumnSelect.tsx
index c686ce65c6..5b99ffc82f 100644
--- a/src/frontend/lib/components/TableColumnSelect.tsx
+++ b/src/frontend/lib/components/TableColumnSelect.tsx
@@ -23,7 +23,7 @@ export function TableColumnSelect({
{t`Select Columns`}
{columns
- .filter((col) => col.switchable ?? true)
+ .filter((col) => (col.switchable ?? true) && !col.propHidden)
.map((col) => (
= {
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;
diff --git a/src/frontend/src/tables/ColumnRenderers.tsx b/src/frontend/src/tables/ColumnRenderers.tsx
index f3c61e8990..b2d4eb4f81 100644
--- a/src/frontend/src/tables/ColumnRenderers.tsx
+++ b/src/frontend/src/tables/ColumnRenderers.tsx
@@ -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) => (
@@ -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
});
diff --git a/src/frontend/src/tables/FilterSelectDrawer.tsx b/src/frontend/src/tables/FilterSelectDrawer.tsx
index 78eecb16ea..8be8e4f9f8 100644
--- a/src/frontend/src/tables/FilterSelectDrawer.tsx
+++ b/src/frontend/src/tables/FilterSelectDrawer.tsx
@@ -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 (
}
- onChange={(e) => setTextValue(e.currentTarget.value)}
- onKeyDown={(e) => {
+ onChange={(e: React.ChangeEvent) =>
+ setTextValue(e.currentTarget.value)
+ }
+ onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
onValueChange(textValue);
}
@@ -267,9 +272,11 @@ function FilterElement({
case 'date':
return (
);
case 'choice':
@@ -278,8 +285,9 @@ function FilterElement({
return (
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 (
+
+
+ {filter.label ?? filter.name}
+
+ {activeFilter ? (
+
+
+ {activeFilter.displayValue ?? activeFilter.value}
+
+
+
+
+
+ ) : (
+
+ )}
+
+ );
+}
+
+/*
+ * 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 (
+
+ {filters.map((filter, index) => (
+
+ {index > 0 && }
+
+
+ ))}
+
+ );
+}
+
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) =>
+ setSaveName(e.currentTarget.value)
+ }
+ onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter') confirmSave();
if (e.key === 'Escape') setSaving(false);
}}
diff --git a/src/frontend/src/tables/InvenTreeTable.tsx b/src/frontend/src/tables/InvenTreeTable.tsx
index 9bcabdd738..29ac6661cc 100644
--- a/src/frontend/src/tables/InvenTreeTable.tsx
+++ b/src/frontend/src/tables/InvenTreeTable.tsx
@@ -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>({
// 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>({
};
}
+ // 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 }) => (
+
+ )
+ : 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>({
return cols;
}, [
columns,
+ filters,
fieldNames,
tableProps.rowActions,
tableState.hiddenColumns,
- tableState.selectedRecords
+ tableState.selectedRecords,
+ tableState.filterSet.activeFilters
]);
// Callback when column visibility is toggled
diff --git a/src/frontend/src/tables/bom/BomTable.tsx b/src/frontend/src/tables/bom/BomTable.tsx
index 29e4b2cad4..a1ead32be2 100644
--- a/src/frontend/src/tables/bom/BomTable.tsx
+++ b/src/frontend/src/tables/bom/BomTable.tsx
@@ -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
diff --git a/src/frontend/src/tables/bom/UsedInTable.tsx b/src/frontend/src/tables/bom/UsedInTable.tsx
index 1d8c665640..28cb1f2060 100644
--- a/src/frontend/src/tables/bom/UsedInTable.tsx
+++ b/src/frontend/src/tables/bom/UsedInTable.tsx
@@ -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
diff --git a/src/frontend/src/tables/build/BuildLineTable.tsx b/src/frontend/src/tables/build/BuildLineTable.tsx
index 8631204214..ab9493ebd5 100644
--- a/src/frontend/src/tables/build/BuildLineTable.tsx
+++ b/src/frontend/src/tables/build/BuildLineTable.tsx
@@ -322,6 +322,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 +357,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 +384,7 @@ export default function BuildLineTable({
BooleanColumn({
accessor: 'part_detail.trackable',
ordering: 'trackable',
+ filter: 'tracked',
hidden: hasOutput,
defaultVisible: false
}),
@@ -457,6 +461,7 @@ export default function BuildLineTable({
{
accessor: 'available_stock',
sortable: true,
+ filter: 'available',
switchable: false,
render: renderAvailableColumn
},
@@ -481,12 +486,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 +539,7 @@ export default function BuildLineTable({
{
accessor: 'consumed',
sortable: true,
+ filter: 'consumed',
hidden: !!output?.pk,
minWidth: 125,
render: (record: any) => {
diff --git a/src/frontend/src/tables/build/BuildOrderTable.tsx b/src/frontend/src/tables/build/BuildOrderTable.tsx
index 29b825ce27..12eaf08167 100644
--- a/src/frontend/src/tables/build/BuildOrderTable.tsx
+++ b/src/frontend/src/tables/build/BuildOrderTable.tsx
@@ -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',
diff --git a/src/frontend/src/tables/company/CompanyTable.tsx b/src/frontend/src/tables/company/CompanyTable.tsx
index f4a833955f..a3ebc82422 100644
--- a/src/frontend/src/tables/company/CompanyTable.tsx
+++ b/src/frontend/src/tables/company/CompanyTable.tsx
@@ -64,6 +64,7 @@ export function CompanyTable({
DescriptionColumn({}),
BooleanColumn({
accessor: 'active',
+ filter: 'active',
title: t`Active`,
sortable: true,
switchable: true
diff --git a/src/frontend/src/tables/general/ParameterTable.tsx b/src/frontend/src/tables/general/ParameterTable.tsx
index 6883682ab0..996f53698d 100644
--- a/src/frontend/src/tables/general/ParameterTable.tsx
+++ b/src/frontend/src/tables/general/ParameterTable.tsx
@@ -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`
})
];
diff --git a/src/frontend/src/tables/part/PartCategoryTable.tsx b/src/frontend/src/tables/part/PartCategoryTable.tsx
index 6c972f6321..649df800fd 100644
--- a/src/frontend/src/tables/part/PartCategoryTable.tsx
+++ b/src/frontend/src/tables/part/PartCategoryTable.tsx
@@ -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 ;
- }
- },
+ defaultVisible: false
+ }),
{
accessor: 'part_count',
sortable: true
diff --git a/src/frontend/src/tables/part/PartPurchaseOrdersTable.tsx b/src/frontend/src/tables/part/PartPurchaseOrdersTable.tsx
index f4cff53948..93bb7b1805 100644
--- a/src/frontend/src/tables/part/PartPurchaseOrdersTable.tsx
+++ b/src/frontend/src/tables/part/PartPurchaseOrdersTable.tsx
@@ -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
}),
{
diff --git a/src/frontend/src/tables/part/PartTable.tsx b/src/frontend/src/tables/part/PartTable.tsx
index 1759fc79bc..583bdacac2 100644
--- a/src/frontend/src/tables/part/PartTable.tsx
+++ b/src/frontend/src/tables/part/PartTable.tsx
@@ -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({})
];
}
diff --git a/src/frontend/src/tables/part/PartTestResultTable.tsx b/src/frontend/src/tables/part/PartTestResultTable.tsx
index a2079ee6a7..7f956c68d0 100644
--- a/src/frontend/src/tables/part/PartTestResultTable.tsx
+++ b/src/frontend/src/tables/part/PartTestResultTable.tsx
@@ -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
},
diff --git a/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx b/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx
index 5825bf97a4..0722b58894 100644
--- a/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx
+++ b/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx
@@ -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) => (
)
diff --git a/src/frontend/src/tables/purchasing/SupplierPartTable.tsx b/src/frontend/src/tables/purchasing/SupplierPartTable.tsx
index 8dbf7337ea..4e38368bce 100644
--- a/src/frontend/src/tables/purchasing/SupplierPartTable.tsx
+++ b/src/frontend/src/tables/purchasing/SupplierPartTable.tsx
@@ -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) => (
@@ -179,6 +181,7 @@ export function SupplierPartTable({
accessor: 'available',
sortable: true,
defaultVisible: false,
+ filter: 'has_stock',
render: (record: any) => {
const extra = [];
diff --git a/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx b/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx
index 46712b967d..26cf4433ce 100644
--- a/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx
+++ b/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx
@@ -159,6 +159,7 @@ export default function SalesOrderShipmentTable({
{
accessor: 'checked',
title: t`Checked`,
+ filter: 'checked',
switchable: true,
sortable: false,
render: (record: any) =>
@@ -168,12 +169,14 @@ export default function SalesOrderShipmentTable({
title: t`Shipped`,
switchable: true,
sortable: false,
+ filter: 'shipped',
render: (record: any) =>
},
{
accessor: 'delivered',
title: t`Delivered`,
switchable: true,
+ filter: 'delivered',
sortable: false,
render: (record: any) =>
},
diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx
index 9444d00bbc..d6d4aa1472 100644
--- a/src/frontend/src/tables/stock/StockItemTable.tsx
+++ b/src/frontend/src/tables/stock/StockItemTable.tsx
@@ -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']
})
];
}
diff --git a/src/frontend/src/tables/stock/StockItemTestResultTable.tsx b/src/frontend/src/tables/stock/StockItemTestResultTable.tsx
index 5526389eba..cb5d9098e3 100644
--- a/src/frontend/src/tables/stock/StockItemTestResultTable.tsx
+++ b/src/frontend/src/tables/stock/StockItemTestResultTable.tsx
@@ -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 =
diff --git a/src/frontend/src/tables/stock/StockLocationTable.tsx b/src/frontend/src/tables/stock/StockLocationTable.tsx
index d8d6c9acd8..de1ca7795b 100644
--- a/src/frontend/src/tables/stock/StockLocationTable.tsx
+++ b/src/frontend/src/tables/stock/StockLocationTable.tsx
@@ -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
}
];
diff --git a/src/frontend/tests/helpers.ts b/src/frontend/tests/helpers.ts
index b6eeebd585..8b82527491 100644
--- a/src/frontend/tests/helpers.ts
+++ b/src/frontend/tests/helpers.ts
@@ -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();
+};
diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts
index 0bb1a0d531..8f27eb8322 100644
--- a/src/frontend/tests/pages/pui_build.spec.ts
+++ b/src/frontend/tests/pages/pui_build.spec.ts
@@ -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();
@@ -756,8 +756,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(
diff --git a/src/frontend/tests/pages/pui_company.spec.ts b/src/frontend/tests/pages/pui_company.spec.ts
index cd86544b22..96e0631621 100644
--- a/src/frontend/tests/pages/pui_company.spec.ts
+++ b/src/frontend/tests/pages/pui_company.spec.ts
@@ -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
diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts
index 39f2cc07cf..4b0301ceed 100644
--- a/src/frontend/tests/pages/pui_part.spec.ts
+++ b/src/frontend/tests/pages/pui_part.spec.ts
@@ -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();
diff --git a/src/frontend/tests/pages/pui_purchasing.spec.ts b/src/frontend/tests/pages/pui_purchasing.spec.ts
index c3bea7ecad..8b88a7c5bc 100644
--- a/src/frontend/tests/pages/pui_purchasing.spec.ts
+++ b/src/frontend/tests/pages/pui_purchasing.spec.ts
@@ -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(
diff --git a/src/frontend/tests/pages/pui_stock.spec.ts b/src/frontend/tests/pages/pui_stock.spec.ts
index de41ac9899..0f568349f1 100644
--- a/src/frontend/tests/pages/pui_stock.spec.ts
+++ b/src/frontend/tests/pages/pui_stock.spec.ts
@@ -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' })
diff --git a/src/frontend/tests/pui_forms.spec.ts b/src/frontend/tests/pui_forms.spec.ts
index 38f48099c8..5e95012bd5 100644
--- a/src/frontend/tests/pui_forms.spec.ts
+++ b/src/frontend/tests/pui_forms.spec.ts
@@ -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');
diff --git a/src/frontend/tests/pui_login.spec.ts b/src/frontend/tests/pui_login.spec.ts
index 9876a88e24..fe1e69cce5 100644
--- a/src/frontend/tests/pui_login.spec.ts
+++ b/src/frontend/tests/pui_login.spec.ts
@@ -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');
diff --git a/src/frontend/tests/pui_tables.spec.ts b/src/frontend/tests/pui_tables.spec.ts
index ded7c1fe7c..37e7197d08 100644
--- a/src/frontend/tests/pui_tables.spec.ts
+++ b/src/frontend/tests/pui_tables.spec.ts
@@ -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