2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

Dynamic filters (#9290)

* Add attributes to TableFilter type def

* Refactoring

* Refactor ProjectCodeFilter

* Provide simple string rendering of a dynamic filter

* Refactor ResponsibleFilter

* Further refactoring

* More refactoring

* Fix placeholder value
This commit is contained in:
Oliver 2025-03-13 13:09:37 +11:00 committed by GitHub
parent 7a43c3a83e
commit b25bf5e669
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 153 additions and 209 deletions

View File

@ -6,9 +6,7 @@ import { useQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { useApi } from '../contexts/ApiContext';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { resolveItem } from '../functions/conversion';
import { apiUrl } from '../states/ApiState';
import type { TableFilterChoice } from '../tables/Filter';
type UseFilterProps = {
@ -67,42 +65,3 @@ export function useFilters(props: UseFilterProps) {
refresh
};
}
// Provide list of project code filters
export function useProjectCodeFilters() {
return useFilters({
url: apiUrl(ApiEndpoints.project_code_list),
transform: (item) => ({
value: item.pk,
label: item.code
})
});
}
// Provide list of user filters
export function useUserFilters() {
return useFilters({
url: apiUrl(ApiEndpoints.user_list),
params: {
is_active: true
},
transform: (item) => ({
value: item.pk,
label: item.username
})
});
}
// Provide list of owner filters
export function useOwnerFilters() {
return useFilters({
url: apiUrl(ApiEndpoints.owner_list),
params: {
is_active: true
},
transform: (item) => ({
value: item.pk,
label: item.name
})
});
}

View File

@ -4,7 +4,9 @@ import type {
StatusCodeInterface,
StatusCodeListInterface
} from '../components/render/StatusRenderer';
import type { ModelType } from '../enums/ModelType';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { ModelType } from '../enums/ModelType';
import { apiUrl } from '../states/ApiState';
import { useGlobalSettingsState } from '../states/SettingsState';
import { type StatusLookup, useGlobalStatusState } from '../states/StatusState';
@ -23,8 +25,9 @@ export type TableFilterChoice = {
* choice: A filter which allows selection from a list of (supplied)
* date: A filter which allows selection from a date input
* text: A filter which allows raw text input
* api: A filter which fetches its options from an API endpoint
*/
export type TableFilterType = 'boolean' | 'choice' | 'date' | 'text';
export type TableFilterType = 'boolean' | 'choice' | 'date' | 'text' | 'api';
/**
* Interface for the table filter type. Provides a number of options for selecting filter value:
@ -39,6 +42,9 @@ export type TableFilterType = 'boolean' | 'choice' | 'date' | 'text';
* value: The current value of the filter
* displayValue: The current display value of the filter
* active: Whether the filter is active (false = hidden, not used)
* apiUrl: The API URL to use for fetching dynamic filter options
* model: The model type to use for fetching dynamic filter options
* modelRenderer: A function to render a simple text version of the model type
*/
export type TableFilter = {
name: string;
@ -51,6 +57,9 @@ export type TableFilter = {
value?: any;
displayValue?: any;
active?: boolean;
apiUrl?: string;
model?: ModelType;
modelRenderer?: (instance: any) => string;
};
/**
@ -247,9 +256,7 @@ export function OrderStatusFilter({
};
}
export function ProjectCodeFilter({
choices
}: { choices: TableFilterChoice[] }): TableFilter {
export function ProjectCodeFilter(): TableFilter {
const globalSettings = useGlobalSettingsState.getState();
const enabled = globalSettings.isSet('PROJECT_CODES_ENABLED', true);
@ -258,28 +265,73 @@ export function ProjectCodeFilter({
label: t`Project Code`,
description: t`Filter by project code`,
active: enabled,
choices: choices
type: 'api',
apiUrl: apiUrl(ApiEndpoints.project_code_list),
model: ModelType.projectcode,
modelRenderer: (instance) => instance.code
};
}
export function ResponsibleFilter({
choices
}: { choices: TableFilterChoice[] }): TableFilter {
export function OwnerFilter({
name,
label,
description
}: {
name: string;
label: string;
description: string;
}): TableFilter {
return {
name: name,
label: label,
description: description,
type: 'api',
apiUrl: apiUrl(ApiEndpoints.owner_list),
model: ModelType.owner,
modelRenderer: (instance: any) => instance.name
};
}
export function ResponsibleFilter(): TableFilter {
return OwnerFilter({
name: 'assigned_to',
label: t`Responsible`,
description: t`Filter by responsible owner`,
choices: choices
description: t`Filter by responsible owner`
});
}
export function UserFilter({
name,
label,
description
}: {
name?: string;
label?: string;
description?: string;
}): TableFilter {
return {
name: name ?? 'user',
label: label ?? t`User`,
description: description ?? t`Filter by user`,
type: 'api',
apiUrl: apiUrl(ApiEndpoints.user_list),
model: ModelType.user,
modelRenderer: (instance: any) => instance.username
};
}
export function CreatedByFilter({
choices
}: { choices: TableFilterChoice[] }): TableFilter {
return {
export function CreatedByFilter(): TableFilter {
return UserFilter({
name: 'created_by',
label: t`Created By`,
description: t`Filter by user who created the order`,
choices: choices
};
description: t`Filter by user who created the order`
});
}
export function IssuedByFilter(): TableFilter {
return UserFilter({
name: 'issued_by',
label: t`Issued By`,
description: t`Filter by user who issued the order`
});
}

View File

@ -19,6 +19,7 @@ import dayjs from 'dayjs';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { IconCheck } from '@tabler/icons-react';
import { StandaloneField } from '../components/forms/StandaloneField';
import { StylishText } from '../components/items/StylishText';
import type { TableState } from '../hooks/UseTable';
import {
@ -64,13 +65,15 @@ function FilterItem({
}
function FilterElement({
filterType,
filterName,
filterProps,
valueOptions,
onValueChange
}: {
filterType: TableFilterType;
filterName: string;
filterProps: TableFilter;
valueOptions: TableFilterChoice[];
onValueChange: (value: string | null) => void;
onValueChange: (value: string | null, displayValue?: any) => void;
}) {
const setDateValue = useCallback(
(value: DateValue) => {
@ -86,7 +89,23 @@ function FilterElement({
const [textValue, setTextValue] = useState<string>('');
switch (filterType) {
switch (filterProps.type) {
case 'api':
return (
<StandaloneField
fieldName={`filter_value_${filterName}`}
fieldDefinition={{
field_type: 'related field',
api_url: filterProps.apiUrl,
placeholder: t`Select filter value`,
model: filterProps.model,
label: t`Select filter value`,
onValueChange: (value: any, instance: any) => {
onValueChange(value, filterProps.modelRenderer?.(instance));
}
}}
/>
);
case 'text':
return (
<TextInput
@ -124,7 +143,7 @@ function FilterElement({
return (
<Select
data={valueOptions}
searchable={filterType != 'boolean'}
searchable={filterProps.type == 'choice'}
label={t`Value`}
placeholder={t`Select filter value`}
onChange={(value: string | null) => onValueChange(value)}
@ -177,23 +196,32 @@ function FilterAddGroup({
return getTableFilterOptions(filter);
}, [selectedFilter]);
// Determine the "type" of filter (default = boolean)
const filterType: TableFilterType = useMemo(() => {
const filter = availableFilters?.find((flt) => flt.name === selectedFilter);
if (filter?.type) {
// Determine the filter "type" - if it is not supplied
const getFilterType = (filter: TableFilter): TableFilterType => {
if (filter.type) {
return filter.type;
} else if (filter?.choices) {
// If choices are provided, it is a choice filter
} else if (filter.apiUrl && filter.model) {
return 'api';
} else if (filter.choices || filter.choiceFunction) {
return 'choice';
} else {
// Default fallback
return 'boolean';
}
}, [selectedFilter]);
};
// Extract filter definition
const filterProps: TableFilter | undefined = useMemo(() => {
const filter = availableFilters?.find((flt) => flt.name === selectedFilter);
if (filter) {
filter.type = getFilterType(filter);
}
return filter;
}, [availableFilters, selectedFilter]);
const setSelectedValue = useCallback(
(value: string | null) => {
(value: string | null, displayValue?: any) => {
// Find the matching filter
const filter: TableFilter | undefined = availableFilters.find(
(flt) => flt.name === selectedFilter
@ -211,7 +239,8 @@ function FilterAddGroup({
const newFilter: TableFilter = {
...filter,
value: value,
displayValue: valueOptions.find((v) => v.value === value)?.label
displayValue:
displayValue ?? valueOptions.find((v) => v.value === value)?.label
};
tableState.setActiveFilters([...filters, newFilter]);
@ -233,9 +262,10 @@ function FilterAddGroup({
onChange={(value: string | null) => setSelectedFilter(value)}
maxDropdownHeight={800}
/>
{selectedFilter && (
{selectedFilter && filterProps && (
<FilterElement
filterType={filterType}
filterName={selectedFilter}
filterProps={filterProps}
valueOptions={valueOptions}
onValueChange={setSelectedValue}
/>

View File

@ -8,13 +8,6 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useBuildOrderFields } from '../../forms/BuildForms';
import { shortenString } from '../../functions/tables';
import {
useFilters,
useOwnerFilters,
useProjectCodeFilters,
useUserFilters
} from '../../hooks/UseFilter';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
@ -37,6 +30,7 @@ import {
CreatedAfterFilter,
CreatedBeforeFilter,
HasProjectCodeFilter,
IssuedByFilter,
MaxDateFilter,
MinDateFilter,
OrderStatusFilter,
@ -128,21 +122,6 @@ export function BuildOrderTable({
];
}, [parentBuildId]);
const projectCodeFilters = useProjectCodeFilters();
const ownerFilters = useOwnerFilters();
const userFilters = useUserFilters();
const categoryFilters = useFilters({
url: apiUrl(ApiEndpoints.category_list),
transform: (item) => ({
value: item.pk,
label: shortenString({
str: item.pathstring,
len: 50
})
})
});
const tableFilters: TableFilter[] = useMemo(() => {
const filters: TableFilter[] = [
OutstandingFilter(),
@ -171,20 +150,17 @@ export function BuildOrderTable({
},
CompletedBeforeFilter(),
CompletedAfterFilter(),
ProjectCodeFilter({ choices: projectCodeFilters.choices }),
ProjectCodeFilter(),
HasProjectCodeFilter(),
{
name: 'issued_by',
label: t`Issued By`,
description: t`Filter by user who issued this order`,
choices: userFilters.choices
},
ResponsibleFilter({ choices: ownerFilters.choices }),
IssuedByFilter(),
ResponsibleFilter(),
{
name: 'category',
label: t`Category`,
description: t`Filter by part category`,
choices: categoryFilters.choices
apiUrl: apiUrl(ApiEndpoints.category_list),
model: ModelType.partcategory,
modelRenderer: (instance: any) => instance.name
}
];
@ -199,13 +175,7 @@ export function BuildOrderTable({
}
return filters;
}, [
partId,
categoryFilters.choices,
projectCodeFilters.choices,
ownerFilters.choices,
userFilters.choices
]);
}, [partId]);
const user = useUserState();

View File

@ -8,11 +8,6 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { usePurchaseOrderFields } from '../../forms/PurchaseOrderForms';
import {
useOwnerFilters,
useProjectCodeFilters,
useUserFilters
} from '../../hooks/UseFilter';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
@ -66,10 +61,6 @@ export function PurchaseOrderTable({
const table = useTable('purchase-order');
const user = useUserState();
const projectCodeFilters = useProjectCodeFilters();
const responsibleFilters = useOwnerFilters();
const createdByFilters = useUserFilters();
const tableFilters: TableFilter[] = useMemo(() => {
return [
OrderStatusFilter({ model: ModelType.purchaseorder }),
@ -98,16 +89,12 @@ export function PurchaseOrderTable({
},
CompletedBeforeFilter(),
CompletedAfterFilter(),
ProjectCodeFilter({ choices: projectCodeFilters.choices }),
ProjectCodeFilter(),
HasProjectCodeFilter(),
ResponsibleFilter({ choices: responsibleFilters.choices }),
CreatedByFilter({ choices: createdByFilters.choices })
ResponsibleFilter(),
CreatedByFilter()
];
}, [
projectCodeFilters.choices,
responsibleFilters.choices,
createdByFilters.choices
]);
}, []);
const tableColumns = useMemo(() => {
return [

View File

@ -8,11 +8,6 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useReturnOrderFields } from '../../forms/ReturnOrderForms';
import {
useOwnerFilters,
useProjectCodeFilters,
useUserFilters
} from '../../hooks/UseFilter';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
@ -63,10 +58,6 @@ export function ReturnOrderTable({
const table = useTable(!!partId ? 'returnorders-part' : 'returnorders-index');
const user = useUserState();
const projectCodeFilters = useProjectCodeFilters();
const responsibleFilters = useOwnerFilters();
const createdByFilters = useUserFilters();
const tableFilters: TableFilter[] = useMemo(() => {
const filters: TableFilter[] = [
OrderStatusFilter({ model: ModelType.returnorder }),
@ -96,9 +87,9 @@ export function ReturnOrderTable({
CompletedBeforeFilter(),
CompletedAfterFilter(),
HasProjectCodeFilter(),
ProjectCodeFilter({ choices: projectCodeFilters.choices }),
ResponsibleFilter({ choices: responsibleFilters.choices }),
CreatedByFilter({ choices: createdByFilters.choices })
ProjectCodeFilter(),
ResponsibleFilter(),
CreatedByFilter()
];
if (!!partId) {
@ -111,12 +102,7 @@ export function ReturnOrderTable({
}
return filters;
}, [
partId,
projectCodeFilters.choices,
responsibleFilters.choices,
createdByFilters.choices
]);
}, [partId]);
const tableColumns = useMemo(() => {
return [

View File

@ -9,11 +9,6 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useSalesOrderFields } from '../../forms/SalesOrderForms';
import {
useOwnerFilters,
useProjectCodeFilters,
useUserFilters
} from '../../hooks/UseFilter';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
@ -64,10 +59,6 @@ export function SalesOrderTable({
const table = useTable(!!partId ? 'salesorder-part' : 'salesorder-index');
const user = useUserState();
const projectCodeFilters = useProjectCodeFilters();
const responsibleFilters = useOwnerFilters();
const createdByFilters = useUserFilters();
const tableFilters: TableFilter[] = useMemo(() => {
const filters: TableFilter[] = [
OrderStatusFilter({ model: ModelType.salesorder }),
@ -97,9 +88,9 @@ export function SalesOrderTable({
CompletedBeforeFilter(),
CompletedAfterFilter(),
HasProjectCodeFilter(),
ProjectCodeFilter({ choices: projectCodeFilters.choices }),
ResponsibleFilter({ choices: responsibleFilters.choices }),
CreatedByFilter({ choices: createdByFilters.choices })
ProjectCodeFilter(),
ResponsibleFilter(),
CreatedByFilter()
];
if (!!partId) {
@ -112,12 +103,7 @@ export function SalesOrderTable({
}
return filters;
}, [
partId,
projectCodeFilters.choices,
responsibleFilters.choices,
createdByFilters.choices
]);
}, [partId]);
const salesOrderFields = useSalesOrderFields({});

View File

@ -21,14 +21,13 @@ import { RenderUser } from '../../components/render/User';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { UserRoles } from '../../enums/Roles';
import { shortenString } from '../../functions/tables';
import { useUserFilters } from '../../hooks/UseFilter';
import { useDeleteApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { useUserState } from '../../states/UserState';
import type { TableColumn } from '../Column';
import type { TableFilter } from '../Filter';
import { type TableFilter, UserFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowDeleteAction } from '../RowActions';
@ -148,8 +147,6 @@ export default function BarcodeScanHistoryTable() {
const globalSettings = useGlobalSettingsState();
const userFilters = useUserFilters();
const [opened, { open, close }] = useDisclosure(false);
const tableColumns: TableColumn[] = useMemo(() => {
@ -204,19 +201,14 @@ export default function BarcodeScanHistoryTable() {
const filters: TableFilter[] = useMemo(() => {
return [
{
name: 'user',
label: t`User`,
choices: userFilters.choices,
description: t`Filter by user`
},
UserFilter({}),
{
name: 'result',
label: t`Result`,
description: t`Filter by result`
}
];
}, [userFilters]);
}, []);
const canDelete: boolean = useMemo(() => {
return user.isStaff() && user.hasDeleteRole(UserRoles.admin);

View File

@ -9,7 +9,7 @@ import { RenderUser } from '../../components/render/User';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { dataImporterSessionFields } from '../../forms/ImporterForms';
import { useFilters, useUserFilters } from '../../hooks/UseFilter';
import { useFilters } from '../../hooks/UseFilter';
import {
useCreateApiFormModal,
useDeleteApiFormModal
@ -18,7 +18,7 @@ import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import type { TableColumn } from '../Column';
import { DateColumn, StatusColumn } from '../ColumnRenderers';
import { StatusFilterOptions, type TableFilter } from '../Filter';
import { StatusFilterOptions, type TableFilter, UserFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { type RowAction, RowDeleteAction } from '../RowActions';
@ -88,8 +88,6 @@ export default function ImportSesssionTable() {
];
}, []);
const userFilter = useUserFilters();
const modelTypeFilters = useFilters({
url: apiUrl(ApiEndpoints.import_session_list),
method: 'OPTIONS',
@ -116,14 +114,9 @@ export default function ImportSesssionTable() {
description: t`Filter by import session status`,
choiceFunction: StatusFilterOptions(ModelType.importsession)
},
{
name: 'user',
label: t`User`,
description: t`Filter by user`,
choices: userFilter.choices
}
UserFilter({})
];
}, [modelTypeFilters.choices, userFilter.choices]);
}, [modelTypeFilters.choices]);
const tableActions = useMemo(() => {
return [

View File

@ -8,7 +8,6 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { stockLocationFields } from '../../forms/StockForms';
import { useFilters } from '../../hooks/UseFilter';
import {
useCreateApiFormModal,
useEditApiFormModal
@ -29,14 +28,6 @@ export function StockLocationTable({ parentId }: Readonly<{ parentId?: any }>) {
const table = useTable('stocklocation');
const user = useUserState();
const locationTypeFilters = useFilters({
url: apiUrl(ApiEndpoints.stock_location_type_list),
transform: (item) => ({
value: item.pk,
label: item.name
})
});
const tableFilters: TableFilter[] = useMemo(() => {
return [
{
@ -62,10 +53,12 @@ export function StockLocationTable({ parentId }: Readonly<{ parentId?: any }>) {
name: 'location_type',
label: t`Location Type`,
description: t`Filter by location type`,
choices: locationTypeFilters.choices
apiUrl: apiUrl(ApiEndpoints.stock_location_type_list),
model: ModelType.stocklocationtype,
modelRenderer: (instance: any) => instance.name
}
];
}, [locationTypeFilters.choices]);
}, []);
const tableColumns: TableColumn[] = useMemo(() => {
return [

View File

@ -19,12 +19,11 @@ import {
import { RenderUser } from '../../components/render/User';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { useUserFilters } from '../../hooks/UseFilter';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import type { TableColumn } from '../Column';
import { DateColumn, DescriptionColumn } from '../ColumnRenderers';
import type { TableFilter } from '../Filter';
import { type TableFilter, UserFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
type StockTrackingEntry = {
@ -37,8 +36,6 @@ export function StockTrackingTable({ itemId }: Readonly<{ itemId: number }>) {
const navigate = useNavigate();
const table = useTable('stock_tracking');
const userFilters = useUserFilters();
// Render "details" for a stock tracking record
const renderDetails = useCallback(
(record: any) => {
@ -186,14 +183,13 @@ export function StockTrackingTable({ itemId }: Readonly<{ itemId: number }>) {
const filters: TableFilter[] = useMemo(() => {
return [
{
UserFilter({
name: 'user',
label: t`User`,
choices: userFilters.choices,
description: t`Filter by user`
}
})
];
}, [userFilters]);
}, []);
const tableColumns: TableColumn[] = useMemo(() => {
return [