mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-17 20:45:44 +00:00
Revision Improvements (#7585)
* Bump djangorestframework from 3.14.0 to 3.15.2 in /src/backend Bumps [djangorestframework](https://github.com/encode/django-rest-framework) from 3.14.0 to 3.15.2. - [Release notes](https://github.com/encode/django-rest-framework/releases) - [Commits](https://github.com/encode/django-rest-framework/compare/3.14.0...3.15.2) --- updated-dependencies: - dependency-name: djangorestframework dependency-type: direct:production ... Signed-off-by: dependabot[bot] <support@github.com> * fix req * fix deps again * patch serializer * bump api version * Fix "min_value" for DRF decimal fields * Add default serializer values for 'IPN' and 'revision' * Add specific serializer for email field * Fix API version * Add 'revision_of' field to Part model * Add validation checks for new revision_of field * Update migration * Add unit test for 'revision' rules * Add API filters for revision control * Add table filters for PUI * Add "revision_of" field to PUI form * Update part forms for PUI * Render part revision selection dropdown in PUI * Prevent refetch on focus * Ensure select renders above other items * Disable searching * Cleanup <PartDetail/> * UI tweak * Add setting to control revisions for assemblies * Hide revision selection drop-down if revisions are not enabled * Query updates * Validate entire BOM table from PUI * Sort revisions * Fix requirements files * Fix api_version.py * Reintroduce previous check for IPN / revision uniqueness * Set default value for refetchOnWindowFocus (false) * Revert serializer change * Further CI fixes * Further unit test updates * Fix defaults for query client * Add docs * Add link to "revision_of" in CUI * Add playwright test for revisions * Ignore notification errors for playwright --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
@ -29,4 +29,10 @@ export function setApiDefaults() {
|
||||
}
|
||||
}
|
||||
|
||||
export const queryClient = new QueryClient();
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -30,17 +30,6 @@ import { StylishText } from '../items/StylishText';
|
||||
import { getModelInfo } from '../render/ModelType';
|
||||
import { StatusRenderer } from '../render/StatusRenderer';
|
||||
|
||||
export type PartIconsType = {
|
||||
assembly: boolean;
|
||||
template: boolean;
|
||||
component: boolean;
|
||||
trackable: boolean;
|
||||
purchaseable: boolean;
|
||||
saleable: boolean;
|
||||
virtual: boolean;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export type DetailsField =
|
||||
| {
|
||||
hidden?: boolean;
|
||||
|
@ -1,92 +0,0 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { Badge, Tooltip } from '@mantine/core';
|
||||
|
||||
import { InvenTreeIcon, InvenTreeIconType } from '../../functions/icons';
|
||||
|
||||
/**
|
||||
* Fetches and wraps an InvenTreeIcon in a flex div
|
||||
* @param icon name of icon
|
||||
*
|
||||
*/
|
||||
function PartIcon(icon: InvenTreeIconType) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<InvenTreeIcon icon={icon} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a table cell with Part icons.
|
||||
* Only used for Part Model Details
|
||||
*/
|
||||
export function PartIcons({ part }: { part: any }) {
|
||||
return (
|
||||
<td colSpan={2}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
{part.locked && (
|
||||
<Tooltip label={t`Part is locked`}>
|
||||
<Badge color="black" variant="filled">
|
||||
<Trans>Locked</Trans>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!part.active && (
|
||||
<Tooltip label={t`Part is not active`}>
|
||||
<Badge color="red" variant="filled">
|
||||
<Trans>Inactive</Trans>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{part.template && (
|
||||
<Tooltip
|
||||
label={t`Part is a template part (variants can be made from this part)`}
|
||||
children={PartIcon('template')}
|
||||
/>
|
||||
)}
|
||||
{part.assembly && (
|
||||
<Tooltip
|
||||
label={t`Part can be assembled from other parts`}
|
||||
children={PartIcon('assembly')}
|
||||
/>
|
||||
)}
|
||||
{part.component && (
|
||||
<Tooltip
|
||||
label={t`Part can be used in assemblies`}
|
||||
children={PartIcon('component')}
|
||||
/>
|
||||
)}
|
||||
{part.trackable && (
|
||||
<Tooltip
|
||||
label={t`Part stock is tracked by serial number`}
|
||||
children={PartIcon('trackable')}
|
||||
/>
|
||||
)}
|
||||
{part.purchaseable && (
|
||||
<Tooltip
|
||||
label={t`Part can be purchased from external suppliers`}
|
||||
children={PartIcon('purchaseable')}
|
||||
/>
|
||||
)}
|
||||
{part.saleable && (
|
||||
<Tooltip
|
||||
label={t`Part can be sold to customers`}
|
||||
children={PartIcon('saleable')}
|
||||
/>
|
||||
)}
|
||||
{part.virtual && (
|
||||
<Tooltip label={t`Part is virtual (not a physical part)`}>
|
||||
<Badge color="yellow" variant="filled">
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '5px' }}
|
||||
>
|
||||
<InvenTreeIcon icon="virtual" iconProps={{ size: 18 }} />{' '}
|
||||
<Trans>Virtual</Trans>
|
||||
</div>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
@ -125,7 +125,6 @@ export function OptionsApiForm({
|
||||
const optionsQuery = useQuery({
|
||||
enabled: true,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
queryKey: [
|
||||
'form-options-data',
|
||||
id,
|
||||
|
@ -39,12 +39,14 @@ export function ActionDropdown({
|
||||
icon,
|
||||
tooltip,
|
||||
actions,
|
||||
disabled = false
|
||||
disabled = false,
|
||||
hidden = false
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
tooltip: string;
|
||||
actions: ActionDropdownItem[];
|
||||
disabled?: boolean;
|
||||
hidden?: boolean;
|
||||
}) {
|
||||
const hasActions = useMemo(() => {
|
||||
return actions.some((action) => !action.hidden);
|
||||
@ -58,7 +60,7 @@ export function ActionDropdown({
|
||||
return identifierString(`action-menu-${tooltip}`);
|
||||
}, [tooltip]);
|
||||
|
||||
return hasActions ? (
|
||||
return !hidden && hasActions ? (
|
||||
<Menu position="bottom-end" key={menuName}>
|
||||
<Indicator disabled={!indicatorProps} {...indicatorProps?.indicator}>
|
||||
<Menu.Target>
|
||||
|
@ -70,8 +70,7 @@ export function Header() {
|
||||
}
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false
|
||||
refetchOnMount: true
|
||||
});
|
||||
|
||||
// Sync Navigation Drawer state with zustand
|
||||
|
@ -53,8 +53,7 @@ export function NotificationDrawer({
|
||||
.catch((error) => {
|
||||
return error;
|
||||
}),
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false
|
||||
refetchOnMount: false
|
||||
});
|
||||
|
||||
const hasNotifications: boolean = useMemo(() => {
|
||||
|
@ -275,8 +275,7 @@ export function SearchDrawer({
|
||||
// Search query manager
|
||||
const searchQuery = useQuery({
|
||||
queryKey: ['search', searchText, searchRegex, searchWhole],
|
||||
queryFn: performSearch,
|
||||
refetchOnWindowFocus: false
|
||||
queryFn: performSearch
|
||||
});
|
||||
|
||||
// A list of queries which return valid results
|
||||
|
@ -47,6 +47,7 @@ export interface InstanceRenderInterface {
|
||||
instance: any;
|
||||
link?: boolean;
|
||||
navigate?: any;
|
||||
showSecondary?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -149,10 +150,12 @@ export function RenderInlineModel({
|
||||
image,
|
||||
labels,
|
||||
url,
|
||||
navigate
|
||||
navigate,
|
||||
showSecondary = true
|
||||
}: {
|
||||
primary: string;
|
||||
secondary?: string;
|
||||
showSecondary?: boolean;
|
||||
suffix?: ReactNode;
|
||||
image?: string;
|
||||
labels?: string[];
|
||||
@ -181,7 +184,7 @@ export function RenderInlineModel({
|
||||
) : (
|
||||
<Text size="sm">{primary}</Text>
|
||||
)}
|
||||
{secondary && <Text size="xs">{secondary}</Text>}
|
||||
{showSecondary && secondary && <Text size="xs">{secondary}</Text>}
|
||||
</Group>
|
||||
{suffix && (
|
||||
<>
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Badge } from '@mantine/core';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
@ -12,14 +13,35 @@ export function RenderPart(
|
||||
props: Readonly<InstanceRenderInterface>
|
||||
): ReactNode {
|
||||
const { instance } = props;
|
||||
const stock = t`Stock` + `: ${instance.in_stock}`;
|
||||
|
||||
let badgeText = '';
|
||||
let badgeColor = 'green';
|
||||
|
||||
let stock = instance.total_in_stock;
|
||||
|
||||
if (instance.active == false) {
|
||||
badgeColor = 'red';
|
||||
badgeText = t`Inactive`;
|
||||
} else if (stock <= 0) {
|
||||
badgeColor = 'orange';
|
||||
badgeText = t`No stock`;
|
||||
} else {
|
||||
badgeText = t`Stock` + `: ${stock}`;
|
||||
badgeColor = instance.minimum_stock > stock ? 'yellow' : 'green';
|
||||
}
|
||||
|
||||
const badge = (
|
||||
<Badge size="xs" color={badgeColor}>
|
||||
{badgeText}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return (
|
||||
<RenderInlineModel
|
||||
{...props}
|
||||
primary={instance.name}
|
||||
primary={instance.full_name ?? instance.name}
|
||||
secondary={instance.description}
|
||||
suffix={stock}
|
||||
suffix={badge}
|
||||
image={instance.thumnbnail || instance.image}
|
||||
url={props.link ? getDetailUrl(ModelType.part, instance.pk) : undefined}
|
||||
/>
|
||||
|
@ -69,6 +69,7 @@ export enum ApiEndpoints {
|
||||
|
||||
bom_list = 'bom/',
|
||||
bom_item_validate = 'bom/:id/validate/',
|
||||
bom_validate = 'part/:id/bom-validate/',
|
||||
|
||||
// Part API endpoints
|
||||
part_list = 'part/',
|
||||
|
@ -3,6 +3,7 @@ import { IconPackages } from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
|
||||
import { useGlobalSettingsState } from '../states/SettingsState';
|
||||
|
||||
/**
|
||||
* Construct a set of fields for creating / editing a Part instance
|
||||
@ -21,9 +22,19 @@ export function usePartFields({
|
||||
},
|
||||
name: {},
|
||||
IPN: {},
|
||||
revision: {},
|
||||
description: {},
|
||||
variant_of: {},
|
||||
revision: {},
|
||||
revision_of: {
|
||||
filters: {
|
||||
is_revision: false,
|
||||
is_template: false
|
||||
}
|
||||
},
|
||||
variant_of: {
|
||||
filters: {
|
||||
is_template: true
|
||||
}
|
||||
},
|
||||
keywords: {},
|
||||
units: {},
|
||||
link: {},
|
||||
@ -82,13 +93,22 @@ export function usePartFields({
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: pop 'expiry' field if expiry not enabled
|
||||
delete fields['default_expiry'];
|
||||
const settings = useGlobalSettingsState.getState();
|
||||
|
||||
// TODO: pop 'revision' field if PART_ENABLE_REVISION is False
|
||||
delete fields['revision'];
|
||||
if (settings.isSet('PART_REVISION_ASSEMBLY_ONLY')) {
|
||||
fields.revision_of.filters['assembly'] = true;
|
||||
}
|
||||
|
||||
// TODO: handle part duplications
|
||||
// Pop 'revision' field if PART_ENABLE_REVISION is False
|
||||
if (!settings.isSet('PART_ENABLE_REVISION')) {
|
||||
delete fields['revision'];
|
||||
delete fields['revision_of'];
|
||||
}
|
||||
|
||||
// Pop 'expiry' field if expiry not enabled
|
||||
if (!settings.isSet('STOCK_ENABLE_EXPIRY')) {
|
||||
delete fields['default_expiry'];
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, [create]);
|
||||
|
@ -33,6 +33,7 @@ import {
|
||||
IconGitBranch,
|
||||
IconGridDots,
|
||||
IconHash,
|
||||
IconHierarchy,
|
||||
IconInfoCircle,
|
||||
IconLayersLinked,
|
||||
IconLink,
|
||||
@ -89,7 +90,8 @@ import React from 'react';
|
||||
const icons = {
|
||||
name: IconPoint,
|
||||
description: IconInfoCircle,
|
||||
variant_of: IconStatusChange,
|
||||
variant_of: IconHierarchy,
|
||||
revision_of: IconStatusChange,
|
||||
unallocated_stock: IconPackage,
|
||||
total_in_stock: IconPackages,
|
||||
minimum_stock: IconFlag,
|
||||
|
@ -85,7 +85,7 @@ export function useInstance<T = any>({
|
||||
});
|
||||
},
|
||||
refetchOnMount: refetchOnMount,
|
||||
refetchOnWindowFocus: refetchOnWindowFocus,
|
||||
refetchOnWindowFocus: refetchOnWindowFocus ?? false,
|
||||
refetchInterval: updateInterval
|
||||
});
|
||||
|
||||
|
@ -180,11 +180,12 @@ export default function SystemSettings() {
|
||||
content: (
|
||||
<GlobalSettingList
|
||||
keys={[
|
||||
'PART_ENABLE_REVISION',
|
||||
'PART_IPN_REGEX',
|
||||
'PART_ALLOW_DUPLICATE_IPN',
|
||||
'PART_ALLOW_EDIT_IPN',
|
||||
'PART_ALLOW_DELETE_FROM_ASSEMBLY',
|
||||
'PART_ENABLE_REVISION',
|
||||
'PART_REVISION_ASSEMBLY_ONLY',
|
||||
'PART_NAME_FORMAT',
|
||||
'PART_SHOW_RELATED',
|
||||
'PART_CREATE_INITIAL',
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Alert, Grid, Skeleton, Stack, Table } from '@mantine/core';
|
||||
import { Alert, Grid, Skeleton, Space, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
IconBookmarks,
|
||||
IconBuilding,
|
||||
@ -22,9 +22,10 @@ import {
|
||||
IconTruckDelivery,
|
||||
IconVersions
|
||||
} from '@tabler/icons-react';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { ReactNode, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import Select from 'react-select';
|
||||
|
||||
import { api } from '../../App';
|
||||
import AdminButton from '../../components/buttons/AdminButton';
|
||||
@ -32,7 +33,6 @@ import { DetailsField, DetailsTable } from '../../components/details/Details';
|
||||
import DetailsBadge from '../../components/details/DetailsBadge';
|
||||
import { DetailsImage } from '../../components/details/DetailsImage';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import { PartIcons } from '../../components/details/PartIcons';
|
||||
import NotesEditor from '../../components/editors/NotesEditor';
|
||||
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
|
||||
import { Thumbnail } from '../../components/images/Thumbnail';
|
||||
@ -51,6 +51,7 @@ import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import NavigationTree from '../../components/nav/NavigationTree';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { RenderPart } from '../../components/render/Part';
|
||||
import { formatPriceRange } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
@ -70,6 +71,7 @@ import {
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { BomTable } from '../../tables/bom/BomTable';
|
||||
import { UsedInTable } from '../../tables/bom/UsedInTable';
|
||||
@ -96,6 +98,8 @@ export default function PartDetail() {
|
||||
|
||||
const [treeOpen, setTreeOpen] = useState(false);
|
||||
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
const {
|
||||
instance: part,
|
||||
refreshInstance,
|
||||
@ -137,6 +141,20 @@ export default function PartDetail() {
|
||||
model: ModelType.part,
|
||||
hidden: !part.variant_of
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
name: 'revision_of',
|
||||
label: t`Revision of`,
|
||||
model: ModelType.part,
|
||||
hidden: !part.revision_of
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'revision',
|
||||
label: t`Revision`,
|
||||
hidden: !part.revision,
|
||||
copy: true
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
name: 'category',
|
||||
@ -164,13 +182,6 @@ export default function PartDetail() {
|
||||
copy: true,
|
||||
hidden: !part.IPN
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'revision',
|
||||
label: t`Revision`,
|
||||
copy: true,
|
||||
hidden: !part.revision
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'units',
|
||||
@ -449,7 +460,7 @@ export default function PartDetail() {
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
return part ? (
|
||||
<ItemDetailsGrid>
|
||||
<Grid>
|
||||
<Grid.Col span={4}>
|
||||
@ -467,22 +478,15 @@ export default function PartDetail() {
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={8}>
|
||||
<Stack gap="xs">
|
||||
<Table>
|
||||
<Table.Tbody>
|
||||
<Table.Tr>
|
||||
<PartIcons part={part} />
|
||||
</Table.Tr>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<DetailsTable fields={tl} item={part} />
|
||||
</Stack>
|
||||
<DetailsTable fields={tl} item={part} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<DetailsTable fields={tr} item={part} />
|
||||
<DetailsTable fields={bl} item={part} />
|
||||
<DetailsTable fields={br} item={part} />
|
||||
</ItemDetailsGrid>
|
||||
) : (
|
||||
<Skeleton />
|
||||
);
|
||||
}, [part, instanceQuery]);
|
||||
|
||||
@ -655,6 +659,89 @@ export default function PartDetail() {
|
||||
];
|
||||
}, [id, part, user]);
|
||||
|
||||
// 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 [];
|
||||
}
|
||||
|
||||
let 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;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return revisions;
|
||||
}
|
||||
});
|
||||
|
||||
const partRevisionOptions: any[] = useMemo(() => {
|
||||
if (partRevisionQuery.isFetching || !partRevisionQuery.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!part.revision_of && !part.revision_count) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let 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(
|
||||
() => [
|
||||
{ name: t`Parts`, url: '/part' },
|
||||
@ -686,7 +773,7 @@ export default function PartDetail() {
|
||||
/>,
|
||||
<DetailsBadge
|
||||
label={t`No Stock`}
|
||||
color="red"
|
||||
color="orange"
|
||||
visible={part.total_in_stock == 0}
|
||||
key="no_stock"
|
||||
/>,
|
||||
@ -705,7 +792,7 @@ export default function PartDetail() {
|
||||
<DetailsBadge
|
||||
label={t`Locked`}
|
||||
color="black"
|
||||
visible={part.locked}
|
||||
visible={part.locked == true}
|
||||
key="locked"
|
||||
/>,
|
||||
<DetailsBadge
|
||||
@ -738,14 +825,22 @@ export default function PartDetail() {
|
||||
value: part.pk,
|
||||
hidden: true
|
||||
},
|
||||
copy_image: {},
|
||||
copy_bom: {},
|
||||
copy_notes: {},
|
||||
copy_parameters: {}
|
||||
copy_image: {
|
||||
value: true
|
||||
},
|
||||
copy_bom: {
|
||||
value: globalSettings.isSet('PART_COPY_BOM')
|
||||
},
|
||||
copy_notes: {
|
||||
value: true
|
||||
},
|
||||
copy_parameters: {
|
||||
value: globalSettings.isSet('PART_COPY_PARAMETERS')
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [createPartFields, part]);
|
||||
}, [createPartFields, globalSettings, part]);
|
||||
|
||||
const duplicatePart = useCreateApiFormModal({
|
||||
url: ApiEndpoints.part_list,
|
||||
@ -859,6 +954,13 @@ export default function PartDetail() {
|
||||
];
|
||||
}, [id, part, user]);
|
||||
|
||||
const enableRevisionSelection: boolean = useMemo(() => {
|
||||
return (
|
||||
partRevisionOptions.length > 0 &&
|
||||
globalSettings.isSet('PART_ENABLE_REVISION')
|
||||
);
|
||||
}, [partRevisionOptions, globalSettings]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{duplicatePart.modal}
|
||||
@ -886,6 +988,40 @@ export default function PartDetail() {
|
||||
setTreeOpen(true);
|
||||
}}
|
||||
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>
|
||||
) : (
|
||||
<Space />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<PanelGroup pageKey="part" panels={partPanels} />
|
||||
{transferStockItems.modal}
|
||||
|
@ -178,7 +178,6 @@ export function InvenTreeTable<T = any>({
|
||||
queryKey: ['options', url, tableState.tableKey, props.enableColumnCaching],
|
||||
retry: 3,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
queryFn: async () => {
|
||||
if (props.enableColumnCaching == false) {
|
||||
return null;
|
||||
@ -483,7 +482,6 @@ export function InvenTreeTable<T = any>({
|
||||
tableState.searchTerm
|
||||
],
|
||||
queryFn: fetchTableData,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: true
|
||||
});
|
||||
|
||||
|
@ -11,6 +11,7 @@ import { ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { api } from '../../App';
|
||||
import { ActionButton } from '../../components/buttons/ActionButton';
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { YesNoButton } from '../../components/buttons/YesNoButton';
|
||||
import { Thumbnail } from '../../components/images/Thumbnail';
|
||||
@ -20,6 +21,7 @@ import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { bomItemFields } from '../../forms/BomForms';
|
||||
import {
|
||||
useApiFormModal,
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal,
|
||||
useEditApiFormModal
|
||||
@ -371,6 +373,26 @@ export function BomTable({
|
||||
table: table
|
||||
});
|
||||
|
||||
const validateBom = useApiFormModal({
|
||||
url: ApiEndpoints.bom_validate,
|
||||
method: 'PUT',
|
||||
fields: {
|
||||
valid: {
|
||||
hidden: true,
|
||||
value: true
|
||||
}
|
||||
},
|
||||
title: t`Validate BOM`,
|
||||
pk: partId,
|
||||
preFormContent: (
|
||||
<Alert color="green" icon={<IconCircleCheck />} title={t`Validate BOM`}>
|
||||
<Text>{t`Do you want to validate the bill of materials for this assembly?`}</Text>
|
||||
</Alert>
|
||||
),
|
||||
successMessage: t`BOM validated`,
|
||||
onFormSuccess: () => table.refreshTable()
|
||||
});
|
||||
|
||||
const validateBomItem = useCallback((record: any) => {
|
||||
const url = apiUrl(ApiEndpoints.bom_item_validate, record.pk);
|
||||
|
||||
@ -445,6 +467,12 @@ export function BomTable({
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [
|
||||
<ActionButton
|
||||
hidden={partLocked || !user.hasChangeRole(UserRoles.part)}
|
||||
tooltip={t`Validate BOM`}
|
||||
icon={<IconCircleCheck />}
|
||||
onClick={() => validateBom.open()}
|
||||
/>,
|
||||
<AddItemButton
|
||||
hidden={partLocked || !user.hasAddRole(UserRoles.part)}
|
||||
tooltip={t`Add BOM Item`}
|
||||
@ -457,12 +485,13 @@ export function BomTable({
|
||||
<>
|
||||
{newBomItem.modal}
|
||||
{editBomItem.modal}
|
||||
{validateBom.modal}
|
||||
{deleteBomItem.modal}
|
||||
<Stack gap="xs">
|
||||
{partLocked && (
|
||||
<Alert
|
||||
title={t`Part is Locked`}
|
||||
color="red"
|
||||
color="orange"
|
||||
icon={<IconLock />}
|
||||
p="xs"
|
||||
>
|
||||
|
@ -189,7 +189,7 @@ export function PartParameterTable({
|
||||
{partLocked && (
|
||||
<Alert
|
||||
title={t`Part is Locked`}
|
||||
color="red"
|
||||
color="orange"
|
||||
icon={<IconLock />}
|
||||
p="xs"
|
||||
>
|
||||
|
@ -34,6 +34,10 @@ function partTableColumns(): TableColumn[] {
|
||||
accessor: 'IPN',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
accessor: 'revision',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
accessor: 'units',
|
||||
sortable: true
|
||||
@ -257,6 +261,16 @@ function partTableFilters(): TableFilter[] {
|
||||
description: t`Filter by parts which are templates`,
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
name: 'is_revision',
|
||||
label: t`Is Revision`,
|
||||
description: t`Filter by parts which are revisions`
|
||||
},
|
||||
{
|
||||
name: 'has_revisions',
|
||||
label: t`Has Revisions`,
|
||||
description: t`Filter by parts which have revisions`
|
||||
},
|
||||
{
|
||||
name: 'has_pricing',
|
||||
label: t`Has Pricing`,
|
||||
|
@ -67,8 +67,9 @@ export const test = baseTest.extend({
|
||||
url != 'http://localhost:8000/api/user/me/' &&
|
||||
url != 'http://localhost:8000/api/user/token/' &&
|
||||
url != 'http://localhost:8000/api/barcode/' &&
|
||||
url != 'http://localhost:8000/api/news/?search=&offset=0&limit=25' &&
|
||||
url != 'https://docs.inventree.org/en/versions.json' &&
|
||||
!url.startsWith('http://localhost:8000/api/news/') &&
|
||||
!url.startsWith('http://localhost:8000/api/notifications/') &&
|
||||
!url.startsWith('chrome://') &&
|
||||
url.indexOf('99999') < 0
|
||||
)
|
||||
|
@ -254,3 +254,21 @@ test('PUI - Pages - Part - 404', async ({ page }) => {
|
||||
// Clear out any console error messages
|
||||
await page.evaluate(() => console.clear());
|
||||
});
|
||||
|
||||
test('PUI - Pages - Part - Revision', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/part/906/details`);
|
||||
|
||||
await page.getByText('Revision of').waitFor();
|
||||
await page.getByText('Select Part Revision').waitFor();
|
||||
await page
|
||||
.getByText('Green Round Table (revision B) | B', { exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('option', { name: 'Thumbnail Green Round Table No stock' })
|
||||
.click();
|
||||
|
||||
await page.waitForURL('**/platform/part/101/**');
|
||||
await page.getByText('Select Part Revision').waitFor();
|
||||
});
|
||||
|
Reference in New Issue
Block a user