mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-04 07:05:41 +00:00 
			
		
		
		
	[PUI] Table refactor (#8395)
* Refactor table header items out into new file * Improve BomItem API query * Allow table header to be removed entirely * revert BomTable * Re-add "box" component * Reimplement partlocked attribute * Fix for PartDetail - Revert to proper panels * Updated playwright tests * Additional tests
This commit is contained in:
		@@ -1708,6 +1708,10 @@ class BomItemSerializer(
 | 
			
		||||
            'sub_part__stock_items__sales_order_allocations',
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        queryset = queryset.select_related(
 | 
			
		||||
            'part__pricing_data', 'sub_part__pricing_data'
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        queryset = queryset.prefetch_related(
 | 
			
		||||
            'substitutes', 'substitutes__part__stock_items'
 | 
			
		||||
        )
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
import { randomId, useLocalStorage } from '@mantine/hooks';
 | 
			
		||||
import { useCallback, useMemo, useState } from 'react';
 | 
			
		||||
import { SetURLSearchParams, useSearchParams } from 'react-router-dom';
 | 
			
		||||
 | 
			
		||||
import { TableFilter } from '../tables/Filter';
 | 
			
		||||
 | 
			
		||||
@@ -8,19 +9,47 @@ import { TableFilter } from '../tables/Filter';
 | 
			
		||||
 *
 | 
			
		||||
 * tableKey: A unique key for the table. When this key changes, the table will be refreshed.
 | 
			
		||||
 * refreshTable: A callback function to externally refresh the table.
 | 
			
		||||
 * isLoading: A boolean flag to indicate if the table is currently loading data
 | 
			
		||||
 * setIsLoading: A function to set the isLoading flag
 | 
			
		||||
 * activeFilters: An array of active filters (saved to local storage)
 | 
			
		||||
 * setActiveFilters: A function to set the active filters
 | 
			
		||||
 * clearActiveFilters: A function to clear all active filters
 | 
			
		||||
 * queryFilters: A map of query filters (e.g. ?active=true&overdue=false) passed in the URL
 | 
			
		||||
 * setQueryFilters: A function to set the query filters
 | 
			
		||||
 * clearQueryFilters: A function to clear all query filters
 | 
			
		||||
 * expandedRecords: An array of expanded records (rows) in the table
 | 
			
		||||
 * setExpandedRecords: A function to set the expanded records
 | 
			
		||||
 * isRowExpanded: A function to determine if a record is expanded
 | 
			
		||||
 * selectedRecords: An array of selected records (rows) in the table
 | 
			
		||||
 * selectedIds: An array of primary key values for selected records
 | 
			
		||||
 * hasSelectedRecords: A boolean flag to indicate if any records are selected
 | 
			
		||||
 * setSelectedRecords: A function to set the selected records
 | 
			
		||||
 * clearSelectedRecords: A function to clear all selected records
 | 
			
		||||
 * hiddenColumns: An array of hidden column names
 | 
			
		||||
 * setHiddenColumns: A function to set the hidden columns
 | 
			
		||||
 * searchTerm: The current search term for the table
 | 
			
		||||
 * setSearchTerm: A function to set the search term
 | 
			
		||||
 * recordCount: The total number of records in the table
 | 
			
		||||
 * setRecordCount: A function to set the record count
 | 
			
		||||
 * page: The current page number
 | 
			
		||||
 * setPage: A function to set the current page number
 | 
			
		||||
 * pageSize: The number of records per page
 | 
			
		||||
 * setPageSize: A function to set the number of records per page
 | 
			
		||||
 * records: An array of records (rows) in the table
 | 
			
		||||
 * setRecords: A function to set the records
 | 
			
		||||
 * updateRecord: A function to update a single record in the table
 | 
			
		||||
 */
 | 
			
		||||
export type TableState = {
 | 
			
		||||
  tableKey: string;
 | 
			
		||||
  refreshTable: () => void;
 | 
			
		||||
  activeFilters: TableFilter[];
 | 
			
		||||
  isLoading: boolean;
 | 
			
		||||
  setIsLoading: (value: boolean) => void;
 | 
			
		||||
  activeFilters: TableFilter[];
 | 
			
		||||
  setActiveFilters: (filters: TableFilter[]) => void;
 | 
			
		||||
  clearActiveFilters: () => void;
 | 
			
		||||
  queryFilters: URLSearchParams;
 | 
			
		||||
  setQueryFilters: SetURLSearchParams;
 | 
			
		||||
  clearQueryFilters: () => void;
 | 
			
		||||
  expandedRecords: any[];
 | 
			
		||||
  setExpandedRecords: (records: any[]) => void;
 | 
			
		||||
  isRowExpanded: (pk: number) => boolean;
 | 
			
		||||
@@ -42,8 +71,6 @@ export type TableState = {
 | 
			
		||||
  records: any[];
 | 
			
		||||
  setRecords: (records: any[]) => void;
 | 
			
		||||
  updateRecord: (record: any) => void;
 | 
			
		||||
  editable: boolean;
 | 
			
		||||
  setEditable: (value: boolean) => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
@@ -58,6 +85,13 @@ export function useTable(tableName: string): TableState {
 | 
			
		||||
    return `${tableName.replaceAll('-', '')}-${randomId()}`;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Extract URL query parameters (e.g. ?active=true&overdue=false)
 | 
			
		||||
  const [queryFilters, setQueryFilters] = useSearchParams();
 | 
			
		||||
 | 
			
		||||
  const clearQueryFilters = useCallback(() => {
 | 
			
		||||
    setQueryFilters({});
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const [tableKey, setTableKey] = useState<string>(generateTableName());
 | 
			
		||||
 | 
			
		||||
  // Callback used to refresh (reload) the table
 | 
			
		||||
@@ -145,8 +179,6 @@ export function useTable(tableName: string): TableState {
 | 
			
		||||
 | 
			
		||||
  const [isLoading, setIsLoading] = useState<boolean>(false);
 | 
			
		||||
 | 
			
		||||
  const [editable, setEditable] = useState<boolean>(false);
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    tableKey,
 | 
			
		||||
    refreshTable,
 | 
			
		||||
@@ -155,6 +187,9 @@ export function useTable(tableName: string): TableState {
 | 
			
		||||
    activeFilters,
 | 
			
		||||
    setActiveFilters,
 | 
			
		||||
    clearActiveFilters,
 | 
			
		||||
    queryFilters,
 | 
			
		||||
    setQueryFilters,
 | 
			
		||||
    clearQueryFilters,
 | 
			
		||||
    expandedRecords,
 | 
			
		||||
    setExpandedRecords,
 | 
			
		||||
    isRowExpanded,
 | 
			
		||||
@@ -175,8 +210,6 @@ export function useTable(tableName: string): TableState {
 | 
			
		||||
    setPageSize,
 | 
			
		||||
    records,
 | 
			
		||||
    setRecords,
 | 
			
		||||
    updateRecord,
 | 
			
		||||
    editable,
 | 
			
		||||
    setEditable
 | 
			
		||||
    updateRecord
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -625,8 +625,10 @@ export default function PartDetail() {
 | 
			
		||||
        label: t`Bill of Materials`,
 | 
			
		||||
        icon: <IconListTree />,
 | 
			
		||||
        hidden: !part.assembly,
 | 
			
		||||
        content: (
 | 
			
		||||
        content: part?.pk ? (
 | 
			
		||||
          <BomTable partId={part.pk ?? -1} partLocked={part?.locked == true} />
 | 
			
		||||
        ) : (
 | 
			
		||||
          <Skeleton />
 | 
			
		||||
        )
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,21 +1,5 @@
 | 
			
		||||
import { t } from '@lingui/macro';
 | 
			
		||||
import {
 | 
			
		||||
  ActionIcon,
 | 
			
		||||
  Alert,
 | 
			
		||||
  Box,
 | 
			
		||||
  Group,
 | 
			
		||||
  Indicator,
 | 
			
		||||
  Space,
 | 
			
		||||
  Stack,
 | 
			
		||||
  Tooltip
 | 
			
		||||
} from '@mantine/core';
 | 
			
		||||
import {
 | 
			
		||||
  IconBarcode,
 | 
			
		||||
  IconFilter,
 | 
			
		||||
  IconFilterCancel,
 | 
			
		||||
  IconRefresh,
 | 
			
		||||
  IconTrash
 | 
			
		||||
} from '@tabler/icons-react';
 | 
			
		||||
import { Box, Stack } from '@mantine/core';
 | 
			
		||||
import { useQuery } from '@tanstack/react-query';
 | 
			
		||||
import {
 | 
			
		||||
  DataTable,
 | 
			
		||||
@@ -23,20 +7,11 @@ import {
 | 
			
		||||
  DataTableRowExpansionProps,
 | 
			
		||||
  DataTableSortStatus
 | 
			
		||||
} from 'mantine-datatable';
 | 
			
		||||
import React, {
 | 
			
		||||
  Fragment,
 | 
			
		||||
  useCallback,
 | 
			
		||||
  useEffect,
 | 
			
		||||
  useMemo,
 | 
			
		||||
  useState
 | 
			
		||||
} from 'react';
 | 
			
		||||
import { useNavigate, useSearchParams } from 'react-router-dom';
 | 
			
		||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
 | 
			
		||||
import { useNavigate } from 'react-router-dom';
 | 
			
		||||
 | 
			
		||||
import { api } from '../App';
 | 
			
		||||
import { Boundary } from '../components/Boundary';
 | 
			
		||||
import { ActionButton } from '../components/buttons/ActionButton';
 | 
			
		||||
import { ButtonMenu } from '../components/buttons/ButtonMenu';
 | 
			
		||||
import { PrintingActions } from '../components/buttons/PrintingActions';
 | 
			
		||||
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
 | 
			
		||||
import { ModelType } from '../enums/ModelType';
 | 
			
		||||
import { resolveItem } from '../functions/conversion';
 | 
			
		||||
@@ -44,16 +19,12 @@ import { cancelEvent } from '../functions/events';
 | 
			
		||||
import { extractAvailableFields, mapFields } from '../functions/forms';
 | 
			
		||||
import { navigateToLink } from '../functions/navigation';
 | 
			
		||||
import { getDetailUrl } from '../functions/urls';
 | 
			
		||||
import { useDeleteApiFormModal } from '../hooks/UseForm';
 | 
			
		||||
import { TableState } from '../hooks/UseTable';
 | 
			
		||||
import { useLocalState } from '../states/LocalState';
 | 
			
		||||
import { TableColumn } from './Column';
 | 
			
		||||
import { TableColumnSelect } from './ColumnSelect';
 | 
			
		||||
import { DownloadAction } from './DownloadAction';
 | 
			
		||||
import { TableFilter } from './Filter';
 | 
			
		||||
import { FilterSelectDrawer } from './FilterSelectDrawer';
 | 
			
		||||
import InvenTreeTableHeader from './InvenTreeTableHeader';
 | 
			
		||||
import { RowAction, RowActions } from './RowActions';
 | 
			
		||||
import { TableSearchInput } from './Search';
 | 
			
		||||
 | 
			
		||||
const defaultPageSize: number = 25;
 | 
			
		||||
const PAGE_SIZES = [10, 15, 20, 25, 50, 100, 500];
 | 
			
		||||
@@ -84,6 +55,7 @@ const PAGE_SIZES = [10, 15, 20, 25, 50, 100, 500];
 | 
			
		||||
 * @param onRowClick : (record: any, index: number, event: any) => void - Callback function when a row is clicked
 | 
			
		||||
 * @param onCellClick : (event: any, record: any, index: number, column: any, columnIndex: number) => void - Callback function when a cell is clicked
 | 
			
		||||
 * @param modelType: ModelType - The model type for the table
 | 
			
		||||
 * @param noHeader: boolean - Hide the table header
 | 
			
		||||
 */
 | 
			
		||||
export type InvenTreeTableProps<T = any> = {
 | 
			
		||||
  params?: any;
 | 
			
		||||
@@ -113,6 +85,7 @@ export type InvenTreeTableProps<T = any> = {
 | 
			
		||||
  modelType?: ModelType;
 | 
			
		||||
  rowStyle?: (record: T, index: number) => any;
 | 
			
		||||
  modelField?: string;
 | 
			
		||||
  noHeader?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
@@ -162,9 +135,6 @@ export function InvenTreeTable<T extends Record<string, any>>({
 | 
			
		||||
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
 | 
			
		||||
  // Extract URL query parameters (e.g. ?active=true&overdue=false)
 | 
			
		||||
  const [urlQueryParams, setUrlQueryParams] = useSearchParams();
 | 
			
		||||
 | 
			
		||||
  // Construct table filters - note that we can introspect filter labels from column names
 | 
			
		||||
  const filters: TableFilter[] = useMemo(() => {
 | 
			
		||||
    return (
 | 
			
		||||
@@ -286,7 +256,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
 | 
			
		||||
 | 
			
		||||
  // Update column visibility when hiddenColumns change
 | 
			
		||||
  const dataColumns: any = useMemo(() => {
 | 
			
		||||
    let cols = columns
 | 
			
		||||
    let cols: TableColumn[] = columns
 | 
			
		||||
      .filter((col) => col?.hidden != true)
 | 
			
		||||
      .map((col) => {
 | 
			
		||||
        let hidden: boolean = col.hidden ?? false;
 | 
			
		||||
@@ -298,6 +268,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
 | 
			
		||||
        return {
 | 
			
		||||
          ...col,
 | 
			
		||||
          hidden: hidden,
 | 
			
		||||
          noWrap: true,
 | 
			
		||||
          title: col.title ?? fieldNames[col.accessor] ?? `${col.accessor}`
 | 
			
		||||
        };
 | 
			
		||||
      });
 | 
			
		||||
@@ -344,86 +315,79 @@ export function InvenTreeTable<T extends Record<string, any>>({
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Filter list visibility
 | 
			
		||||
  const [filtersVisible, setFiltersVisible] = useState<boolean>(false);
 | 
			
		||||
 | 
			
		||||
  // Reset the pagination state when the search term changes
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    tableState.setPage(1);
 | 
			
		||||
  }, [tableState.searchTerm]);
 | 
			
		||||
 | 
			
		||||
  /*
 | 
			
		||||
   * Construct query filters for the current table
 | 
			
		||||
   */
 | 
			
		||||
  function getTableFilters(paginate: boolean = false) {
 | 
			
		||||
    let queryParams = {
 | 
			
		||||
      ...tableProps.params
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Add custom filters
 | 
			
		||||
    if (tableState.activeFilters) {
 | 
			
		||||
      tableState.activeFilters.forEach(
 | 
			
		||||
        (flt) => (queryParams[flt.name] = flt.value)
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Allow override of filters based on URL query parameters
 | 
			
		||||
    if (urlQueryParams) {
 | 
			
		||||
      for (let [key, value] of urlQueryParams) {
 | 
			
		||||
        queryParams[key] = value;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Add custom search term
 | 
			
		||||
    if (tableState.searchTerm) {
 | 
			
		||||
      queryParams.search = tableState.searchTerm;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Pagination
 | 
			
		||||
    if (tableProps.enablePagination && paginate) {
 | 
			
		||||
      let pageSize = tableState.pageSize ?? defaultPageSize;
 | 
			
		||||
      if (pageSize != tableState.pageSize) tableState.setPageSize(pageSize);
 | 
			
		||||
      queryParams.limit = pageSize;
 | 
			
		||||
      queryParams.offset = (tableState.page - 1) * pageSize;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Ordering
 | 
			
		||||
    let ordering = getOrderingTerm();
 | 
			
		||||
 | 
			
		||||
    if (ordering) {
 | 
			
		||||
      if (sortStatus.direction == 'asc') {
 | 
			
		||||
        queryParams.ordering = ordering;
 | 
			
		||||
      } else {
 | 
			
		||||
        queryParams.ordering = `-${ordering}`;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return queryParams;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Data download callback
 | 
			
		||||
  function downloadData(fileFormat: string) {
 | 
			
		||||
    // Download entire dataset (no pagination)
 | 
			
		||||
    let queryParams = getTableFilters(false);
 | 
			
		||||
 | 
			
		||||
    // Specify file format
 | 
			
		||||
    queryParams.export = fileFormat;
 | 
			
		||||
 | 
			
		||||
    let downloadUrl = api.getUri({
 | 
			
		||||
      url: url,
 | 
			
		||||
      params: queryParams
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Download file in a new window (to force download)
 | 
			
		||||
    window.open(downloadUrl, '_blank');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Data Sorting
 | 
			
		||||
  const [sortStatus, setSortStatus] = useState<DataTableSortStatus<T>>({
 | 
			
		||||
    columnAccessor: tableProps.defaultSortColumn ?? '',
 | 
			
		||||
    direction: 'asc'
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  /*
 | 
			
		||||
   * Construct query filters for the current table
 | 
			
		||||
   */
 | 
			
		||||
  const getTableFilters = useCallback(
 | 
			
		||||
    (paginate: boolean = false) => {
 | 
			
		||||
      let queryParams = {
 | 
			
		||||
        ...tableProps.params
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      // Add custom filters
 | 
			
		||||
      if (tableState.activeFilters) {
 | 
			
		||||
        tableState.activeFilters.forEach(
 | 
			
		||||
          (flt) => (queryParams[flt.name] = flt.value)
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Allow override of filters based on URL query parameters
 | 
			
		||||
      if (tableState.queryFilters) {
 | 
			
		||||
        for (let [key, value] of tableState.queryFilters) {
 | 
			
		||||
          queryParams[key] = value;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Add custom search term
 | 
			
		||||
      if (tableState.searchTerm) {
 | 
			
		||||
        queryParams.search = tableState.searchTerm;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Pagination
 | 
			
		||||
      if (tableProps.enablePagination && paginate) {
 | 
			
		||||
        let pageSize = tableState.pageSize ?? defaultPageSize;
 | 
			
		||||
        if (pageSize != tableState.pageSize) tableState.setPageSize(pageSize);
 | 
			
		||||
        queryParams.limit = pageSize;
 | 
			
		||||
        queryParams.offset = (tableState.page - 1) * pageSize;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Ordering
 | 
			
		||||
      let ordering = getOrderingTerm();
 | 
			
		||||
 | 
			
		||||
      if (ordering) {
 | 
			
		||||
        if (sortStatus.direction == 'asc') {
 | 
			
		||||
          queryParams.ordering = ordering;
 | 
			
		||||
        } else {
 | 
			
		||||
          queryParams.ordering = `-${ordering}`;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return queryParams;
 | 
			
		||||
    },
 | 
			
		||||
    [
 | 
			
		||||
      tableProps.params,
 | 
			
		||||
      tableProps.enablePagination,
 | 
			
		||||
      tableState.activeFilters,
 | 
			
		||||
      tableState.queryFilters,
 | 
			
		||||
      tableState.searchTerm,
 | 
			
		||||
      tableState.pageSize,
 | 
			
		||||
      tableState.setPageSize,
 | 
			
		||||
      sortStatus,
 | 
			
		||||
      getOrderingTerm
 | 
			
		||||
    ]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const tableKey: string = tableState.tableKey.split('-')[0];
 | 
			
		||||
    const sorting: DataTableSortStatus = getTableSorting(tableKey);
 | 
			
		||||
@@ -538,7 +502,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
 | 
			
		||||
  // Refetch data when the query parameters change
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    refetch();
 | 
			
		||||
  }, [urlQueryParams]);
 | 
			
		||||
  }, [tableState.queryFilters]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    tableState.setIsLoading(
 | 
			
		||||
@@ -559,35 +523,6 @@ export function InvenTreeTable<T extends Record<string, any>>({
 | 
			
		||||
    }
 | 
			
		||||
  }, [data]);
 | 
			
		||||
 | 
			
		||||
  const deleteRecords = useDeleteApiFormModal({
 | 
			
		||||
    url: url,
 | 
			
		||||
    title: t`Delete Selected Items`,
 | 
			
		||||
    preFormContent: (
 | 
			
		||||
      <Alert
 | 
			
		||||
        color="red"
 | 
			
		||||
        title={t`Are you sure you want to delete the selected items?`}
 | 
			
		||||
      >
 | 
			
		||||
        {t`This action cannot be undone`}
 | 
			
		||||
      </Alert>
 | 
			
		||||
    ),
 | 
			
		||||
    initialData: {
 | 
			
		||||
      items: tableState.selectedIds
 | 
			
		||||
    },
 | 
			
		||||
    fields: {
 | 
			
		||||
      items: {
 | 
			
		||||
        hidden: true
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    onFormSuccess: () => {
 | 
			
		||||
      tableState.clearSelectedRecords();
 | 
			
		||||
      tableState.refreshTable();
 | 
			
		||||
 | 
			
		||||
      if (props.afterBulkDelete) {
 | 
			
		||||
        props.afterBulkDelete();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // Callback when a cell is clicked
 | 
			
		||||
  const handleCellClick = useCallback(
 | 
			
		||||
    ({
 | 
			
		||||
@@ -672,122 +607,24 @@ export function InvenTreeTable<T extends Record<string, any>>({
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {deleteRecords.modal}
 | 
			
		||||
      {tableProps.enableFilters && (filters.length ?? 0) > 0 && (
 | 
			
		||||
        <Boundary label="table-filter-drawer">
 | 
			
		||||
          <FilterSelectDrawer
 | 
			
		||||
            availableFilters={filters}
 | 
			
		||||
            tableState={tableState}
 | 
			
		||||
            opened={filtersVisible}
 | 
			
		||||
            onClose={() => setFiltersVisible(false)}
 | 
			
		||||
          />
 | 
			
		||||
        </Boundary>
 | 
			
		||||
      )}
 | 
			
		||||
      <Boundary label={`InvenTreeTable-${tableState.tableKey}`}>
 | 
			
		||||
        <Stack gap="sm">
 | 
			
		||||
          <Group justify="apart" grow wrap="nowrap">
 | 
			
		||||
            <Group justify="left" key="custom-actions" gap={5} wrap="nowrap">
 | 
			
		||||
              <PrintingActions
 | 
			
		||||
                items={tableState.selectedIds}
 | 
			
		||||
                modelType={tableProps.modelType}
 | 
			
		||||
                enableLabels={tableProps.enableLabels}
 | 
			
		||||
                enableReports={tableProps.enableReports}
 | 
			
		||||
              />
 | 
			
		||||
              {(tableProps.barcodeActions?.length ?? 0) > 0 && (
 | 
			
		||||
                <ButtonMenu
 | 
			
		||||
                  key="barcode-actions"
 | 
			
		||||
                  icon={<IconBarcode />}
 | 
			
		||||
                  label={t`Barcode Actions`}
 | 
			
		||||
                  tooltip={t`Barcode Actions`}
 | 
			
		||||
                  actions={tableProps.barcodeActions ?? []}
 | 
			
		||||
                />
 | 
			
		||||
              )}
 | 
			
		||||
              {tableProps.enableBulkDelete && (
 | 
			
		||||
                <ActionButton
 | 
			
		||||
                  disabled={!tableState.hasSelectedRecords}
 | 
			
		||||
                  icon={<IconTrash />}
 | 
			
		||||
                  color="red"
 | 
			
		||||
                  tooltip={t`Delete selected records`}
 | 
			
		||||
                  onClick={() => {
 | 
			
		||||
                    deleteRecords.open();
 | 
			
		||||
                  }}
 | 
			
		||||
                />
 | 
			
		||||
              )}
 | 
			
		||||
              {tableProps.tableActions?.map((group, idx) => (
 | 
			
		||||
                <Fragment key={idx}>{group}</Fragment>
 | 
			
		||||
              ))}
 | 
			
		||||
            </Group>
 | 
			
		||||
            <Space />
 | 
			
		||||
            <Group justify="right" gap={5} wrap="nowrap">
 | 
			
		||||
              {tableProps.enableSearch && (
 | 
			
		||||
                <TableSearchInput
 | 
			
		||||
                  searchCallback={(term: string) =>
 | 
			
		||||
                    tableState.setSearchTerm(term)
 | 
			
		||||
                  }
 | 
			
		||||
                />
 | 
			
		||||
              )}
 | 
			
		||||
              {tableProps.enableRefresh && (
 | 
			
		||||
                <ActionIcon variant="transparent" aria-label="table-refresh">
 | 
			
		||||
                  <Tooltip label={t`Refresh data`}>
 | 
			
		||||
                    <IconRefresh
 | 
			
		||||
                      onClick={() => {
 | 
			
		||||
                        refetch();
 | 
			
		||||
                        tableState.clearSelectedRecords();
 | 
			
		||||
                      }}
 | 
			
		||||
                    />
 | 
			
		||||
                  </Tooltip>
 | 
			
		||||
                </ActionIcon>
 | 
			
		||||
              )}
 | 
			
		||||
              {hasSwitchableColumns && (
 | 
			
		||||
                <TableColumnSelect
 | 
			
		||||
                  columns={dataColumns}
 | 
			
		||||
                  onToggleColumn={toggleColumn}
 | 
			
		||||
                />
 | 
			
		||||
              )}
 | 
			
		||||
              {urlQueryParams.size > 0 && (
 | 
			
		||||
                <ActionIcon
 | 
			
		||||
                  variant="transparent"
 | 
			
		||||
                  color="red"
 | 
			
		||||
                  aria-label="table-clear-query-filters"
 | 
			
		||||
                >
 | 
			
		||||
                  <Tooltip label={t`Clear custom query filters`}>
 | 
			
		||||
                    <IconFilterCancel
 | 
			
		||||
                      onClick={() => {
 | 
			
		||||
                        setUrlQueryParams({});
 | 
			
		||||
                      }}
 | 
			
		||||
                    />
 | 
			
		||||
                  </Tooltip>
 | 
			
		||||
                </ActionIcon>
 | 
			
		||||
              )}
 | 
			
		||||
              {tableProps.enableFilters && filters.length > 0 && (
 | 
			
		||||
                <Indicator
 | 
			
		||||
                  size="xs"
 | 
			
		||||
                  label={tableState.activeFilters?.length ?? 0}
 | 
			
		||||
                  disabled={tableState.activeFilters?.length == 0}
 | 
			
		||||
                >
 | 
			
		||||
                  <ActionIcon
 | 
			
		||||
                    variant="transparent"
 | 
			
		||||
                    aria-label="table-select-filters"
 | 
			
		||||
                  >
 | 
			
		||||
                    <Tooltip label={t`Table Filters`}>
 | 
			
		||||
                      <IconFilter
 | 
			
		||||
                        onClick={() => setFiltersVisible(!filtersVisible)}
 | 
			
		||||
                      />
 | 
			
		||||
                    </Tooltip>
 | 
			
		||||
                  </ActionIcon>
 | 
			
		||||
                </Indicator>
 | 
			
		||||
              )}
 | 
			
		||||
              {tableProps.enableDownload && (
 | 
			
		||||
                <DownloadAction
 | 
			
		||||
                  key="download-action"
 | 
			
		||||
                  downloadCallback={downloadData}
 | 
			
		||||
                />
 | 
			
		||||
              )}
 | 
			
		||||
            </Group>
 | 
			
		||||
          </Group>
 | 
			
		||||
      <Stack gap="xs">
 | 
			
		||||
        {!tableProps.noHeader && (
 | 
			
		||||
          <Boundary label={`InvenTreeTableHeader-${tableState.tableKey}`}>
 | 
			
		||||
            <InvenTreeTableHeader
 | 
			
		||||
              tableUrl={url}
 | 
			
		||||
              tableState={tableState}
 | 
			
		||||
              tableProps={tableProps}
 | 
			
		||||
              hasSwitchableColumns={hasSwitchableColumns}
 | 
			
		||||
              columns={dataColumns}
 | 
			
		||||
              filters={filters}
 | 
			
		||||
              toggleColumn={toggleColumn}
 | 
			
		||||
            />
 | 
			
		||||
          </Boundary>
 | 
			
		||||
        )}
 | 
			
		||||
        <Boundary label={`InvenTreeTable-${tableState.tableKey}`}>
 | 
			
		||||
          <Box pos="relative">
 | 
			
		||||
            <DataTable
 | 
			
		||||
              withTableBorder
 | 
			
		||||
              withTableBorder={!tableProps.noHeader}
 | 
			
		||||
              withColumnBorders
 | 
			
		||||
              striped
 | 
			
		||||
              highlightOnHover
 | 
			
		||||
@@ -814,6 +651,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
 | 
			
		||||
              records={tableState.records}
 | 
			
		||||
              columns={dataColumns}
 | 
			
		||||
              onCellClick={handleCellClick}
 | 
			
		||||
              noHeader={tableProps.noHeader ?? false}
 | 
			
		||||
              defaultColumnProps={{
 | 
			
		||||
                noWrap: true,
 | 
			
		||||
                textAlign: 'left',
 | 
			
		||||
@@ -825,8 +663,8 @@ export function InvenTreeTable<T extends Record<string, any>>({
 | 
			
		||||
              {...optionalParams}
 | 
			
		||||
            />
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Stack>
 | 
			
		||||
      </Boundary>
 | 
			
		||||
        </Boundary>
 | 
			
		||||
      </Stack>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										239
									
								
								src/frontend/src/tables/InvenTreeTableHeader.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										239
									
								
								src/frontend/src/tables/InvenTreeTableHeader.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,239 @@
 | 
			
		||||
import { t } from '@lingui/macro';
 | 
			
		||||
import {
 | 
			
		||||
  ActionIcon,
 | 
			
		||||
  Alert,
 | 
			
		||||
  Group,
 | 
			
		||||
  Indicator,
 | 
			
		||||
  Space,
 | 
			
		||||
  Tooltip
 | 
			
		||||
} from '@mantine/core';
 | 
			
		||||
import {
 | 
			
		||||
  IconBarcode,
 | 
			
		||||
  IconFilter,
 | 
			
		||||
  IconFilterCancel,
 | 
			
		||||
  IconRefresh,
 | 
			
		||||
  IconTrash
 | 
			
		||||
} from '@tabler/icons-react';
 | 
			
		||||
import { useEffect, useState } from 'react';
 | 
			
		||||
import { useSearchParams } from 'react-router-dom';
 | 
			
		||||
import { Fragment } from 'react/jsx-runtime';
 | 
			
		||||
 | 
			
		||||
import { api } from '../App';
 | 
			
		||||
import { Boundary } from '../components/Boundary';
 | 
			
		||||
import { ActionButton } from '../components/buttons/ActionButton';
 | 
			
		||||
import { ButtonMenu } from '../components/buttons/ButtonMenu';
 | 
			
		||||
import { PrintingActions } from '../components/buttons/PrintingActions';
 | 
			
		||||
import { useDeleteApiFormModal } from '../hooks/UseForm';
 | 
			
		||||
import { TableState } from '../hooks/UseTable';
 | 
			
		||||
import { TableColumnSelect } from './ColumnSelect';
 | 
			
		||||
import { DownloadAction } from './DownloadAction';
 | 
			
		||||
import { TableFilter } from './Filter';
 | 
			
		||||
import { FilterSelectDrawer } from './FilterSelectDrawer';
 | 
			
		||||
import { InvenTreeTableProps } from './InvenTreeTable';
 | 
			
		||||
import { TableSearchInput } from './Search';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Render a composite header for an InvenTree table
 | 
			
		||||
 */
 | 
			
		||||
export default function InvenTreeTableHeader({
 | 
			
		||||
  tableUrl,
 | 
			
		||||
  tableState,
 | 
			
		||||
  tableProps,
 | 
			
		||||
  hasSwitchableColumns,
 | 
			
		||||
  columns,
 | 
			
		||||
  filters,
 | 
			
		||||
  toggleColumn
 | 
			
		||||
}: {
 | 
			
		||||
  tableUrl: string;
 | 
			
		||||
  tableState: TableState;
 | 
			
		||||
  tableProps: InvenTreeTableProps<any>;
 | 
			
		||||
  hasSwitchableColumns: boolean;
 | 
			
		||||
  columns: any;
 | 
			
		||||
  filters: TableFilter[];
 | 
			
		||||
  toggleColumn: (column: string) => void;
 | 
			
		||||
}) {
 | 
			
		||||
  // Filter list visibility
 | 
			
		||||
  const [filtersVisible, setFiltersVisible] = useState<boolean>(false);
 | 
			
		||||
 | 
			
		||||
  const downloadData = (fileFormat: string) => {
 | 
			
		||||
    // Download entire dataset (no pagination)
 | 
			
		||||
 | 
			
		||||
    let queryParams = {
 | 
			
		||||
      ...tableProps.params
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Add in active filters
 | 
			
		||||
    if (tableState.activeFilters) {
 | 
			
		||||
      tableState.activeFilters.forEach((filter) => {
 | 
			
		||||
        queryParams[filter.name] = filter.value;
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Allow overriding of query parameters
 | 
			
		||||
    if (tableState.queryFilters) {
 | 
			
		||||
      for (let [key, value] of tableState.queryFilters) {
 | 
			
		||||
        queryParams[key] = value;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Add custom search term
 | 
			
		||||
    if (tableState.searchTerm) {
 | 
			
		||||
      queryParams.search = tableState.searchTerm;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Specify file format
 | 
			
		||||
    queryParams.export = fileFormat;
 | 
			
		||||
 | 
			
		||||
    let downloadUrl = api.getUri({
 | 
			
		||||
      url: tableUrl,
 | 
			
		||||
      params: queryParams
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Download file in a new window (to force download)
 | 
			
		||||
    window.open(downloadUrl, '_blank');
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const deleteRecords = useDeleteApiFormModal({
 | 
			
		||||
    url: tableUrl,
 | 
			
		||||
    title: t`Delete Selected Items`,
 | 
			
		||||
    preFormContent: (
 | 
			
		||||
      <Alert
 | 
			
		||||
        color="red"
 | 
			
		||||
        title={t`Are you sure you want to delete the selected items?`}
 | 
			
		||||
      >
 | 
			
		||||
        {t`This action cannot be undone`}
 | 
			
		||||
      </Alert>
 | 
			
		||||
    ),
 | 
			
		||||
    initialData: {
 | 
			
		||||
      items: tableState.selectedIds
 | 
			
		||||
    },
 | 
			
		||||
    fields: {
 | 
			
		||||
      items: {
 | 
			
		||||
        hidden: true
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    onFormSuccess: () => {
 | 
			
		||||
      tableState.clearSelectedRecords();
 | 
			
		||||
      tableState.refreshTable();
 | 
			
		||||
 | 
			
		||||
      if (tableProps.afterBulkDelete) {
 | 
			
		||||
        tableProps.afterBulkDelete();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {deleteRecords.modal}
 | 
			
		||||
      {tableProps.enableFilters && (filters.length ?? 0) > 0 && (
 | 
			
		||||
        <Boundary label={`InvenTreeTableFilterDrawer-${tableState.tableKey}`}>
 | 
			
		||||
          <FilterSelectDrawer
 | 
			
		||||
            availableFilters={filters}
 | 
			
		||||
            tableState={tableState}
 | 
			
		||||
            opened={filtersVisible}
 | 
			
		||||
            onClose={() => setFiltersVisible(false)}
 | 
			
		||||
          />
 | 
			
		||||
        </Boundary>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      <Group justify="apart" grow wrap="nowrap">
 | 
			
		||||
        <Group justify="left" key="custom-actions" gap={5} wrap="nowrap">
 | 
			
		||||
          <PrintingActions
 | 
			
		||||
            items={tableState.selectedIds}
 | 
			
		||||
            modelType={tableProps.modelType}
 | 
			
		||||
            enableLabels={tableProps.enableLabels}
 | 
			
		||||
            enableReports={tableProps.enableReports}
 | 
			
		||||
          />
 | 
			
		||||
          {(tableProps.barcodeActions?.length ?? 0) > 0 && (
 | 
			
		||||
            <ButtonMenu
 | 
			
		||||
              key="barcode-actions"
 | 
			
		||||
              icon={<IconBarcode />}
 | 
			
		||||
              label={t`Barcode Actions`}
 | 
			
		||||
              tooltip={t`Barcode Actions`}
 | 
			
		||||
              actions={tableProps.barcodeActions ?? []}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
          {tableProps.enableBulkDelete && (
 | 
			
		||||
            <ActionButton
 | 
			
		||||
              disabled={!tableState.hasSelectedRecords}
 | 
			
		||||
              icon={<IconTrash />}
 | 
			
		||||
              color="red"
 | 
			
		||||
              tooltip={t`Delete selected records`}
 | 
			
		||||
              onClick={() => {
 | 
			
		||||
                deleteRecords.open();
 | 
			
		||||
              }}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
          {tableProps.tableActions?.map((group, idx) => (
 | 
			
		||||
            <Fragment key={idx}>{group}</Fragment>
 | 
			
		||||
          ))}
 | 
			
		||||
        </Group>
 | 
			
		||||
        <Space />
 | 
			
		||||
        <Group justify="right" gap={5} wrap="nowrap">
 | 
			
		||||
          {tableProps.enableSearch && (
 | 
			
		||||
            <TableSearchInput
 | 
			
		||||
              searchCallback={(term: string) => tableState.setSearchTerm(term)}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
          {tableProps.enableRefresh && (
 | 
			
		||||
            <ActionIcon variant="transparent" aria-label="table-refresh">
 | 
			
		||||
              <Tooltip label={t`Refresh data`}>
 | 
			
		||||
                <IconRefresh
 | 
			
		||||
                  onClick={() => {
 | 
			
		||||
                    tableState.refreshTable();
 | 
			
		||||
                    tableState.clearSelectedRecords();
 | 
			
		||||
                  }}
 | 
			
		||||
                />
 | 
			
		||||
              </Tooltip>
 | 
			
		||||
            </ActionIcon>
 | 
			
		||||
          )}
 | 
			
		||||
          {hasSwitchableColumns && (
 | 
			
		||||
            <TableColumnSelect
 | 
			
		||||
              columns={columns}
 | 
			
		||||
              onToggleColumn={toggleColumn}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
          {tableState.queryFilters.size > 0 && (
 | 
			
		||||
            <ActionIcon
 | 
			
		||||
              variant="transparent"
 | 
			
		||||
              color="red"
 | 
			
		||||
              aria-label="table-clear-query-filters"
 | 
			
		||||
            >
 | 
			
		||||
              <Tooltip label={t`Clear custom query filters`}>
 | 
			
		||||
                <IconFilterCancel
 | 
			
		||||
                  onClick={() => {
 | 
			
		||||
                    tableState.clearQueryFilters();
 | 
			
		||||
                  }}
 | 
			
		||||
                />
 | 
			
		||||
              </Tooltip>
 | 
			
		||||
            </ActionIcon>
 | 
			
		||||
          )}
 | 
			
		||||
          {tableProps.enableFilters && filters.length > 0 && (
 | 
			
		||||
            <Indicator
 | 
			
		||||
              size="xs"
 | 
			
		||||
              label={tableState.activeFilters?.length ?? 0}
 | 
			
		||||
              disabled={tableState.activeFilters?.length == 0}
 | 
			
		||||
            >
 | 
			
		||||
              <ActionIcon
 | 
			
		||||
                variant="transparent"
 | 
			
		||||
                aria-label="table-select-filters"
 | 
			
		||||
              >
 | 
			
		||||
                <Tooltip label={t`Table Filters`}>
 | 
			
		||||
                  <IconFilter
 | 
			
		||||
                    onClick={() => setFiltersVisible(!filtersVisible)}
 | 
			
		||||
                  />
 | 
			
		||||
                </Tooltip>
 | 
			
		||||
              </ActionIcon>
 | 
			
		||||
            </Indicator>
 | 
			
		||||
          )}
 | 
			
		||||
          {tableProps.enableDownload && (
 | 
			
		||||
            <DownloadAction
 | 
			
		||||
              key="download-action"
 | 
			
		||||
              downloadCallback={downloadData}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
        </Group>
 | 
			
		||||
      </Group>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -18,11 +18,45 @@ test('Pages - Part - Locking', async ({ page }) => {
 | 
			
		||||
  await page.getByLabel('part-lock-icon').waitFor();
 | 
			
		||||
  await page.getByText('Part is Locked', { exact: true }).waitFor();
 | 
			
		||||
 | 
			
		||||
  // Check expected "badge" values
 | 
			
		||||
  await page.getByText('In Stock: 13').waitFor();
 | 
			
		||||
  await page.getByText('Required: 10').waitFor();
 | 
			
		||||
  await page.getByText('In Production: 50').waitFor();
 | 
			
		||||
 | 
			
		||||
  // Check the "parameters" tab also
 | 
			
		||||
  await page.getByRole('tab', { name: 'Parameters' }).click();
 | 
			
		||||
  await page.getByText('Part parameters cannot be').waitFor();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('Pages - Part - Allocations', async ({ page }) => {
 | 
			
		||||
  await doQuickLogin(page);
 | 
			
		||||
 | 
			
		||||
  // Let's look at the allocations for a single stock item
 | 
			
		||||
  await page.goto(`${baseUrl}/stock/item/324/`);
 | 
			
		||||
  await page.getByRole('tab', { name: 'Allocations' }).click();
 | 
			
		||||
 | 
			
		||||
  await page.getByRole('button', { name: 'Build Order Allocations' }).waitFor();
 | 
			
		||||
  await page.getByRole('cell', { name: 'Making some blue chairs' }).waitFor();
 | 
			
		||||
  await page.getByRole('cell', { name: 'Making tables for SO 0003' }).waitFor();
 | 
			
		||||
 | 
			
		||||
  // Let's look at the allocations for the entire part
 | 
			
		||||
  await page.getByRole('tab', { name: 'Details' }).click();
 | 
			
		||||
  await page.getByRole('link', { name: 'Leg' }).click();
 | 
			
		||||
 | 
			
		||||
  await page.getByRole('tab', { name: 'Part Details' }).click();
 | 
			
		||||
  await page.getByText('660 / 760').waitFor();
 | 
			
		||||
 | 
			
		||||
  await page.getByRole('tab', { name: 'Allocations' }).click();
 | 
			
		||||
 | 
			
		||||
  // Number of table records
 | 
			
		||||
  await page.getByText('1 - 4 / 4').waitFor();
 | 
			
		||||
  await page.getByRole('cell', { name: 'Making red square tables' }).waitFor();
 | 
			
		||||
 | 
			
		||||
  // Navigate through to the build order
 | 
			
		||||
  await page.getByRole('cell', { name: 'BO0007' }).click();
 | 
			
		||||
  await page.getByRole('tab', { name: 'Build Details' }).waitFor();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('Pages - Part - Pricing (Nothing, BOM)', async ({ page }) => {
 | 
			
		||||
  await doQuickLogin(page);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user