[WIP] Mantine datatables (#5218)

* Create dependency-review.yml

* Create scan.yml

* Create sonar-project.properties

* add option to use sections and refactro

* translate error messages

* remove unneeded vars

* move function code

* move data inside

* add global section

* add plugin section

* use translated section titles

* add translation strings

* rename scan action

* add user settings

* use ordered data

* fix settings url

* use debounced value for strings (not choices!)

* rename contex to context

* move i18n provider up

* move theme options into seperate context/ component

* renmae statrtup vars

* move translations out

* reactivate sentry

* move i18n provider to seperate context

* move langauge state completly out of App

* use theme out

* move theme context

* move LanguageContext

* move function into state

* make sentry optional for now

* add key to accordion

* init langauge context on top

* remove unneeded css files

* move errorpage to tsx

* add translation for error page

* Add error to title

* add typecast for error

* move type definition out

* remove todo -> type was already added

* upgrade deps

* add bootstrap

* remove @mantine/core

* readd core

* switch to bootstrap

* simplify import

* Add SPA views for react #2789

* split up frontend urls

* Add settings for frontend url loading

* add new UI scaffold

* remove tracking insert

* add platform app

* ensure static indexes work too

* add lingui

* add lingui config

* add mgmt tasks

* add base locales

* settings for frontend dev

* fix typo

* update deps

* add pre-commit

* add eslint

* add testing scaffold

* fix paths

* remove error - tests trip correctly

* merge workflow

* cleanup samples

* use name inline with other tests

* Add real worl frontend tests

* setup env

* tun migrations first

* optimize setup time

* setup demo dataset

* optimize run setup

* add test for class ui

* rename

* fix typo

* and another typo

* do install

* run migrations first

* fix name

* cleanup

* use other credentials

* use other credentials

* fix qc

* move envs to qc

* remove create_site

* reduce testing env

* fix test

* fix test call

* allaccess user

* add ui plattform check

* add better check

* remove unneeded env

* enable debug

* reduce wait time

* also build frontend on static

* add sekeleton

* fix various issues

* add locales

* clean output before building

* cleanup dir

* remove bootstrap

* clean up deps

* fix settings panel

* remove assets

* move logo

* split out router

* split up chunks

* fix zustand import syntax

* bundl

* update pre-render

* use vendor splitting

* maximes space usage

* enlarge breakpoints

* remove wired color changes

* cleanup tabs

* fix error

* update auth functions

* default to mail login

* add placeholder marking

* Add text to placeholder

* readd codespell

* add another test

* add sort plugin

* add sort plugin

* sort imports

* fix order

* Add mega menu

* run pre-commit fixes

* add node min version

* Docker container (#129)

* Fix allocation check for completing build order (#5199)

- Allocation check only applies to untracked line items

* docker dev

Install required node packages to docker development image

* add import order settings

* cleanout built ui

* Add "parttable" component

* Add task to serve front-end code dev

* remove default arg from build

* remove eslint

* optimize svg

* Adds generic function for rendering a table with server-side data

* Implement pagination and sorting

* Add more example columns

* Enable selection of table data rows

* add build step for plattform UI

* fix install command

* optional parameters

* Add simple stock table

* Add optional parameter for default sort

* Change "no records" text based on query result

* Translate

* Start writing some helper functions

* Add thumbnail component

* Fill out more columns for stock table

* Add simple skeleton for table search input

* Adjust default table properties

* Change loader variant

* Drop-down for selecting table columns

* Add search text callback

* use alpine commands

* do not use cache when creating image

* More updates for inventree table

- Fix search text entry
- Add "refresh" button
- Adjust variable names

* Search input improvements

- Add button to clear search input

* Enable mantine notification system

* Add "not yet implemented" notification message

* Add download action button

* Adds ButtonMenu component

- Button which expands to show other actions
- Add hooks for adding action menus to tables

* Add basic build order list table

* Add custom filters button for table

* Allow columns to be toggled

* Column visibility saved across table loads

* Adds display for table filters

- Define interface for table filter definition
- Add component for displaying filters
- Cleanup for part table

* Cleanup

* Define type for controlling column data

* Allow custom ordering term for table column

- Replaces "sortName" concept from bootstrap-table

* Improve build order table

- Fancy progress bars

* Reimplement invoke task to serve frontend files via yarn

* Update package files with mantine

* Implement callback when record selection is changed

* Adds generic "actionbutton" component

* Remove duplicate form components

* Remove tracked files in web/static

* Remove a bunch of files

- tracked in from the wrong original branch

* More page fixes

* Revert changes to reqiurements-dev.txt

* Spelling fix

* Component updates

* Cleanup components

* Cleanup

* Use spread operator

* Add some new dummy pages for testing

* Cleanup / simplify stockitem table

* Cleanup for part table

* Cleanup build order table

* Cleanup column toggle function

* Remove hard-coded URL

* Format updates

* Update deps

* npm required for inventree-python checks

* Fix search input

- Better debouncing
- Cleaner code

* Update package files

* vite polling fixes

* Implementation for download button

- Dropdown menu with file format options

* Implement callback for download of table data

* Better state management for hidden columns

* Implement state framework for active custom filters

* Silence some errors

* Revert change to vite config

* Implement collapsible filter list group

- Save active filters to local storage
- Add some example filters to the part table
- Add FilterBadge component

* Fix page names

* Simplify search input

- useDebouncedValue

* linting

* Refactor

* Remove debug msg

* Simplify search state

* Refactor function for constructing API query

* Add tooltip

* Update icons

* Add modal for selecting filter options

* Add more table filters for part table

* render custom item for filter select

* Complete implementation for selectable filters

- Allow choices to be specified as attribute
- Allow choices to be specified as function
- Handle state management for filter choice form

* Tweak badge

* Cleanup top-level yarn and npm files

* Less roundy

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver 2023-07-27 10:10:07 +10:00 committed by GitHub
parent 941451203a
commit 339eae53a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 2151 additions and 3701 deletions

View File

@ -152,6 +152,7 @@ jobs:
apt-dependency: gettext poppler-utils
dev-install: true
update: true
npm: true
- name: Download Python Code For `${{ env.wrapper_name }}`
run: git clone --depth 1 https://github.com/inventree/${{ env.wrapper_name }}
./${{ env.wrapper_name }}

2534
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,6 @@
{
"dependencies": {
"@emotion/react": "^11.11.1",
"@mantine/core": "^6.0.17",
"@mantine/dropzone": "^6.0.17",
"@mantine/hooks": "^6.0.17",
"@mantine/notifications": "^6.0.17",
"eslint": "^8.41.0",
"eslint-config-google": "^0.14.0",
"mantine-datatable": "^2.8.5"
"eslint-config-google": "^0.14.0"
}
}

View File

@ -30,6 +30,7 @@
"axios": "^1.4.0",
"dayjs": "^1.11.9",
"html5-qrcode": "^2.3.8",
"mantine-datatable": "^2.9.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.14.2",

View File

@ -0,0 +1,35 @@
import { ActionIcon, Tooltip } from '@mantine/core';
/**
* Construct a simple action button with consistent styling
*/
export function ActionButton({
icon,
color = 'black',
tooltip = '',
disabled = false,
size = 18,
onClick
}: {
icon: any;
color?: string;
tooltip?: string;
variant?: string;
size?: number;
disabled?: boolean;
onClick?: any;
}) {
return (
<ActionIcon
disabled={disabled}
radius="xs"
color={color}
size={size}
onClick={onClick}
>
<Tooltip disabled={!tooltip} label={tooltip} position="left">
{icon}
</Tooltip>
</ActionIcon>
);
}

View File

@ -0,0 +1,36 @@
import { ActionIcon, Menu, Tooltip } from '@mantine/core';
import { Component } from 'react';
/**
* A ButtonMenu is a button that opens a menu when clicked.
* It features a number of actions, which can be selected by the user.
*/
export function ButtonMenu({
icon,
actions,
tooltip = '',
label = ''
}: {
icon: any;
actions: any[];
label?: string;
tooltip?: string;
}) {
let idx = 0;
return (
<Menu shadow="xs">
<Menu.Target>
<ActionIcon>
<Tooltip label={tooltip}>{icon}</Tooltip>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{label && <Menu.Label>{label}</Menu.Label>}
{actions.map((action) => (
<Menu.Item key={idx++}>{action}</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}

View File

@ -0,0 +1,57 @@
import { t } from '@lingui/macro';
import { Image } from '@mantine/core';
import { Group } from '@mantine/core';
import { Text } from '@mantine/core';
export function Thumbnail({
src,
alt = t`Thumbnail`,
size = 24
}: {
src: string;
alt?: string;
size?: number;
}) {
// TODO: Use api to determine the correct URL
let url = 'http://localhost:8000' + src;
// TODO: Use HoverCard to display a larger version of the image
return (
<Image
src={url}
alt={alt}
width={size}
fit="contain"
radius="xs"
withPlaceholder
imageProps={{
style: {
maxHeight: size
}
}}
/>
);
}
export function ThumbnailHoverCard({
src,
text,
link = '',
alt = t`Thumbnail`,
size = 24
}: {
src: string;
text: string;
link?: string;
alt?: string;
size?: number;
}) {
// TODO: Handle link
return (
<Group position="left" spacing={10}>
<Thumbnail src={src} alt={alt} size={size} />
<Text>{text}</Text>
</Group>
);
}

View File

@ -0,0 +1,15 @@
/**
* Interface for the table column definition
*/
export type TableColumn = {
accessor: string; // The key in the record to access
ordering?: string; // The key in the record to sort by (defaults to accessor)
title: string; // The title of the column
sortable?: boolean; // Whether the column is sortable
switchable?: boolean; // Whether the column is switchable
hidden?: boolean; // Whether the column is hidden
render?: (record: any) => any; // A custom render function
filter?: any; // A custom filter function
filtering?: boolean; // Whether the column is filterable
width?: number; // The width of the column
};

View File

@ -0,0 +1,40 @@
import { t } from '@lingui/macro';
import { Checkbox, Menu, Tooltip } from '@mantine/core';
import { ActionIcon } from '@mantine/core';
import { IconAdjustments } from '@tabler/icons-react';
export function TableColumnSelect({
columns,
onToggleColumn
}: {
columns: any[];
onToggleColumn: (columnName: string) => void;
}) {
return (
<Menu shadow="xs">
<Menu.Target>
<ActionIcon>
<Tooltip label={t`Select Columns`}>
<IconAdjustments />
</Tooltip>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t`Select Columns`}</Menu.Label>
{columns
.filter((col) => col.switchable)
.map((col) => (
<Menu.Item key={col.accessor}>
<Checkbox
checked={!col.hidden}
label={col.title || col.accessor}
onChange={(event) => onToggleColumn(col.accessor)}
radius="sm"
/>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}

View File

@ -0,0 +1,45 @@
import { Trans, t } from '@lingui/macro';
import { ActionIcon, Divider, Group, Menu, Select } from '@mantine/core';
import { Tooltip } from '@mantine/core';
import { Button, Modal, Stack } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconDownload } from '@tabler/icons-react';
import { useState } from 'react';
export function DownloadAction({
downloadCallback
}: {
downloadCallback: (fileFormat: string) => void;
}) {
const formatOptions = [
{ value: 'csv', label: t`CSV` },
{ value: 'tsv', label: t`TSV` },
{ value: 'xlsx', label: t`Excel` }
];
return (
<>
<Menu>
<Menu.Target>
<ActionIcon>
<Tooltip label={t`Download selected data`}>
<IconDownload />
</Tooltip>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{formatOptions.map((format) => (
<Menu.Item
key={format.value}
onClick={() => {
downloadCallback(format.value);
}}
>
{format.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
</>
);
}

View File

@ -0,0 +1,21 @@
/**
* Interface for the table filter choice
*/
export type TableFilterChoice = {
value: string;
label: string;
};
/**
* Interface for the table filter,
*/
export type TableFilter = {
name: string;
label: string;
description?: string;
type: string;
choices?: TableFilterChoice[];
choiceFunction?: () => TableFilterChoice[];
defaultValue?: any;
value?: any;
};

View File

@ -0,0 +1,50 @@
import { t } from '@lingui/macro';
import { Badge, CloseButton } from '@mantine/core';
import { Text, Tooltip } from '@mantine/core';
import { Group } from '@mantine/core';
import { TableFilter } from './Filter';
export function FilterBadge({
filter,
onFilterRemove
}: {
filter: TableFilter;
onFilterRemove: () => void;
}) {
/**
* Construct text to display for the given badge ID
*/
function filterDescription() {
let text = filter.label || filter.name;
text += ' = ';
text += filter.value;
return text;
}
return (
<Badge
size="lg"
radius="lg"
variant="outline"
color="gray"
styles={(theme) => ({
root: {
paddingRight: '4px'
},
inner: {
textTransform: 'none'
}
})}
>
<Group spacing={1}>
<Text>{filterDescription()}</Text>
<Tooltip label={t`Remove filter`}>
<CloseButton color="red" onClick={() => onFilterRemove()} />
</Tooltip>
</Group>
</Badge>
);
}

View File

@ -0,0 +1,58 @@
import { t } from '@lingui/macro';
import { ActionIcon, Group, Text, Tooltip } from '@mantine/core';
import { IconFilterMinus } from '@tabler/icons-react';
import { IconFilterPlus } from '@tabler/icons-react';
import { TableFilter } from './Filter';
import { FilterBadge } from './FilterBadge';
/**
* Return a table filter group component:
* - Displays a list of active filters for the table
* - Allows the user to add/remove filters
* - Allows the user to clear all filters
*/
export function FilterGroup({
activeFilters,
onFilterAdd,
onFilterRemove,
onFilterClearAll
}: {
activeFilters: TableFilter[];
onFilterAdd: () => void;
onFilterRemove: (filterName: string) => void;
onFilterClearAll: () => void;
}) {
return (
<Group position="right" spacing={5}>
{activeFilters.length == 0 && (
<Text italic={true} size="sm">{t`Add table filter`}</Text>
)}
{activeFilters.map((f) => (
<FilterBadge
key={f.name}
filter={f}
onFilterRemove={() => onFilterRemove(f.name)}
/>
))}
{activeFilters.length && (
<ActionIcon
radius="sm"
variant="outline"
onClick={() => onFilterClearAll()}
>
<Tooltip label={t`Clear all filters`}>
<IconFilterMinus color="red" />
</Tooltip>
</ActionIcon>
)}
{
<ActionIcon radius="sm" variant="outline" onClick={() => onFilterAdd()}>
<Tooltip label={t`Add filter`}>
<IconFilterPlus color="green" />
</Tooltip>
</ActionIcon>
}
</Group>
);
}

View File

@ -0,0 +1,178 @@
import { t } from '@lingui/macro';
import { Modal, Space } from '@mantine/core';
import { Select } from '@mantine/core';
import { Stack } from '@mantine/core';
import { Button, Group, Text } from '@mantine/core';
import { forwardRef, useMemo, useState } from 'react';
import { TableFilter, TableFilterChoice } from './Filter';
/**
* Construct the selection of filters
*/
function constructAvailableFilters(
activeFilters: TableFilter[],
availableFilters: TableFilter[]
) {
// Collect a list of active filters
let activeFilterNames = activeFilters.map((flt) => flt.name);
let options = availableFilters
.filter((flt) => !activeFilterNames.includes(flt.name))
.map((flt) => ({
value: flt.name,
label: flt.label,
description: flt.description
}));
return options;
}
/**
* Construct the selection of available values for the selected filter
*/
function constructValueOptions(
availableFilters: TableFilter[],
selectedFilter: string | null
) {
// No options if no filter is selected
if (!selectedFilter) {
return [];
}
let filter = availableFilters.find((flt) => flt.name === selectedFilter);
if (!filter) {
console.error(`Could not find filter ${selectedFilter}`);
return [];
}
let options: TableFilterChoice[] = [];
switch (filter.type) {
case 'boolean':
// Boolean filter values True / False
options = [
{ value: 'true', label: t`True` },
{ value: 'false', label: t`False` }
];
break;
default:
// Choices are supplied by the filter definition
if (filter.choices) {
options = filter.choices;
} else if (filter.choiceFunction) {
options = filter.choiceFunction();
} else {
console.error(`Filter choices not supplied for filter ${filter.name}`);
}
break;
}
return options;
}
interface FilterProps extends React.ComponentPropsWithoutRef<'div'> {
name: string;
label: string;
description?: string;
}
/*
* Custom component for the filter select
*/
const FilterSelectItem = forwardRef<HTMLDivElement, FilterProps>(
({ name, label, description, ...others }, ref) => (
<div ref={ref} {...others}>
<Text size="sm">{label}</Text>
<Text size="xs">{description}</Text>
</div>
)
);
/**
* Modal dialog to add a} new filter for a particular table
* @param opened : boolean - Whether the modal is opened or not
* @param onClose : () => void - Function called when the modal is closed
* @returns
*/
export function FilterSelectModal({
availableFilters,
activeFilters,
opened,
onCreateFilter,
onClose
}: {
availableFilters: TableFilter[];
activeFilters: TableFilter[];
opened: boolean;
onCreateFilter: (name: string, value: string) => void;
onClose: () => void;
}) {
let filterOptions = useMemo(
() => constructAvailableFilters(activeFilters, availableFilters),
[activeFilters, availableFilters]
);
// Internal state variable for the selected filter
let [selectedFilter, setSelectedFilter] = useState<string | null>(null);
// Internal state variable for the selected filter value
let [value, setValue] = useState<string | null>(null);
let valueOptions = useMemo(
() => constructValueOptions(availableFilters, selectedFilter),
[availableFilters, activeFilters, selectedFilter]
);
// Callback when the modal is closed. Ensure that the internal state is reset
function closeModal() {
setSelectedFilter(null);
setValue(null);
onClose();
}
function createFilter() {
if (selectedFilter && value) {
onCreateFilter(selectedFilter, value);
}
closeModal();
}
return (
<Modal title={t`Add Table Filter`} opened={opened} onClose={closeModal}>
<Stack>
<Text>{t`Select from the available filters`}</Text>
<Select
data={filterOptions}
itemComponent={FilterSelectItem}
label={t`Filter`}
placeholder={t`Select filter`}
searchable={true}
onChange={(value) => setSelectedFilter(value)}
withinPortal={true}
maxDropdownHeight={400}
/>
<Select
data={valueOptions}
disabled={valueOptions.length == 0}
label={t`Value`}
placeholder={t`Select filter value`}
onChange={(value) => setValue(value)}
withinPortal={true}
maxDropdownHeight={400}
/>
<Group position="right">
<Button color="red" onClick={closeModal}>{t`Cancel`}</Button>
<Button
color="green"
onClick={createFilter}
disabled={!(selectedFilter && value)}
>
{t`Add Filter`}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@ -0,0 +1,468 @@
import { t } from '@lingui/macro';
import { ActionIcon, Indicator, Space, Stack, Tooltip } from '@mantine/core';
import { Group } from '@mantine/core';
import { IconFilter, IconRefresh } from '@tabler/icons-react';
import { IconBarcode, IconPrinter } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useEffect, useState } from 'react';
import { api } from '../../App';
import { ButtonMenu } from '../items/ButtonMenu';
import { TableColumn } from './Column';
import { TableColumnSelect } from './ColumnSelect';
import { DownloadAction } from './DownloadAction';
import { TableFilter } from './Filter';
import { FilterGroup } from './FilterGroup';
import { FilterSelectModal } from './FilterSelectModal';
import { TableSearchInput } from './Search';
/*
* Load list of hidden columns from local storage.
* Returns a list of column names which are "hidden" for the current table
*/
function loadHiddenColumns(tableKey: string) {
return JSON.parse(
localStorage.getItem(`inventree-hidden-table-columns-${tableKey}`) || '[]'
);
}
/**
* Write list of hidden columns to local storage
* @param tableKey : string - unique key for the table
* @param columns : string[] - list of column names
*/
function saveHiddenColumns(tableKey: string, columns: any[]) {
localStorage.setItem(
`inventree-hidden-table-columns-${tableKey}`,
JSON.stringify(columns)
);
}
/**
* Loads the list of active filters from local storage
* @param tableKey : string - unique key for the table
* @param filterList : TableFilter[] - list of available filters
* @returns a map of active filters for the current table, {name: value}
*/
function loadActiveFilters(tableKey: string, filterList: TableFilter[]) {
let active = JSON.parse(
localStorage.getItem(`inventree-active-table-filters-${tableKey}`) || '{}'
);
// We expect that the active filter list is a map of {name: value}
// Return *only* those filters which are in the filter list
let x = filterList
.filter((f) => f.name in active)
.map((f) => ({
...f,
value: active[f.name]
}));
return x;
}
/**
* Write the list of active filters to local storage
* @param tableKey : string - unique key for the table
* @param filters : any - map of active filters, {name: value}
*/
function saveActiveFilters(tableKey: string, filters: TableFilter[]) {
let active = Object.fromEntries(filters.map((flt) => [flt.name, flt.value]));
localStorage.setItem(
`inventree-active-table-filters-${tableKey}`,
JSON.stringify(active)
);
}
/**
* Table Component which extends DataTable with custom InvenTree functionality
*/
export function InvenTreeTable({
url,
params,
columns,
enableDownload = false,
enableFilters = true,
enablePagination = true,
enableRefresh = true,
enableSearch = true,
enableSelection = false,
pageSize = 25,
tableKey = '',
defaultSortColumn = '',
noRecordsText = t`No records found`,
printingActions = [],
barcodeActions = [],
customActionGroups = [],
customFilters = []
}: {
url: string;
params: any;
columns: TableColumn[];
tableKey: string;
defaultSortColumn?: string;
noRecordsText?: string;
enableDownload?: boolean;
enableFilters?: boolean;
enableSelection?: boolean;
enableSearch?: boolean;
enablePagination?: boolean;
enableRefresh?: boolean;
pageSize?: number;
printingActions?: any[];
barcodeActions?: any[];
customActionGroups?: any[];
customFilters?: TableFilter[];
}) {
// Data columns
const [dataColumns, setDataColumns] = useState<any[]>(columns);
// Check if any columns are switchable (can be hidden)
const hasSwitchableColumns = columns.some((col: any) => col.switchable);
// Manage state for switchable columns (initially load from local storage)
let [hiddenColumns, setHiddenColumns] = useState(() =>
loadHiddenColumns(tableKey)
);
// Update column visibility when hiddenColumns change
useEffect(() => {
setDataColumns(
dataColumns.map((col) => {
return {
...col,
hidden: hiddenColumns.includes(col.accessor)
};
})
);
}, [hiddenColumns]);
// Callback when column visibility is toggled
function toggleColumn(columnName: string) {
let newColumns = [...dataColumns];
let colIdx = newColumns.findIndex((col) => col.accessor == columnName);
if (colIdx >= 0 && colIdx < newColumns.length) {
newColumns[colIdx].hidden = !newColumns[colIdx].hidden;
}
let hiddenColumnNames = newColumns
.filter((col) => col.hidden)
.map((col) => col.accessor);
// Save list of hidden columns to local storage
saveHiddenColumns(tableKey, hiddenColumnNames);
// Refresh state
setHiddenColumns(loadHiddenColumns(tableKey));
}
// Check if custom filtering is enabled for this table
const hasCustomFilters = enableFilters && customFilters.length > 0;
// Filter selection open state
const [filterSelectOpen, setFilterSelectOpen] = useState<boolean>(false);
// Pagination
const [page, setPage] = useState(1);
// Filter list visibility
const [filtersVisible, setFiltersVisible] = useState<boolean>(false);
// Map of currently active filters, {name: value}
const [activeFilters, setActiveFilters] = useState(() =>
loadActiveFilters(tableKey, customFilters)
);
/*
* Callback for the "add filter" button.
* Launches a modal dialog to add a new filter
*/
function onFilterAdd(name: string, value: string) {
let filters = [...activeFilters];
let newFilter = customFilters.find((flt) => flt.name == name);
if (newFilter) {
filters.push({
...newFilter,
value: value
});
saveActiveFilters(tableKey, filters);
setActiveFilters(filters);
}
}
/*
* Callback function when a specified filter is removed from the table
*/
function onFilterRemove(filterName: string) {
let filters = activeFilters.filter((flt) => flt.name != filterName);
saveActiveFilters(tableKey, filters);
setActiveFilters(filters);
}
/*
* Callback function when all custom filters are removed from the table
*/
function onFilterClearAll() {
saveActiveFilters(tableKey, []);
setActiveFilters([]);
}
// Search term
const [searchTerm, setSearchTerm] = useState<string>('');
/*
* Construct query filters for the current table
*/
function getTableFilters(paginate: boolean = false) {
let queryParams = { ...params };
// Add custom filters
activeFilters.forEach((flt) => (queryParams[flt.name] = flt.value));
// Add custom search term
if (searchTerm) {
queryParams.search = searchTerm;
}
// Pagination
if (enablePagination && paginate) {
queryParams.limit = pageSize;
queryParams.offset = (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>({
columnAccessor: defaultSortColumn,
direction: 'asc'
});
// Return the ordering parameter
function getOrderingTerm() {
let key = sortStatus.columnAccessor;
// Sorting column not specified
if (key == '') {
return '';
}
// Find matching column:
// If column provides custom ordering term, use that
let column = dataColumns.find((col) => col.accessor == key);
return column?.ordering || key;
}
// Missing records text (based on server response)
const [missingRecordsText, setMissingRecordsText] =
useState<string>(noRecordsText);
// Data selection
const [selectedRecords, setSelectedRecords] = useState<any[]>([]);
function onSelectedRecordsChange(records: any[]) {
setSelectedRecords(records);
}
const handleSortStatusChange = (status: DataTableSortStatus) => {
setPage(1);
setSortStatus(status);
};
// Function to perform API query to fetch required data
const fetchTableData = async () => {
let queryParams = getTableFilters(true);
return api
.get(`${url}`, {
params: queryParams,
timeout: 30 * 1000
})
.then(function (response) {
switch (response.status) {
case 200:
setMissingRecordsText(noRecordsText);
return response.data;
case 400:
setMissingRecordsText(t`Bad request`);
break;
case 401:
setMissingRecordsText(t`Unauthorized`);
break;
case 403:
setMissingRecordsText(t`Forbidden`);
break;
case 404:
setMissingRecordsText(t`Not found`);
break;
default:
setMissingRecordsText(
t`Unknown error` + ': ' + response.statusText
); // TODO: Translate
break;
}
return [];
})
.catch(function (error) {
setMissingRecordsText(t`Error` + ': ' + error.message);
return [];
});
};
const { data, isError, isFetching, isLoading, refetch } = useQuery(
[
`table-${tableKey}`,
sortStatus.columnAccessor,
sortStatus.direction,
page,
activeFilters,
searchTerm
],
fetchTableData,
{
refetchOnWindowFocus: false,
refetchOnMount: 'always'
}
);
return (
<>
<FilterSelectModal
availableFilters={customFilters}
activeFilters={activeFilters}
opened={filterSelectOpen}
onCreateFilter={onFilterAdd}
onClose={() => setFilterSelectOpen(false)}
/>
<Stack>
<Group position="apart">
<Group position="left" spacing={5}>
{customActionGroups.map((group: any, idx: number) => group)}
{barcodeActions.length > 0 && (
<ButtonMenu
icon={<IconBarcode />}
label={t`Barcode actions`}
tooltip={t`Barcode actions`}
actions={barcodeActions}
/>
)}
{printingActions.length > 0 && (
<ButtonMenu
icon={<IconPrinter />}
label={t`Print actions`}
tooltip={t`Print actions`}
actions={printingActions}
/>
)}
{enableDownload && (
<DownloadAction downloadCallback={downloadData} />
)}
</Group>
<Space />
<Group position="right" spacing={5}>
{enableSearch && (
<TableSearchInput
searchCallback={(term: string) => setSearchTerm(term)}
/>
)}
{enableRefresh && (
<ActionIcon>
<Tooltip label={t`Refresh data`}>
<IconRefresh onClick={() => refetch()} />
</Tooltip>
</ActionIcon>
)}
{hasSwitchableColumns && (
<TableColumnSelect
columns={dataColumns}
onToggleColumn={toggleColumn}
/>
)}
{hasCustomFilters && (
<Indicator
size="xs"
label={activeFilters.length}
disabled={activeFilters.length == 0}
>
<ActionIcon>
<Tooltip label={t`Table filters`}>
<IconFilter
onClick={() => setFiltersVisible(!filtersVisible)}
/>
</Tooltip>
</ActionIcon>
</Indicator>
)}
</Group>
</Group>
{filtersVisible && (
<FilterGroup
activeFilters={activeFilters}
onFilterAdd={() => setFilterSelectOpen(true)}
onFilterRemove={onFilterRemove}
onFilterClearAll={onFilterClearAll}
/>
)}
<DataTable
withBorder
striped
highlightOnHover
loaderVariant="dots"
idAccessor={'pk'}
minHeight={200}
totalRecords={data?.count ?? data?.length ?? 0}
recordsPerPage={pageSize}
page={page}
onPageChange={setPage}
sortStatus={sortStatus}
onSortStatusChange={handleSortStatusChange}
selectedRecords={enableSelection ? selectedRecords : undefined}
onSelectedRecordsChange={
enableSelection ? onSelectedRecordsChange : undefined
}
fetching={isFetching}
noRecordsText={missingRecordsText}
records={data?.results ?? data ?? []}
columns={dataColumns}
/>
</Stack>
</>
);
}

View File

@ -0,0 +1,31 @@
import { CloseButton, TextInput } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { IconSearch } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
export function TableSearchInput({
searchCallback
}: {
searchCallback: (searchTerm: string) => void;
}) {
const [value, setValue] = useState<string>('');
const [searchText] = useDebouncedValue(value, 500);
useEffect(() => {
searchCallback(searchText);
}, [searchText]);
return (
<TextInput
value={value}
icon={<IconSearch />}
placeholder="Search"
onChange={(event) => setValue(event.target.value)}
rightSection={
value.length > 0 ? (
<CloseButton size="xs" onClick={(event) => setValue('')} />
) : null
}
/>
);
}

View File

@ -0,0 +1,135 @@
import { t } from '@lingui/macro';
import { Progress } from '@mantine/core';
import { useMemo } from 'react';
import { ThumbnailHoverCard } from '../../items/Thumbnail';
import { TableColumn } from '../Column';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
/**
* Construct a list of columns for the build order table
*/
function buildOrderTableColumns(): TableColumn[] {
return [
{
accessor: 'reference',
sortable: true,
title: t`Reference`
// TODO: Link to the build order detail page
},
{
accessor: 'part',
sortable: true,
title: t`Part`,
render: (record: any) => {
let part = record.part_detail;
return (
part && (
<ThumbnailHoverCard
src={part.thumbnail || part.image}
text={part.full_name}
link=""
/>
)
);
}
},
{
accessor: 'title',
sortable: false,
title: t`Description`,
switchable: true
},
{
accessor: 'project_code',
title: t`Project Code`,
sortable: true,
switchable: false,
hidden: true
// TODO: Hide this if project code is not enabled
// TODO: Custom render function here
},
{
accessor: 'priority',
title: t`Priority`,
sortable: true,
switchable: true
},
{
accessor: 'quantity',
sortable: true,
title: t`Quantity`,
switchable: true
},
{
accessor: 'completed',
sortable: true,
title: t`Completed`,
render: (record: any) => {
let progress =
record.quantity <= 0 ? 0 : (100 * record.completed) / record.quantity;
return (
<Progress
value={progress}
label={record.completed}
color={progress < 100 ? 'blue' : 'green'}
size="xl"
radius="xl"
/>
);
}
},
{
accessor: 'status',
sortable: true,
title: t`Status`,
switchable: true
// TODO: Custom render function here (status label)
},
{
accessor: 'creation_date',
sortable: true,
title: t`Created`,
switchable: true
}
// TODO: issued_by
// TODO: responsible
// TODO: target_date
// TODO: completion_date
];
}
function buildOrderTableFilters(): TableFilter[] {
return [];
}
function buildOrderTableParams(params: any): any {
return {
...params,
part_detail: true
};
}
/*
* Construct a table of build orders, according to the provided parameters
*/
export function BuildOrderTable({ params = {} }: { params?: any }) {
// Add required query parameters
let tableParams = useMemo(() => buildOrderTableParams(params), [params]);
let tableColumns = useMemo(() => buildOrderTableColumns(), []);
let tableFilters = useMemo(() => buildOrderTableFilters(), []);
tableParams.part_detail = true;
return (
<InvenTreeTable
url="build/"
enableDownload
tableKey="build-order-table"
params={tableParams}
columns={tableColumns}
customFilters={tableFilters}
/>
);
}

View File

@ -0,0 +1,210 @@
import { t } from '@lingui/macro';
import { Text } from '@mantine/core';
import { useMemo } from 'react';
import { notYetImplemented } from '../../../functions/notifications';
import { shortenString } from '../../../functions/tables';
import { ThumbnailHoverCard } from '../../items/Thumbnail';
import { TableColumn } from '../Column';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
/**
* Construct a list of columns for the part table
*/
function partTableColumns(): TableColumn[] {
return [
{
accessor: 'name',
sortable: true,
title: t`Part`,
render: function (record: any) {
// TODO - Link to the part detail page
return (
<ThumbnailHoverCard
src={record.thumbnail || record.image}
text={record.name}
link=""
/>
);
}
},
{
accessor: 'IPN',
title: t`IPN`,
sortable: true,
switchable: true
},
{
accessor: 'units',
sortable: true,
title: t`Units`,
switchable: true
},
{
accessor: 'description',
title: t`Description`,
sortable: true,
switchable: true
},
{
accessor: 'category',
title: t`Category`,
sortable: true,
render: function (record: any) {
// TODO: Link to the category detail page
return shortenString({
str: record.category_detail.pathstring
});
}
},
{
accessor: 'total_in_stock',
title: t`Stock`,
sortable: true,
switchable: true
},
{
accessor: 'price_range',
title: t`Price Range`,
sortable: false,
switchable: true,
render: function (record: any) {
// TODO: Render price range
return '-- price --';
}
},
{
accessor: 'link',
title: t`Link`,
switchable: true
}
];
}
/**
* Construct a set of filters for the part table
*/
function partTableFilters(): TableFilter[] {
return [
{
name: 'active',
label: t`Active`,
description: t`Filter by part active status`,
type: 'boolean'
},
{
name: 'assembly',
label: t`Assembly`,
description: t`Filter by assembly attribute`,
type: 'boolean'
},
{
name: 'cascade',
label: t`Include Subcategories`,
description: t`Include parts in subcategories`,
type: 'boolean'
},
{
name: 'component',
label: t`Component`,
description: t`Filter by component attribute`,
type: 'boolean'
},
{
name: 'trackable',
label: t`Trackable`,
description: t`Filter by trackable attribute`,
type: 'boolean'
},
{
name: 'has_units',
label: t`Has Units`,
description: t`Filter by parts which have units`,
type: 'boolean'
},
{
name: 'has_ipn',
label: t`Has IPN`,
description: t`Filter by parts which have an internal part number`,
type: 'boolean'
},
{
name: 'has_stock',
label: t`Has Stock`,
description: t`Filter by parts which have stock`,
type: 'boolean'
},
{
name: 'low_stock',
label: t`Low Stock`,
description: t`Filter by parts which have low stock`,
type: 'boolean'
},
{
name: 'purchaseable',
label: t`Purchaseable`,
description: t`Filter by parts which are purchaseable`,
type: 'boolean'
},
{
name: 'salable',
label: t`Salable`,
description: t`Filter by parts which are salable`,
type: 'boolean'
},
{
name: 'virtual',
label: t`Virtual`,
description: t`Filter by parts which are virtual`,
type: 'choice',
choices: [
{ value: 'true', label: t`Virtual` },
{ value: 'false', label: t`Not Virtual` }
]
}
// unallocated_stock
// starred
// stocktake
// is_template
// virtual
// has_pricing
// TODO: Any others from table_filters.js?
];
}
function partTableParams(params: any): any {
return {
...params,
category_detail: true
};
}
/**
* PartListTable - Displays a list of parts, based on the provided parameters
* @param {Object} params - The query parameters to pass to the API
* @returns
*/
export function PartListTable({ params = {} }: { params?: any }) {
let tableParams = useMemo(() => partTableParams(params), []);
let tableColumns = useMemo(() => partTableColumns(), []);
let tableFilters = useMemo(() => partTableFilters(), []);
// Add required query parameters
tableParams.category_detail = true;
return (
<InvenTreeTable
url="part/"
enableDownload
tableKey="part-table"
printingActions={[
<Text onClick={notYetImplemented}>Hello</Text>,
<Text onClick={notYetImplemented}>World</Text>
]}
params={tableParams}
columns={tableColumns}
customFilters={tableFilters}
/>
);
}

View File

@ -0,0 +1,155 @@
import { t } from '@lingui/macro';
import { Group } from '@mantine/core';
import { IconEdit, IconTrash } from '@tabler/icons-react';
import { useEffect, useMemo, useState } from 'react';
import { notYetImplemented } from '../../../functions/notifications';
import { ActionButton } from '../../items/ActionButton';
import { ThumbnailHoverCard } from '../../items/Thumbnail';
import { TableColumn } from '../Column';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from './../InvenTreeTable';
/**
* Construct a list of columns for the stock item table
*/
function stockItemTableColumns(): TableColumn[] {
return [
{
accessor: 'part',
sortable: true,
title: t`Part`,
render: function (record: any) {
let part = record.part_detail;
return (
<ThumbnailHoverCard
src={part.thumbnail || part.image}
text={part.name}
link=""
/>
);
}
},
{
accessor: 'part_detail.description',
sortable: false,
switchable: true,
title: t`Description`
},
{
accessor: 'quantity',
sortable: true,
title: t`Stock`
// TODO: Custom renderer for stock quantity
},
{
accessor: 'status',
sortable: true,
switchable: true,
filter: true,
title: t`Status`
// TODO: Custom renderer for stock status label
},
{
accessor: 'batch',
sortable: true,
switchable: true,
title: t`Batch`
},
{
accessor: 'location',
sortable: true,
switchable: true,
title: t`Location`,
render: function (record: any) {
// TODO: Custom renderer for location
return record.location;
}
},
// TODO: stocktake column
// TODO: expiry date
// TODO: last updated
// TODO: purchase order
// TODO: Supplier part
// TODO: purchase price
// TODO: stock value
// TODO: packaging
// TODO: notes
{
accessor: 'actions',
title: t`Actions`,
sortable: false,
render: function (record: any) {
return (
<Group position="right" spacing={5} noWrap={true}>
{/* {EditButton(setEditing, editing)} */}
{/* {DeleteButton()} */}
<ActionButton
color="green"
icon={<IconEdit />}
tooltip="Edit stock item"
onClick={() => notYetImplemented()}
/>
<ActionButton
color="red"
tooltip="Delete stock item"
icon={<IconTrash />}
onClick={() => notYetImplemented()}
/>
</Group>
);
}
}
];
}
/**
* Return a set of parameters for the stock item table
*/
function stockItemTableParams(params: any): any {
return {
...params,
part_detail: true,
location_detail: true
};
}
/**
* Construct a list of available filters for the stock item table
*/
function stockItemTableFilters(): TableFilter[] {
return [
{
name: 'test_filter',
label: t`Test Filter`,
description: t`This is a test filter`,
type: 'choice',
choiceFunction: () => [
{ value: '1', label: 'One' },
{ value: '2', label: 'Two' },
{ value: '3', label: 'Three' }
]
}
];
}
/*
* Load a table of stock items
*/
export function StockItemTable({ params = {} }: { params?: any }) {
let tableParams = useMemo(() => stockItemTableParams(params), []);
let tableColumns = useMemo(() => stockItemTableColumns(), []);
let tableFilters = useMemo(() => stockItemTableFilters(), []);
return (
<InvenTreeTable
url="stock/"
tableKey="stock-table"
enableDownload
enableSelection
params={tableParams}
columns={tableColumns}
customFilters={tableFilters}
/>
);
}

View File

@ -21,7 +21,10 @@ export const footerLinks = [
];
export const navTabs = [
{ text: <Trans>Home</Trans>, name: 'home' },
{ text: <Trans>Dashboard</Trans>, name: 'dashboard' }
{ text: <Trans>Dashboard</Trans>, name: 'dashboard' },
{ text: <Trans>Parts</Trans>, name: 'parts' },
{ text: <Trans>Stock</Trans>, name: 'stock' },
{ text: <Trans>Build</Trans>, name: 'build' }
];
export const docLinks = {

View File

@ -0,0 +1,13 @@
import { t } from '@lingui/macro';
import { notifications } from '@mantine/notifications';
/**
* Show a notification that the feature is not yet implemented
*/
export function notYetImplemented() {
notifications.show({
title: t`Not implemented`,
message: t`This feature is not yet implemented`,
color: 'red'
});
}

View File

@ -0,0 +1,25 @@
/**
* Reduce an input string to a given length, adding an ellipsis if necessary
* @param str - String to shorten
* @param len - Length to shorten to
*/
export function shortenString({
str,
len = 100
}: {
str: string;
len?: number;
}) {
// Ensure that the string is a string
str = str.toString();
// If the string is already short enough, return it
if (str.length <= len) {
return str;
}
// Otherwise, shorten it
let N = Math.floor(len / 2 - 1);
return str.slice(0, N) + '...' + str.slice(-N);
}

View File

@ -0,0 +1,20 @@
import { Trans } from '@lingui/macro';
import { Group } from '@mantine/core';
import { PlaceholderPill } from '../../components/items/Placeholder';
import { StylishText } from '../../components/items/StylishText';
import { BuildOrderTable } from '../../components/tables/build/BuildOrderTable';
export default function Build() {
return (
<>
<Group>
<StylishText>
<Trans>Build Orders</Trans>
</StylishText>
<PlaceholderPill />
</Group>
<BuildOrderTable />
</>
);
}

View File

@ -0,0 +1,20 @@
import { Trans } from '@lingui/macro';
import { Group } from '@mantine/core';
import { PlaceholderPill } from '../../components/items/Placeholder';
import { StylishText } from '../../components/items/StylishText';
import { PartListTable } from '../../components/tables/part/PartTable';
export default function Part() {
return (
<>
<Group>
<StylishText>
<Trans>Parts</Trans>
</StylishText>
<PlaceholderPill />
</Group>
<PartListTable />
</>
);
}

View File

@ -0,0 +1,20 @@
import { Trans } from '@lingui/macro';
import { Group } from '@mantine/core';
import { PlaceholderPill } from '../../components/items/Placeholder';
import { StylishText } from '../../components/items/StylishText';
import { StockItemTable } from '../../components/tables/stock/StockItemTable';
export default function Stock() {
return (
<>
<Group>
<StylishText>
<Trans>Stock Items</Trans>
</StylishText>
<PlaceholderPill />
</Group>
<StockItemTable />
</>
);
}

View File

@ -8,6 +8,10 @@ export const LayoutComponent = Loadable(
lazy(() => import('./components/nav/Layout'))
);
export const Home = Loadable(lazy(() => import('./pages/Index/Home')));
export const Parts = Loadable(lazy(() => import('./pages/Index/Part')));
export const Stock = Loadable(lazy(() => import('./pages/Index/Stock')));
export const Build = Loadable(lazy(() => import('./pages/Index/Build')));
export const Dashboard = Loadable(
lazy(() => import('./pages/Index/Dashboard'))
);
@ -48,6 +52,18 @@ export const router = createBrowserRouter(
path: 'dashboard/',
element: <Dashboard />
},
{
path: 'parts/',
element: <Parts />
},
{
path: 'stock/',
element: <Stock />
},
{
path: 'build/',
element: <Build />
},
{
path: '/profile/:tabValue',
element: <Profile />

View File

@ -14,5 +14,10 @@ export default defineConfig({
build: {
manifest: true,
outDir: '../../InvenTree/web/static/web'
},
server: {
watch: {
usePolling: true
}
}
});

File diff suppressed because it is too large Load Diff

951
yarn.lock

File diff suppressed because it is too large Load Diff