mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-18 02:36:31 +00:00
[UI] Various fixes (#10038)
* Fix badge wrapping * Move revision selector - Simplify top header * Remove "detail" attribute from <PageDetail> * Implement "FORMS_CLOSE_USING_ESCAPE" option * Implement "STICKY_HEADER" setting * Remove unused setting * Improved badge layout * Sticky header fix
This commit is contained in:
@@ -22,7 +22,6 @@ The *Display Settings* screen shows general display configuration options:
|
||||
{{ usersetting("STICKY_HEADER") }}
|
||||
{{ usersetting("DATE_DISPLAY_FORMAT") }}
|
||||
{{ usersetting("FORMS_CLOSE_USING_ESCAPE") }}
|
||||
{{ usersetting("PART_SHOW_QUANTITY_IN_FORMS") }}
|
||||
{{ usersetting("DISPLAY_STOCKTAKE_TAB") }}
|
||||
{{ usersetting("SHOW_FULL_CATEGORY_IN_TABLES")}}
|
||||
{{ usersetting("ENABLE_LAST_BREADCRUMB") }}
|
||||
|
@@ -173,12 +173,6 @@ USER_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'PART_SHOW_QUANTITY_IN_FORMS': {
|
||||
'name': _('Show Quantity in Forms'),
|
||||
'description': _('Display available part quantity in some forms'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
'FORMS_CLOSE_USING_ESCAPE': {
|
||||
'name': _('Escape Key Closes Forms'),
|
||||
'description': _('Use the escape key to close modal forms'),
|
||||
|
@@ -62,6 +62,7 @@ export function Header() {
|
||||
const { isLoggedIn } = useUserState();
|
||||
const [notificationCount, setNotificationCount] = useState<number>(0);
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
const userSettings = useUserSettingsState();
|
||||
|
||||
const navbar_message = useMemo(() => {
|
||||
return server.customize?.navbar_message;
|
||||
@@ -107,8 +108,21 @@ export function Header() {
|
||||
else closeNavDrawer();
|
||||
}, [navigationOpen]);
|
||||
|
||||
const headerStyle: any = useMemo(() => {
|
||||
const sticky: boolean = userSettings.isSet('STICKY_HEADER', true);
|
||||
|
||||
if (sticky) {
|
||||
return {
|
||||
position: 'sticky',
|
||||
top: 0
|
||||
};
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
}, [userSettings]);
|
||||
|
||||
return (
|
||||
<div className={classes.layoutHeader}>
|
||||
<div className={classes.layoutHeader} style={headerStyle}>
|
||||
<SearchDrawer opened={searchDrawerOpened} onClose={closeSearchDrawer} />
|
||||
<NavigationDrawer opened={navDrawerOpened} close={closeNavDrawer} />
|
||||
<NotificationDrawer
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Group, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
import { Group, Paper, Space, Stack, Text } from '@mantine/core';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
|
||||
import { Fragment, type ReactNode, useMemo } from 'react';
|
||||
@@ -14,7 +14,6 @@ interface PageDetailInterface {
|
||||
icon?: ReactNode;
|
||||
subtitle?: string;
|
||||
imageUrl?: string;
|
||||
detail?: ReactNode;
|
||||
badges?: ReactNode[];
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
lastCrumb?: Breadcrumb[];
|
||||
@@ -34,7 +33,6 @@ export function PageDetail({
|
||||
title,
|
||||
icon,
|
||||
subtitle,
|
||||
detail,
|
||||
badges,
|
||||
imageUrl,
|
||||
breadcrumbs,
|
||||
@@ -74,20 +72,6 @@ export function PageDetail({
|
||||
[subtitle]
|
||||
);
|
||||
|
||||
const maxCols = useMemo(() => {
|
||||
let cols = 1;
|
||||
|
||||
if (!!detail) {
|
||||
cols++;
|
||||
}
|
||||
|
||||
if (!!badges) {
|
||||
cols++;
|
||||
}
|
||||
|
||||
return cols;
|
||||
}, [detail, badges]);
|
||||
|
||||
// breadcrumb caching
|
||||
const computedBreadcrumbs = useMemo(() => {
|
||||
if (userSettings.isSet('ENABLE_LAST_BREADCRUMB', false)) {
|
||||
@@ -114,14 +98,13 @@ export function PageDetail({
|
||||
wrap='nowrap'
|
||||
align='flex-start'
|
||||
>
|
||||
<SimpleGrid
|
||||
cols={{
|
||||
base: 1,
|
||||
md: Math.min(2, maxCols),
|
||||
lg: Math.min(3, maxCols)
|
||||
}}
|
||||
<Group
|
||||
justify='space-between'
|
||||
wrap='nowrap'
|
||||
align='flex-start'
|
||||
style={{ flexGrow: 1 }}
|
||||
>
|
||||
<Group justify='left' wrap='nowrap'>
|
||||
<Group justify='start' wrap='nowrap' align='flex-start'>
|
||||
{imageUrl && (
|
||||
<ApiImage
|
||||
src={imageUrl}
|
||||
@@ -142,20 +125,15 @@ export function PageDetail({
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
{detail && <div>{detail}</div>}
|
||||
{badges && (
|
||||
<Group
|
||||
justify='center'
|
||||
gap='xs'
|
||||
align='flex-start'
|
||||
wrap='nowrap'
|
||||
>
|
||||
<Group justify='flex-end' gap='xs' align='center'>
|
||||
{badges?.map((badge, idx) => (
|
||||
<Fragment key={idx}>{badge}</Fragment>
|
||||
))}
|
||||
<Space w='md' />
|
||||
</Group>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Group>
|
||||
{actions && (
|
||||
<Group gap={5} justify='right' wrap='nowrap' align='flex-start'>
|
||||
{actions.map((action, idx) => (
|
||||
|
@@ -4,12 +4,15 @@ import { useCallback } from 'react';
|
||||
|
||||
import type { UseModalProps, UseModalReturn } from '@lib/types/Modals';
|
||||
import { StylishText } from '../components/items/StylishText';
|
||||
import { useUserSettingsState } from '../states/SettingsStates';
|
||||
|
||||
export function useModal(props: UseModalProps): UseModalReturn {
|
||||
const onOpen = useCallback(() => {
|
||||
props.onOpen?.();
|
||||
}, [props.onOpen]);
|
||||
|
||||
const userSettings = useUserSettingsState();
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
props.onClose?.();
|
||||
}, [props.onClose]);
|
||||
@@ -27,6 +30,7 @@ export function useModal(props: UseModalProps): UseModalReturn {
|
||||
<Modal
|
||||
key={props.id}
|
||||
opened={opened}
|
||||
closeOnEscape={userSettings.isSet('FORMS_CLOSE_USING_ESCAPE')}
|
||||
onClose={close}
|
||||
closeOnClickOutside={props.closeOnClickOutside}
|
||||
size={props.size ?? 'xl'}
|
||||
|
@@ -53,7 +53,6 @@ export default function UserSettings() {
|
||||
'STICKY_HEADER',
|
||||
'DATE_DISPLAY_FORMAT',
|
||||
'FORMS_CLOSE_USING_ESCAPE',
|
||||
'PART_SHOW_QUANTITY_IN_FORMS',
|
||||
'DISPLAY_STOCKTAKE_TAB',
|
||||
'ENABLE_LAST_BREADCRUMB',
|
||||
'SHOW_FULL_LOCATION_IN_TABLES',
|
||||
|
@@ -102,6 +102,47 @@ import PartPricingPanel from './PartPricingPanel';
|
||||
import PartStocktakeDetail from './PartStocktakeDetail';
|
||||
import PartSupplierDetail from './PartSupplierDetail';
|
||||
|
||||
/**
|
||||
* Render a part revision selector component
|
||||
*/
|
||||
function RevisionSelector({
|
||||
part,
|
||||
options
|
||||
}: {
|
||||
part: any;
|
||||
options: any[];
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Select
|
||||
id='part-revision-select'
|
||||
aria-label='part-revision-select'
|
||||
options={options}
|
||||
value={{
|
||||
value: part.pk,
|
||||
label: part.full_name,
|
||||
part: part
|
||||
}}
|
||||
isSearchable={false}
|
||||
formatOptionLabel={(option: any) =>
|
||||
RenderPart({
|
||||
instance: option.part,
|
||||
showSecondary: false
|
||||
})
|
||||
}
|
||||
onChange={(value: any) => {
|
||||
navigate(getDetailUrl(ModelType.part, value.value));
|
||||
}}
|
||||
styles={{
|
||||
menuPortal: (base: any) => ({ ...base, zIndex: 9999 }),
|
||||
menu: (base: any) => ({ ...base, zIndex: 9999 }),
|
||||
menuList: (base: any) => ({ ...base, zIndex: 9999 })
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail view for a single Part instance
|
||||
*/
|
||||
@@ -146,6 +187,95 @@ export default function PartDetail() {
|
||||
refetchOnMount: true
|
||||
});
|
||||
|
||||
// Fetch information on part revision
|
||||
const partRevisionQuery = useQuery({
|
||||
refetchOnMount: true,
|
||||
queryKey: [
|
||||
'part_revisions',
|
||||
part.pk,
|
||||
part.revision_of,
|
||||
part.revision_count
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!part.revision_of && !part.revision_count) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const revisions = [];
|
||||
|
||||
// First, fetch information for the top-level part
|
||||
if (part.revision_of) {
|
||||
await api
|
||||
.get(apiUrl(ApiEndpoints.part_list, part.revision_of))
|
||||
.then((response) => {
|
||||
revisions.push(response.data);
|
||||
});
|
||||
} else {
|
||||
revisions.push(part);
|
||||
}
|
||||
|
||||
const url = apiUrl(ApiEndpoints.part_list);
|
||||
|
||||
await api
|
||||
.get(url, {
|
||||
params: {
|
||||
revision_of: part.revision_of || part.pk
|
||||
}
|
||||
})
|
||||
.then((response) => {
|
||||
switch (response.status) {
|
||||
case 200:
|
||||
response.data.forEach((r: any) => {
|
||||
revisions.push(r);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return revisions;
|
||||
}
|
||||
});
|
||||
|
||||
const partRevisionOptions: any[] = useMemo(() => {
|
||||
if (partRevisionQuery.isFetching || !partRevisionQuery.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!part.revision_of && !part.revision_count) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const options: any[] = partRevisionQuery.data.map((revision: any) => {
|
||||
return {
|
||||
value: revision.pk,
|
||||
label: revision.full_name,
|
||||
part: revision
|
||||
};
|
||||
});
|
||||
|
||||
// Add this part if not already available
|
||||
if (!options.find((o) => o.value == part.pk)) {
|
||||
options.push({
|
||||
value: part.pk,
|
||||
label: part.full_name,
|
||||
part: part
|
||||
});
|
||||
}
|
||||
|
||||
return options.sort((a, b) => {
|
||||
return `${a.part.revision}`.localeCompare(b.part.revision);
|
||||
});
|
||||
}, [part, partRevisionQuery.isFetching, partRevisionQuery.data]);
|
||||
|
||||
const enableRevisionSelection: boolean = useMemo(() => {
|
||||
return (
|
||||
partRevisionOptions.length > 0 &&
|
||||
globalSettings.isSet('PART_ENABLE_REVISION')
|
||||
);
|
||||
}, [partRevisionOptions, globalSettings]);
|
||||
|
||||
const detailsPanel = useMemo(() => {
|
||||
if (instanceQuery.isFetching) {
|
||||
return <Skeleton />;
|
||||
@@ -481,24 +611,32 @@ export default function PartDetail() {
|
||||
|
||||
return part ? (
|
||||
<ItemDetailsGrid>
|
||||
<Grid grow>
|
||||
<DetailsImage
|
||||
appRole={UserRoles.part}
|
||||
imageActions={{
|
||||
selectExisting: true,
|
||||
downloadImage: true,
|
||||
uploadFile: true,
|
||||
deleteFile: true
|
||||
}}
|
||||
src={part.image}
|
||||
apiPath={apiUrl(ApiEndpoints.part_list, part.pk)}
|
||||
refresh={refreshInstance}
|
||||
pk={part.pk}
|
||||
/>
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable fields={tl} item={data} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Stack gap='xs'>
|
||||
<Grid grow>
|
||||
<DetailsImage
|
||||
appRole={UserRoles.part}
|
||||
imageActions={{
|
||||
selectExisting: true,
|
||||
downloadImage: true,
|
||||
uploadFile: true,
|
||||
deleteFile: true
|
||||
}}
|
||||
src={part.image}
|
||||
apiPath={apiUrl(ApiEndpoints.part_list, part.pk)}
|
||||
refresh={refreshInstance}
|
||||
pk={part.pk}
|
||||
/>
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable fields={tl} item={data} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{enableRevisionSelection && (
|
||||
<Stack gap='xs'>
|
||||
<Text>{t`Select Part Revision`}</Text>
|
||||
<RevisionSelector part={part} options={partRevisionOptions} />
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
<DetailsTable fields={tr} item={data} />
|
||||
<DetailsTable fields={bl} item={data} />
|
||||
<DetailsTable fields={br} item={data} />
|
||||
@@ -513,6 +651,8 @@ export default function PartDetail() {
|
||||
serials,
|
||||
instanceQuery.isFetching,
|
||||
instanceQuery.data,
|
||||
enableRevisionSelection,
|
||||
partRevisionOptions,
|
||||
partRequirementsQuery.isFetching,
|
||||
partRequirements
|
||||
]);
|
||||
@@ -680,88 +820,6 @@ export default function PartDetail() {
|
||||
];
|
||||
}, [id, part, user, globalSettings, userSettings, detailsPanel]);
|
||||
|
||||
// Fetch information on part revision
|
||||
const partRevisionQuery = useQuery({
|
||||
refetchOnMount: true,
|
||||
queryKey: [
|
||||
'part_revisions',
|
||||
part.pk,
|
||||
part.revision_of,
|
||||
part.revision_count
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!part.revision_of && !part.revision_count) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const revisions = [];
|
||||
|
||||
// First, fetch information for the top-level part
|
||||
if (part.revision_of) {
|
||||
await api
|
||||
.get(apiUrl(ApiEndpoints.part_list, part.revision_of))
|
||||
.then((response) => {
|
||||
revisions.push(response.data);
|
||||
});
|
||||
} else {
|
||||
revisions.push(part);
|
||||
}
|
||||
|
||||
const url = apiUrl(ApiEndpoints.part_list);
|
||||
|
||||
await api
|
||||
.get(url, {
|
||||
params: {
|
||||
revision_of: part.revision_of || part.pk
|
||||
}
|
||||
})
|
||||
.then((response) => {
|
||||
switch (response.status) {
|
||||
case 200:
|
||||
response.data.forEach((r: any) => {
|
||||
revisions.push(r);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return revisions;
|
||||
}
|
||||
});
|
||||
|
||||
const partRevisionOptions: any[] = useMemo(() => {
|
||||
if (partRevisionQuery.isFetching || !partRevisionQuery.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!part.revision_of && !part.revision_count) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const options: any[] = partRevisionQuery.data.map((revision: any) => {
|
||||
return {
|
||||
value: revision.pk,
|
||||
label: revision.full_name,
|
||||
part: revision
|
||||
};
|
||||
});
|
||||
|
||||
// Add this part if not already available
|
||||
if (!options.find((o) => o.value == part.pk)) {
|
||||
options.push({
|
||||
value: part.pk,
|
||||
label: part.full_name,
|
||||
part: part
|
||||
});
|
||||
}
|
||||
|
||||
return options.sort((a, b) => {
|
||||
return `${a.part.revision}`.localeCompare(b.part.revision);
|
||||
});
|
||||
}, [part, partRevisionQuery.isFetching, partRevisionQuery.data]);
|
||||
|
||||
const breadcrumbs = useMemo(() => {
|
||||
return [
|
||||
{ name: t`Parts`, url: '/part' },
|
||||
@@ -1003,13 +1061,6 @@ export default function PartDetail() {
|
||||
];
|
||||
}, [id, part, user, stockAdjustActions.menuActions]);
|
||||
|
||||
const enableRevisionSelection: boolean = useMemo(() => {
|
||||
return (
|
||||
partRevisionOptions.length > 0 &&
|
||||
globalSettings.isSet('PART_ENABLE_REVISION')
|
||||
);
|
||||
}, [partRevisionOptions, globalSettings]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{editPart.modal}
|
||||
@@ -1059,38 +1110,6 @@ export default function PartDetail() {
|
||||
editAction={editPart.open}
|
||||
editEnabled={user.hasChangeRole(UserRoles.part)}
|
||||
actions={partActions}
|
||||
detail={
|
||||
enableRevisionSelection ? (
|
||||
<Stack gap='xs'>
|
||||
<Text>{t`Select Part Revision`}</Text>
|
||||
<Select
|
||||
id='part-revision-select'
|
||||
aria-label='part-revision-select'
|
||||
options={partRevisionOptions}
|
||||
value={{
|
||||
value: part.pk,
|
||||
label: part.full_name,
|
||||
part: part
|
||||
}}
|
||||
isSearchable={false}
|
||||
formatOptionLabel={(option: any) =>
|
||||
RenderPart({
|
||||
instance: option.part,
|
||||
showSecondary: false
|
||||
})
|
||||
}
|
||||
onChange={(value: any) => {
|
||||
navigate(getDetailUrl(ModelType.part, value.value));
|
||||
}}
|
||||
styles={{
|
||||
menuPortal: (base: any) => ({ ...base, zIndex: 9999 }),
|
||||
menu: (base: any) => ({ ...base, zIndex: 9999 }),
|
||||
menuList: (base: any) => ({ ...base, zIndex: 9999 })
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<PanelGroup
|
||||
pageKey='part'
|
||||
|
@@ -9,6 +9,7 @@ import {
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
Space,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
@@ -252,7 +253,6 @@ function FilterAddGroup({
|
||||
|
||||
return (
|
||||
<Stack gap='xs'>
|
||||
<Divider />
|
||||
<Select
|
||||
data={filterOptions}
|
||||
searchable={true}
|
||||
@@ -311,12 +311,13 @@ export function FilterSelectDrawer({
|
||||
}}
|
||||
title={<StylishText size='lg'>{title ?? t`Table Filters`}</StylishText>}
|
||||
>
|
||||
<Divider />
|
||||
<Space h='sm' />
|
||||
<Stack gap='xs'>
|
||||
{hasFilters &&
|
||||
filterSet.activeFilters?.map((f) => (
|
||||
<FilterItem key={f.name} flt={f} filterSet={filterSet} />
|
||||
))}
|
||||
{hasFilters && <Divider />}
|
||||
{addFilter && (
|
||||
<Stack gap='xs'>
|
||||
<FilterAddGroup
|
||||
|
Reference in New Issue
Block a user