2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-18 18:56: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:
Oliver
2025-07-18 11:36:04 +10:00
committed by GitHub
parent e6f18db800
commit 31d4a88f90
8 changed files with 190 additions and 182 deletions

View File

@@ -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

View File

@@ -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) => (

View File

@@ -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'}

View File

@@ -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',

View File

@@ -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'

View File

@@ -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