2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-01-31 02:23:50 +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} />;
}
}