2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-12-18 18:28:18 +00:00

Refactor more UI components out into lib directory (#9994)

* Refactor table column types

* Offloading more component type definitions

* Remove unused funcs

* Move conversion functions

* ActionButton

* Refactor YesNoButton

* ProgressBar

* make row actions available

* search input

* ButtonMenu

* Bump UI version

* Tweak function defs
This commit is contained in:
Oliver
2025-07-10 06:54:53 +10:00
committed by GitHub
parent c6166d7c4a
commit d137728e60
119 changed files with 664 additions and 524 deletions

View File

@@ -0,0 +1,61 @@
import {
ActionIcon,
type FloatingPosition,
Group,
Tooltip
} from '@mantine/core';
import type { ReactNode } from 'react';
import { identifierString } from '../functions/Conversion';
export type ActionButtonProps = {
icon?: ReactNode;
text?: string;
color?: string;
tooltip?: string;
variant?: string;
size?: number | string;
radius?: number | string;
disabled?: boolean;
onClick: (event?: any) => void;
hidden?: boolean;
tooltipAlignment?: FloatingPosition;
};
/**
* Construct a simple action button with consistent styling
*/
export function ActionButton(props: ActionButtonProps) {
const hidden = props.hidden ?? false;
return (
!hidden && (
<Tooltip
key={`tooltip-${props.tooltip ?? props.text}`}
disabled={!props.tooltip && !props.text}
label={props.tooltip ?? props.text}
position={props.tooltipAlignment ?? 'left'}
>
<ActionIcon
key={`action-icon-${props.tooltip ?? props.text}`}
disabled={props.disabled}
p={17}
radius={props.radius ?? 'xs'}
color={props.color}
size={props.size}
aria-label={`action-button-${identifierString(
props.tooltip ?? props.text ?? ''
)}`}
onClick={(event: any) => {
props.onClick(event);
}}
variant={props.variant ?? 'transparent'}
>
<Group gap='xs' wrap='nowrap'>
{props.icon}
</Group>
</ActionIcon>
</Tooltip>
)
);
}

View File

@@ -0,0 +1,33 @@
import { ActionIcon, Menu, Tooltip } from '@mantine/core';
/**
* 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 = ''
}: Readonly<{
icon: any;
actions: React.ReactNode[];
label?: string;
tooltip?: string;
}>) {
return (
<Menu shadow='xs'>
<Menu.Target>
<ActionIcon variant='default'>
<Tooltip label={tooltip}>{icon}</Tooltip>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{label && <Menu.Label>{label}</Menu.Label>}
{actions.map((action, i) => (
<Menu.Item key={`${i}-${action}`}>{action}</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}

View File

@@ -0,0 +1,46 @@
import { Progress, Stack, Text } from '@mantine/core';
import { useMemo } from 'react';
import { formatDecimal } from '../functions/Formatting';
export type ProgressBarProps = {
value: number;
maximum?: number;
label?: string;
progressLabel?: boolean;
animated?: boolean;
size?: string;
};
/**
* A progress bar element, built on mantine.Progress
* The color of the bar is determined based on the value
*/
export function ProgressBar(props: Readonly<ProgressBarProps>) {
const progress = useMemo(() => {
const maximum = props.maximum ?? 100;
const value = Math.max(props.value, 0);
if (maximum == 0) {
return 0;
}
return (value / maximum) * 100;
}, [props]);
return (
<Stack gap={2} style={{ flexGrow: 1, minWidth: '100px' }}>
{props.progressLabel && (
<Text ta='center' size='xs'>
{formatDecimal(props.value)} / {formatDecimal(props.maximum)}
</Text>
)}
<Progress
value={progress}
color={progress < 100 ? 'orange' : progress > 100 ? 'blue' : 'green'}
size={props.size ?? 'md'}
radius='sm'
animated={props.animated}
/>
</Stack>
);
}

View File

@@ -0,0 +1,157 @@
import { t } from '@lingui/core/macro';
import { ActionIcon, Menu, Tooltip } from '@mantine/core';
import {
IconArrowRight,
IconCircleX,
IconCopy,
IconDots,
IconEdit,
IconTrash
} from '@tabler/icons-react';
import { type ReactNode, useMemo, useState } from 'react';
import { cancelEvent } from '../functions/Events';
import { getDetailUrl } from '../functions/Navigation';
import { navigateToLink } from '../functions/Navigation';
import type { RowAction, RowViewProps } from '../types/Tables';
export type { RowAction, RowViewProps } from '../types/Tables';
// Component for viewing a row in a table
export function RowViewAction(props: RowViewProps): RowAction {
return {
...props,
color: undefined,
icon: <IconArrowRight />,
onClick: (event: any) => {
const url = getDetailUrl(props.modelType, props.modelId);
navigateToLink(url, props.navigate, event);
}
};
}
// Component for duplicating a row in a table
export function RowDuplicateAction(props: RowAction): RowAction {
return {
...props,
title: t`Duplicate`,
color: 'green',
icon: <IconCopy />
};
}
// Component for editing a row in a table
export function RowEditAction(props: RowAction): RowAction {
return {
...props,
title: t`Edit`,
color: 'blue',
icon: <IconEdit />
};
}
// Component for deleting a row in a table
export function RowDeleteAction(props: RowAction): RowAction {
return {
...props,
title: t`Delete`,
color: 'red',
icon: <IconTrash />
};
}
// Component for cancelling a row in a table
export function RowCancelAction(props: RowAction): RowAction {
return {
...props,
title: t`Cancel`,
color: 'red',
icon: <IconCircleX />
};
}
/**
* Component for displaying actions for a row in a table.
* Displays a simple dropdown menu with a list of actions.
*/
export function RowActions({
title,
actions,
disabled = false,
index
}: {
title?: string;
disabled?: boolean;
actions: RowAction[];
index?: number;
}): ReactNode {
// Prevent default event handling
// Ref: https://icflorescu.github.io/mantine-datatable/examples/links-or-buttons-inside-clickable-rows-or-cells
function openMenu(event: any) {
cancelEvent(event);
setOpened(!opened);
}
const [opened, setOpened] = useState(false);
const visibleActions = useMemo(() => {
return actions.filter((action) => !action.hidden);
}, [actions]);
// Render a single action icon
function RowActionIcon(action: Readonly<RowAction>) {
return (
<Tooltip
withinPortal={true}
label={action.tooltip ?? action.title}
key={action.title}
position='left'
>
<Menu.Item
color={action.color}
leftSection={action.icon}
onClick={(event) => {
// Prevent clicking on the action from selecting the row itself
cancelEvent(event);
action.onClick?.(event);
setOpened(false);
}}
disabled={action.disabled || false}
>
{action.title}
</Menu.Item>
</Tooltip>
);
}
return (
visibleActions.length > 0 && (
<Menu
withinPortal={true}
disabled={disabled}
position='bottom-end'
opened={opened}
onChange={setOpened}
>
<Menu.Target>
<Tooltip withinPortal={true} label={title || t`Actions`}>
<ActionIcon
key={`row-action-menu-${index ?? ''}`}
aria-label={`row-action-menu-${index ?? ''}`}
onClick={openMenu}
disabled={disabled}
variant='subtle'
color='gray'
>
<IconDots />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
{visibleActions.map((action) => (
<RowActionIcon key={action.title} {...action} />
))}
</Menu.Dropdown>
</Menu>
)
);
}

View File

@@ -0,0 +1,49 @@
import { t } from '@lingui/core/macro';
import { CloseButton, TextInput } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { IconSearch } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
/**
* A search input component that debounces user input
*/
export function SearchInput({
disabled,
debounce,
placeholder,
searchCallback
}: Readonly<{
disabled?: boolean;
debounce?: number;
placeholder?: string;
searchCallback: (searchTerm: string) => void;
}>) {
const [value, setValue] = useState<string>('');
const [searchText] = useDebouncedValue(value, debounce ?? 500);
useEffect(() => {
searchCallback(searchText);
}, [searchText]);
return (
<TextInput
value={value}
disabled={disabled}
aria-label='table-search-input'
leftSection={<IconSearch />}
placeholder={placeholder ?? t`Search`}
onChange={(event) => setValue(event.target.value)}
rightSection={
value.length > 0 ? (
<CloseButton
size='xs'
onClick={() => {
setValue('');
searchCallback('');
}}
/>
) : null
}
/>
);
}

View File

@@ -0,0 +1,42 @@
import { t } from '@lingui/core/macro';
import { Badge, Skeleton } from '@mantine/core';
import { isTrue } from '../functions/Conversion';
export function PassFailButton({
value,
passText,
failText
}: Readonly<{
value: any;
passText?: string;
failText?: string;
}>) {
const v = isTrue(value);
const pass = passText ?? t`Pass`;
const fail = failText ?? t`Fail`;
return (
<Badge
color={v ? 'green' : 'red'}
variant='filled'
radius='lg'
size='sm'
style={{ maxWidth: '50px' }}
>
{v ? pass : fail}
</Badge>
);
}
export function YesNoButton({ value }: Readonly<{ value: any }>) {
return <PassFailButton value={value} passText={t`Yes`} failText={t`No`} />;
}
export function YesNoUndefinedButton({ value }: Readonly<{ value?: boolean }>) {
if (value === undefined) {
return <Skeleton height={15} width={32} />;
} else {
return <YesNoButton value={value} />;
}
}

View File

@@ -0,0 +1,42 @@
/*
* Determine if the provided value is "true":
*
* Many settings stored on the server are true/false,
* but stored as string values, "true" / "false".
*
* This function provides a wrapper to ensure that the return type is boolean
*/
export function isTrue(value: any): boolean {
if (value === true) {
return true;
}
if (value === false) {
return false;
}
const s = String(value).trim().toLowerCase();
return ['true', 'yes', '1', 'on', 't', 'y'].includes(s);
}
/*
* Resolve a nested item in an object.
* Returns the resolved item, if it exists.
*
* e.g. resolveItem(data, "sub.key.accessor")
*
* Allows for retrieval of nested items in an object.
*/
export function resolveItem(obj: any, path: string): any {
const properties = path.split('.');
return properties.reduce((prev, curr) => prev?.[curr], obj);
}
export function identifierString(value: string): string {
// Convert an input string e.g. "Hello World" into a string that can be used as an identifier, e.g. "hello-world"
value = value || '-';
return value.toLowerCase().replace(/[^a-z0-9]/g, '-');
}

View File

@@ -0,0 +1,88 @@
export interface FormatDecmimalOptionsInterface {
digits?: number;
minDigits?: number;
locale?: string;
}
export interface FormatCurrencyOptionsInterface {
digits?: number;
minDigits?: number;
currency?: string;
locale?: string;
multiplier?: number;
}
export function formatDecimal(
value: number | null | undefined,
options: FormatDecmimalOptionsInterface = {}
) {
const locale = options.locale || navigator.language || 'en-US';
if (value === null || value === undefined) {
return value;
}
const formatter = new Intl.NumberFormat(locale, {
style: 'decimal',
maximumFractionDigits: options.digits ?? 6,
minimumFractionDigits: options.minDigits ?? 0
});
return formatter.format(value);
}
/*
* format currency (money) value based on current settings
*
* Options:
* - currency: Currency code (uses default value if none provided)
* - locale: Locale specified (uses default value if none provided)
* - digits: Maximum number of significant digits (default = 10)
*/
export function formatCurrencyValue(
value: number | string | null | undefined,
options: FormatCurrencyOptionsInterface = {}
) {
if (value == null || value == undefined) {
return null;
}
value = Number.parseFloat(value.toString());
if (Number.isNaN(value) || !Number.isFinite(value)) {
return null;
}
value *= options.multiplier ?? 1;
// Extract locale information
const locale = options.locale || navigator.language || 'en-US';
const minDigits = options.minDigits ?? 0;
const maxDigits = options.digits ?? 6;
const formatter = new Intl.NumberFormat(locale, {
style: 'currency',
currency: options.currency,
maximumFractionDigits: Math.max(minDigits, maxDigits),
minimumFractionDigits: Math.min(minDigits, maxDigits)
});
return formatter.format(value);
}
/*
* Format a file size (in bytes) into a human-readable format
*/
export function formatFileSize(size: number) {
const suffixes: string[] = ['B', 'KB', 'MB', 'GB'];
let idx = 0;
while (size > 1024 && idx < suffixes.length) {
size /= 1024;
idx++;
}
return `${size.toFixed(2)} ${suffixes[idx]}`;
}

View File

@@ -13,6 +13,7 @@ export type { ModelDict } from './enums/ModelInformation';
export { UserRoles, UserPermissions } from './enums/Roles';
export type { InvenTreePluginContext } from './types/Plugins';
export type { RowAction, RowViewProps } from './types/Tables';
// Common utility functions
export { apiUrl } from './functions/Api';
@@ -22,3 +23,24 @@ export {
navigateToLink
} from './functions/Navigation';
export { checkPluginVersion } from './functions/Plugins';
export {
formatCurrencyValue,
formatDecimal,
formatFileSize
} from './functions/Formatting';
// Common UI components
export { ActionButton } from './components/ActionButton';
export { ButtonMenu } from './components/ButtonMenu';
export { ProgressBar } from './components/ProgressBar';
export { PassFailButton, YesNoButton } from './components/YesNoButton';
export { SearchInput } from './components/SearchInput';
export {
RowViewAction,
RowDuplicateAction,
RowEditAction,
RowDeleteAction,
RowCancelAction,
RowActions
} from './components/RowActions';

View File

@@ -1,5 +1,13 @@
import type { SetURLSearchParams } from 'react-router-dom';
import type { FilterSetState } from './Filters';
import type { MantineStyleProp } from '@mantine/core';
import type {
DataTableCellClickHandler,
DataTableRowExpansionProps
} from 'mantine-datatable';
import type { ReactNode } from 'react';
import type { NavigateFunction, SetURLSearchParams } from 'react-router-dom';
import type { ModelType } from '../enums/ModelType';
import type { FilterSetState, TableFilter } from './Filters';
import type { ApiFormFieldType } from './Forms';
/*
* Type definition for representing the state of a table:
@@ -65,3 +73,140 @@ export type TableState = {
setHiddenColumns: (columns: string[]) => void;
idAccessor?: string;
};
/**
* Table column properties
*
* @param T - The type of the record
* @param accessor - The key in the record to access
* @param title - The title of the column - Note: this may be supplied by the API, and is not required, but it can be overridden if required
* @param ordering - The key in the record to sort by (defaults to accessor)
* @param sortable - Whether the column is sortable
* @param switchable - Whether the column is switchable
* @param defaultVisible - Whether the column is visible by default (defaults to true)
* @param hidden - Whether the column is hidden (forced hidden, cannot be toggled by the user))
* @param editable - Whether the value of this column can be edited
* @param definition - Optional field definition for the column
* @param render - A custom render function
* @param filter - A custom filter function
* @param filtering - Whether the column is filterable
* @param width - The width of the column
* @param resizable - Whether the column is resizable (defaults to true)
* @param noWrap - Whether the column should wrap
* @param ellipsis - Whether the column should be ellipsized
* @param textAlign - The text alignment of the column
* @param cellsStyle - The style of the cells in the column
* @param extra - Extra data to pass to the render function
* @param noContext - Disable context menu for this column
*/
export type TableColumnProps<T = any> = {
accessor?: string;
title?: string;
ordering?: string;
sortable?: boolean;
switchable?: boolean;
hidden?: boolean;
defaultVisible?: boolean;
editable?: boolean;
definition?: ApiFormFieldType;
render?: (record: T, index?: number) => any;
filter?: any;
filtering?: boolean;
width?: number;
resizable?: boolean;
noWrap?: boolean;
ellipsis?: boolean;
textAlign?: 'left' | 'center' | 'right';
cellsStyle?: any;
extra?: any;
noContext?: boolean;
};
/**
* Interface for the table column definition
*/
export type TableColumn<T = any> = {
accessor: string; // The key in the record to access
} & TableColumnProps<T>;
// Type definition for a table row action
export type RowAction = {
title?: string;
tooltip?: string;
color?: string;
icon?: ReactNode;
onClick?: (event: any) => void;
hidden?: boolean;
disabled?: boolean;
};
type RowModelProps = {
modelType: ModelType;
modelId: number;
navigate: NavigateFunction;
};
export type RowViewProps = RowAction & RowModelProps;
/**
* Set of optional properties which can be passed to an InvenTreeTable component
*
* @param params : any - Base query parameters
* @param tableState : TableState - State manager for the table
* @param defaultSortColumn : string - Default column to sort by
* @param noRecordsText : string - Text to display when no records are found
* @param enableBulkDelete : boolean - Enable bulk deletion of records
* @param enableDownload : boolean - Enable download actions
* @param enableFilters : boolean - Enable filter actions
* @param enableSelection : boolean - Enable row selection
* @param enableSearch : boolean - Enable search actions
* @param enableLabels : boolean - Enable printing of labels against selected items
* @param enableReports : boolean - Enable printing of reports against selected items
* @param printingAccessor : string - Accessor for label and report printing (default = 'pk')
* @param enablePagination : boolean - Enable pagination
* @param enableRefresh : boolean - Enable refresh actions
* @param enableColumnSwitching : boolean - Enable column switching
* @param enableColumnCaching : boolean - Enable caching of column names via API
* @param barcodeActions : any[] - List of barcode actions
* @param tableFilters : TableFilter[] - List of custom filters
* @param tableActions : any[] - List of custom action groups
* @param dataFormatter : (data: any) => any - Callback function to reformat data returned by server (if not in default format)
* @param rowActions : (record: any) => RowAction[] - Callback function to generate row actions
* @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 minHeight: number - Minimum height of the table (default 300px)
* @param noHeader: boolean - Hide the table header
*/
export type InvenTreeTableProps<T = any> = {
params?: any;
defaultSortColumn?: string;
noRecordsText?: string;
enableBulkDelete?: boolean;
enableDownload?: boolean;
enableFilters?: boolean;
enableSelection?: boolean;
enableSearch?: boolean;
enablePagination?: boolean;
enableRefresh?: boolean;
enableColumnSwitching?: boolean;
enableColumnCaching?: boolean;
enableLabels?: boolean;
enableReports?: boolean;
printingAccessor?: string;
afterBulkDelete?: () => void;
barcodeActions?: React.ReactNode[];
tableFilters?: TableFilter[];
tableActions?: React.ReactNode[];
rowExpansion?: DataTableRowExpansionProps<T>;
dataFormatter?: (data: any) => any;
rowActions?: (record: T) => RowAction[];
onRowClick?: (record: T, index: number, event: any) => void;
onCellClick?: DataTableCellClickHandler<T>;
modelType?: ModelType;
rowStyle?: (record: T, index: number) => MantineStyleProp | undefined;
modelField?: string;
onCellContextMenu?: (record: T, event: any) => void;
minHeight?: number;
noHeader?: boolean;
};