2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-06-12 03:28:37 +00:00

[UI] Table column filters (#12103)

* Expose properties for column based filtering

* Adjust renderers

* Hide filter if name does not match

* Allow multiple filters on same column

* Better formatting

* Add filtering support for multiple tables

* Revert yarn.lock changes

* Fix date input props

* Updated column

* Add filter  to PartTable

* Add playwright tests for new column filters

* Update CHANGELOG

* Updated docs

* Reduce padding

* Update more table filters

* More filter columns

* Adjust playwright test

* Simplify playwright test

* Robustify playwright tests

* Add some delay

* Add some buffer time
This commit is contained in:
Oliver
2026-06-07 12:59:33 +10:00
committed by GitHub
parent a86e94c63d
commit 7f1f2dbad2
33 changed files with 407 additions and 99 deletions
@@ -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
+5 -2
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;
@@ -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
});
+147 -22
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);
}}
+53 -7
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
+6 -5
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
+2 -1
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
@@ -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) => {
@@ -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',
@@ -64,6 +64,7 @@ export function CompanyTable({
DescriptionColumn({}),
BooleanColumn({
accessor: 'active',
filter: 'active',
title: t`Active`,
sortable: true,
switchable: true
@@ -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`
})
];
@@ -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
@@ -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
}),
{
+15 -3
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({})
];
}
@@ -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
},
@@ -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} />
)
@@ -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 = [];
@@ -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} />
},
@@ -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']
})
];
}
@@ -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 =
@@ -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
}
];
+20
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();
};
+3 -3
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();
@@ -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(
+3 -3
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
+7 -5
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();
@@ -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(
+3 -3
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' })
+2 -3
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');
+3 -3
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');
+39 -1
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