2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 12:35:46 +00:00

Merge branch 'master' into pui-plugins

This commit is contained in:
Oliver Walters
2024-08-11 06:23:51 +00:00
630 changed files with 300583 additions and 174725 deletions

View File

@ -1,5 +1,6 @@
{
"locales": [
"ar",
"bg",
"cs",
"da",
@ -8,6 +9,7 @@
"en",
"es",
"es-mx",
"et",
"fa",
"fi",
"fr",

View File

@ -11,79 +11,83 @@
"compile": "lingui compile --typescript"
},
"dependencies": {
"@codemirror/autocomplete": ">=6.0.0",
"@codemirror/autocomplete": ">=6.18.0",
"@codemirror/lang-liquid": "^6.2.1",
"@codemirror/language": ">=6.0.0",
"@codemirror/lint": ">=6.0.0",
"@codemirror/language": ">=6.10.2",
"@codemirror/lint": ">=6.8.1",
"@codemirror/search": ">=6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/theme-one-dark": ">=6.0.0",
"@codemirror/view": ">=6.0.0",
"@emotion/react": "^11.11.4",
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-regular-svg-icons": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@lingui/core": "^4.10.0",
"@lingui/react": "^4.10.0",
"@mantine/carousel": "^7.8.0",
"@mantine/charts": "^7.10.1",
"@mantine/core": "^7.10.0",
"@mantine/dates": "^7.8.0",
"@mantine/dropzone": "^7.8.0",
"@mantine/form": "^7.8.0",
"@mantine/hooks": "^7.8.0",
"@mantine/modals": "^7.8.0",
"@mantine/notifications": "^7.8.0",
"@mantine/spotlight": "^7.8.0",
"@mantine/vanilla-extract": "^7.8.0",
"@mdxeditor/editor": "^3.0.7",
"@naisutech/react-tree": "^3.1.0",
"@sentry/react": "^7.110.0",
"@tabler/icons-react": "^3.2.0",
"@tanstack/react-query": "^5.29.2",
"@uiw/codemirror-theme-vscode": "^4.21.25",
"@uiw/react-codemirror": "^4.21.25",
"@codemirror/view": ">=6.30.0",
"@emotion/react": "^11.13.0",
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/free-regular-svg-icons": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "^0.2.2",
"@lingui/core": "^4.11.2",
"@lingui/react": "^4.11.2",
"@mantine/carousel": "^7.12.0",
"@mantine/charts": "^7.12.0",
"@mantine/core": "^7.12.0",
"@mantine/dates": "^7.12.0",
"@mantine/dropzone": "^7.12.0",
"@mantine/form": "^7.12.0",
"@mantine/hooks": "^7.12.0",
"@mantine/modals": "^7.12.0",
"@mantine/notifications": "^7.12.0",
"@mantine/spotlight": "^7.12.0",
"@mantine/vanilla-extract": "^7.12.0",
"@mdxeditor/editor": "^3.10.1",
"@sentry/react": "^8.23.0",
"@tabler/icons-react": "^3.11.0",
"@tanstack/react-query": "^5.51.21",
"@uiw/codemirror-theme-vscode": "^4.23.0",
"@uiw/react-codemirror": "^4.23.0",
"@uiw/react-split": "^5.9.3",
"@vanilla-extract/css": "^1.14.2",
"axios": "^1.6.8",
"@vanilla-extract/css": "^1.15.3",
"axios": "^1.7.3",
"clsx": "^2.1.0",
"codemirror": ">=6.0.0",
"dayjs": "^1.11.10",
"embla-carousel-react": "^8.0.2",
"dayjs": "^1.11.12",
"embla-carousel-react": "^8.1.8",
"fuse.js": "^7.0.0",
"html5-qrcode": "^2.3.8",
"mantine-datatable": "^7.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"mantine-datatable": "^7.11.3",
"qrcode": "^1.5.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-grid-layout": "^1.4.4",
"react-hook-form": "^7.51.3",
"react-is": "^18.2.0",
"react-router-dom": "^6.22.3",
"react-hook-form": "^7.52.2",
"react-is": "^18.3.1",
"react-router-dom": "^6.26.0",
"react-select": "^5.8.0",
"react-window": "^1.8.10",
"recharts": "^2.12.4",
"styled-components": "^6.1.8",
"zustand": "^4.5.2"
"styled-components": "^6.1.12",
"zustand": "^4.5.4"
},
"devDependencies": {
"@babel/core": "^7.24.4",
"@babel/preset-react": "^7.24.1",
"@babel/preset-typescript": "^7.24.1",
"@lingui/cli": "^4.10.0",
"@lingui/macro": "^4.10.0",
"@playwright/test": "^1.43.1",
"@types/node": "^20.12.7",
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",
"@babel/core": "^7.25.2",
"@babel/preset-react": "^7.24.7",
"@babel/preset-typescript": "^7.24.7",
"@lingui/cli": "^4.11.2",
"@lingui/macro": "^4.11.2",
"@playwright/test": "^1.45.3",
"@types/node": "^22.1.0",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-grid-layout": "^1.3.5",
"@types/react-router-dom": "^5.3.3",
"@vanilla-extract/vite-plugin": "^4.0.7",
"@vitejs/plugin-react": "^4.2.1",
"@types/react-window": "^1.8.8",
"@vanilla-extract/vite-plugin": "^4.0.13",
"@vitejs/plugin-react": "^4.3.1",
"babel-plugin-macros": "^3.1.0",
"nyc": "^15.1.0",
"rollup-plugin-license": "^3.3.1",
"typescript": "^5.4.5",
"vite": "^5.2.8",
"nyc": "^17.0.0",
"rollup-plugin-license": "^3.5.2",
"typescript": "^5.5.4",
"vite": "^5.3.5",
"vite-plugin-babel-macros": "^1.0.6",
"vite-plugin-istanbul": "^6.0.0"
"vite-plugin-istanbul": "^6.0.2"
}
}

View File

@ -3,7 +3,7 @@ import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
timeout: 60000,
timeout: 90000,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 2 : undefined,

View File

@ -29,4 +29,10 @@ export function setApiDefaults() {
}
}
export const queryClient = new QueryClient();
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false
}
}
});

View File

@ -26,7 +26,7 @@ export function Boundary({
fallback?: React.ReactElement | FallbackRender | undefined;
}): ReactNode {
const onError = useCallback(
(error: Error, componentStack: string, eventId: string) => {
(error: unknown, componentStack: string | undefined, eventId: string) => {
console.error(`Error rendering component: ${label}`);
console.error(error, componentStack);
},

View File

@ -31,7 +31,7 @@ export function DashboardItemProxy({
queryFn: fetchData,
refetchOnWindowFocus: autoupdate
});
const [dashdata, setDashData] = useState({ title: t`Title`, value: '000' });
const [dashData, setDashData] = useState({ title: t`Title`, value: '000' });
useEffect(() => {
if (data) {
@ -44,7 +44,7 @@ export function DashboardItemProxy({
<div key={id}>
<StatisticItem
id={id}
data={dashdata}
data={dashData}
isLoading={isLoading || isFetching}
/>
</div>

View File

@ -43,7 +43,7 @@ export function ActionButton(props: ActionButtonProps) {
props.tooltip ?? props.text ?? ''
)}`}
onClick={props.onClick ?? notYetImplemented}
variant={props.variant ?? 'light'}
variant={props.variant ?? 'transparent'}
>
<Group gap="xs" wrap="nowrap">
{props.icon}

View File

@ -1,11 +1,8 @@
import { t } from '@lingui/macro';
import { IconUserStar } from '@tabler/icons-react';
import { useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { ModelType } from '../../enums/ModelType';
import { navigateToLink } from '../../functions/navigation';
import { base_url } from '../../main';
import { useLocalState } from '../../states/LocalState';
import { useUserState } from '../../states/UserState';
import { ModelInformationDict } from '../render/ModelType';

View File

@ -11,7 +11,7 @@ export function ButtonMenu({
label = ''
}: {
icon: any;
actions: any[];
actions: React.ReactNode[];
label?: string;
tooltip?: string;
}) {

View File

@ -1,6 +1,13 @@
import { t } from '@lingui/macro';
import { Button, CopyButton as MantineCopyButton } from '@mantine/core';
import { IconCopy } from '@tabler/icons-react';
import {
ActionIcon,
Button,
CopyButton as MantineCopyButton,
Text,
Tooltip
} from '@mantine/core';
import { InvenTreeIcon } from '../../functions/icons';
export function CopyButton({
value,
@ -9,20 +16,27 @@ export function CopyButton({
value: any;
label?: JSX.Element;
}) {
const ButtonComponent = label ? Button : ActionIcon;
return (
<MantineCopyButton value={value}>
{({ copied, copy }) => (
<Button
color={copied ? 'teal' : 'gray'}
onClick={copy}
title={t`Copy to clipboard`}
variant="subtle"
size="compact-md"
>
<IconCopy size={10} />
{label && <div>&nbsp;</div>}
{label && label}
</Button>
<Tooltip label={copied ? t`Copied` : t`Copy`} withArrow>
<ButtonComponent
color={copied ? 'teal' : 'gray'}
onClick={copy}
variant="transparent"
size="sm"
>
{copied ? (
<InvenTreeIcon icon="check" />
) : (
<InvenTreeIcon icon="copy" />
)}
{label && <Text ml={10}>{label}</Text>}
</ButtonComponent>
</Tooltip>
)}
</MantineCopyButton>
);

View File

@ -0,0 +1,41 @@
import { Button, Tooltip } from '@mantine/core';
import { InvenTreeIcon, InvenTreeIconType } from '../../functions/icons';
import { notYetImplemented } from '../../functions/notifications';
/**
* A "primary action" button for display on a page detail, (for example)
*/
export default function PrimaryActionButton({
title,
tooltip,
icon,
color,
hidden,
onClick
}: {
title: string;
tooltip?: string;
icon?: InvenTreeIconType;
color?: string;
hidden?: boolean;
onClick?: () => void;
}) {
if (hidden) {
return null;
}
return (
<Tooltip label={tooltip ?? title} position="bottom" hidden={!tooltip}>
<Button
leftSection={icon && <InvenTreeIcon icon={icon} />}
color={color}
radius="sm"
p="xs"
onClick={onClick ?? notYetImplemented}
>
{title}
</Button>
</Tooltip>
);
}

View File

@ -1,7 +1,8 @@
import { t } from '@lingui/macro';
import { notifications } from '@mantine/notifications';
import { IconPrinter, IconReport, IconTags } from '@tabler/icons-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
@ -32,31 +33,28 @@ export function PrintingActions({
const [pluginKey, setPluginKey] = useState<string>('');
const loadFields = useCallback(() => {
if (!enableLabels) {
return;
}
api
.options(apiUrl(ApiEndpoints.label_print), {
params: {
plugin: pluginKey || undefined
}
})
.then((response: any) => {
setExtraFields(extractAvailableFields(response, 'POST') || {});
})
.catch(() => {});
}, [enableLabels, pluginKey]);
useEffect(() => {
loadFields();
}, [loadFields, pluginKey]);
const [extraFields, setExtraFields] = useState<ApiFormFieldSet>({});
// Fetch available printing fields via OPTIONS request
const printingFields = useQuery({
enabled: enableLabels,
queryKey: ['printingFields', modelType, pluginKey],
gcTime: 500,
queryFn: () =>
api
.options(apiUrl(ApiEndpoints.label_print), {
params: {
plugin: pluginKey || undefined
}
})
.then((response: any) => {
return extractAvailableFields(response, 'POST') || {};
})
.catch(() => {
return {};
})
});
const labelFields: ApiFormFieldSet = useMemo(() => {
let fields: ApiFormFieldSet = extraFields;
let fields: ApiFormFieldSet = printingFields.data || {};
// Override field values
fields['template'] = {
@ -88,7 +86,7 @@ export function PrintingActions({
};
return fields;
}, [extraFields, items, loadFields]);
}, [printingFields.data, items]);
const labelModal = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.label_print),
@ -98,6 +96,7 @@ export function PrintingActions({
onClose: () => {
setPluginKey('');
},
submitText: t`Print`,
successMessage: t`Label printing completed successfully`,
onFormSuccess: (response: any) => {
setPluginKey('');
@ -136,6 +135,7 @@ export function PrintingActions({
value: items
}
},
submitText: t`Generate`,
successMessage: t`Report printing completed successfully`,
onFormSuccess: (response: any) => {
if (!response.complete) {

View File

@ -22,6 +22,7 @@ export function PassFailButton({
variant="filled"
radius="lg"
size="sm"
style={{ maxWidth: '50px' }}
>
{v ? pass : fail}
</Badge>

View File

@ -1,15 +1,12 @@
import { t } from '@lingui/macro';
import {
ActionIcon,
Anchor,
Badge,
CopyButton,
Paper,
Skeleton,
Stack,
Table,
Text,
Tooltip
Text
} from '@mantine/core';
import { useSuspenseQuery } from '@tanstack/react-query';
import { getValueAtPath } from 'mantine-datatable';
@ -24,23 +21,13 @@ import { navigateToLink } from '../../functions/navigation';
import { getDetailUrl } from '../../functions/urls';
import { apiUrl } from '../../states/ApiState';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { CopyButton } from '../buttons/CopyButton';
import { YesNoButton } from '../buttons/YesNoButton';
import { ProgressBar } from '../items/ProgressBar';
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;
@ -59,7 +46,7 @@ export type DetailsField =
);
type BadgeType = 'owner' | 'user' | 'group';
type ValueFormatterReturn = string | number | null;
type ValueFormatterReturn = string | number | null | React.ReactNode;
type StringDetailField = {
type: 'string' | 'text';
@ -135,11 +122,11 @@ function NameBadge({ pk, type }: { pk: string | number; type: BadgeType }) {
case 200:
return response.data;
default:
return null;
return {};
}
})
.catch(() => {
return null;
return {};
});
}
});
@ -148,7 +135,9 @@ function NameBadge({ pk, type }: { pk: string | number; type: BadgeType }) {
// Rendering a user's rame for the badge
function _render_name() {
if (type === 'user' && settings.isSet('DISPLAY_FULL_NAMES')) {
if (!data) {
return '';
} else if (type === 'user' && settings.isSet('DISPLAY_FULL_NAMES')) {
if (data.first_name || data.last_name) {
return `${data.first_name} ${data.last_name}`;
} else {
@ -169,7 +158,7 @@ function NameBadge({ pk, type }: { pk: string | number; type: BadgeType }) {
variant="filled"
style={{ display: 'flex', alignItems: 'center' }}
>
{data.name ?? _render_name()}
{data?.name ?? _render_name()}
</Badge>
<InvenTreeIcon icon={type === 'user' ? type : data.label} />
</div>
@ -334,26 +323,7 @@ function StatusValue(props: Readonly<FieldProps>) {
}
function CopyField({ value }: { value: string }) {
return (
<CopyButton value={value}>
{({ copied, copy }) => (
<Tooltip label={copied ? t`Copied` : t`Copy`} withArrow>
<ActionIcon
color={copied ? 'teal' : 'gray'}
onClick={copy}
variant="transparent"
size="sm"
>
{copied ? (
<InvenTreeIcon icon="check" />
) : (
<InvenTreeIcon icon="copy" />
)}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
);
return <CopyButton value={value} />;
}
export function DetailsTableField({
@ -398,10 +368,10 @@ export function DetailsTableField({
>
<InvenTreeIcon icon={field.icon ?? (field.name as InvenTreeIconType)} />
</Table.Td>
<Table.Td style={{ maxWidth: '65%' }}>
<Table.Td style={{ maxWidth: '65%', lineBreak: 'auto' }}>
<Text>{field.label}</Text>
</Table.Td>
<Table.Td style={{}}>
<Table.Td style={{ lineBreak: 'anywhere' }}>
<FieldType field_data={field} field_value={fieldValue} />
</Table.Td>
<Table.Td style={{ width: '50' }}>

View File

@ -85,7 +85,7 @@ function UploadModal({
apiPath: string;
setImage: (image: string) => void;
}) {
const [file1, setFile] = useState<FileWithPath | null>(null);
const [currentFile, setCurrentFile] = useState<FileWithPath | null>(null);
let uploading = false;
// Components to show in the Dropzone when no file is selected
@ -168,7 +168,7 @@ function UploadModal({
return (
<Paper style={{ height: '220px' }}>
<Dropzone
onDrop={(files) => setFile(files[0])}
onDrop={(files) => setCurrentFile(files[0])}
maxFiles={1}
accept={IMAGE_MIME_TYPE}
loading={uploading}
@ -198,7 +198,9 @@ function UploadModal({
}}
/>
</Dropzone.Reject>
<Dropzone.Idle>{file1 ? fileInfo(file1) : noFileIdle}</Dropzone.Idle>
<Dropzone.Idle>
{currentFile ? fileInfo(currentFile) : noFileIdle}
</Dropzone.Idle>
</Group>
</Dropzone>
<Paper
@ -218,12 +220,15 @@ function UploadModal({
>
<Button
variant="outline"
disabled={!file1}
onClick={() => setFile(null)}
disabled={!currentFile}
onClick={() => setCurrentFile(null)}
>
<Trans>Clear</Trans>
</Button>
<Button disabled={!file1} onClick={() => uploadImage(file1)}>
<Button
disabled={!currentFile}
onClick={() => uploadImage(currentFile)}
>
<Trans>Submit</Trans>
</Button>
</Paper>
@ -354,31 +359,27 @@ export function DetailsImage(props: Readonly<DetailImageProps>) {
};
return (
<>
<AspectRatio ref={ref} maw={IMAGE_DIMENSION} ratio={1} pos="relative">
<>
<ApiImage
src={img}
mah={IMAGE_DIMENSION}
maw={IMAGE_DIMENSION}
onClick={expandImage}
/>
{permissions.hasChangeRole(props.appRole) &&
hasOverlay &&
hovered && (
<Overlay color="black" opacity={0.8} onClick={expandImage}>
<ImageActionButtons
visible={hovered}
actions={props.imageActions}
apiPath={props.apiPath}
hasImage={props.src ? true : false}
pk={props.pk}
setImage={setAndRefresh}
/>
</Overlay>
)}
</>
</AspectRatio>
</>
<AspectRatio ref={ref} maw={IMAGE_DIMENSION} ratio={1} pos="relative">
<>
<ApiImage
src={img}
mah={IMAGE_DIMENSION}
maw={IMAGE_DIMENSION}
onClick={expandImage}
/>
{permissions.hasChangeRole(props.appRole) && hasOverlay && hovered && (
<Overlay color="black" opacity={0.8} onClick={expandImage}>
<ImageActionButtons
visible={hovered}
actions={props.imageActions}
apiPath={props.apiPath}
hasImage={props.src ? true : false}
pk={props.pk}
setImage={setAndRefresh}
/>
</Overlay>
)}
</>
</AspectRatio>
);
}

View File

@ -1,90 +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.active && (
<Tooltip label={t`Part is not active`}>
<Badge color="red" variant="filled">
<div
style={{ display: 'flex', alignItems: 'center', gap: '5px' }}
>
<InvenTreeIcon icon="inactive" iconProps={{ size: 19 }} />{' '}
<Trans>Inactive</Trans>
</div>
</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>
);
}

View File

@ -142,7 +142,12 @@ export default function NotesEditor({
// Callback to save notes to the server
const saveNotes = useCallback(() => {
const markdown = ref.current?.getMarkdown() ?? '';
const markdown = ref.current?.getMarkdown();
if (!noteUrl || markdown === undefined) {
return;
}
api
.patch(noteUrl, { notes: markdown })
.then(() => {
@ -163,7 +168,7 @@ export default function NotesEditor({
id: 'notes'
});
});
}, [noteUrl, ref.current]);
}, [api, noteUrl, ref.current]);
const plugins: any[] = useMemo(() => {
let plg = [

View File

@ -25,12 +25,18 @@ const tags: Tag[] = [
description: 'Generate a QR code image',
args: ['data'],
kwargs: {
fill_color: 'Fill color (default = black)',
back_color: 'Background color (default = white)',
version: 'Version (default = 1)',
box_size: 'Box size (default = 20)',
border: 'Border width (default = 1)',
format: 'Format (default = PNG)'
version: 'QR code version, (None to auto detect) (default = None)',
error_correction:
"Error correction level (L: 7%, M: 15%, Q: 25%, H: 30%) (default = 'Q')",
box_size:
'pixel dimensions for one black square pixel in the QR code (default = 20)',
border:
'count white QR square pixels around the qr code, needed as padding (default = 1)',
optimize:
'data will be split into multiple chunks of at least this length using different modes (text, alphanumeric, binary) to optimize the QR code size. Set to `0` to disable. (default = 1)',
format: "Image format (default = 'PNG')",
fill_color: 'Fill color (default = "black")',
back_color: 'Background color (default = "white")'
},
returns: 'base64 encoded qr code image data'
},

View File

@ -81,7 +81,9 @@ export const PdfPreviewComponent: PreviewAreaComponent = forwardRef(
<Trans>Preview not available, click "Reload Preview".</Trans>
</div>
)}
{pdfUrl && <iframe src={pdfUrl} width="100%" height="100%" />}
{pdfUrl && (
<iframe src={pdfUrl} width="100%" height="100%" title="PDF Preview" />
)}
</>
);
}

View File

@ -210,7 +210,11 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
);
const templateFilters: Record<string, string> = useMemo(() => {
// TODO: Extract custom filters from template
// TODO: Extract custom filters from template (make this more generic)
if (template.model_type === ModelType.stockitem) {
return { part_detail: 'true' } as Record<string, string>;
}
return {};
}, [template]);

View File

@ -0,0 +1,28 @@
import { t } from '@lingui/macro';
import GenericErrorPage from './GenericErrorPage';
import NotAuthenticated from './NotAuthenticated';
import NotFound from './NotFound';
import PermissionDenied from './PermissionDenied';
export default function ClientError({ status }: { status?: number }) {
switch (status) {
case 401:
return <NotAuthenticated />;
case 403:
return <PermissionDenied />;
case 404:
return <NotFound />;
default:
break;
}
// Generic client error
return (
<GenericErrorPage
title={t`Client Error`}
message={t`Client error occurred`}
status={status}
/>
);
}

View File

@ -0,0 +1,73 @@
import { Trans } from '@lingui/macro';
import {
ActionIcon,
Button,
Card,
Center,
Container,
Divider,
Group,
Stack,
Text
} from '@mantine/core';
import { IconArrowBack, IconExclamationCircle } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { LanguageContext } from '../../contexts/LanguageContext';
export default function ErrorPage({
title,
message,
status
}: {
title: string;
message: string;
status?: number;
redirectMessage?: string;
redirectTarget?: string;
}) {
const navigate = useNavigate();
return (
<LanguageContext>
<Center>
<Container w="md" miw={400}>
<Card withBorder shadow="xs" padding="xl" radius="sm">
<Card.Section p="lg">
<Group gap="xs">
<ActionIcon color="red" variant="transparent" size="xl">
<IconExclamationCircle />
</ActionIcon>
<Text size="xl">{title}</Text>
</Group>
</Card.Section>
<Divider />
<Card.Section p="lg">
<Stack gap="md">
<Text size="lg">{message}</Text>
{status && (
<Text>
<Trans>Status Code</Trans>: {status}
</Text>
)}
</Stack>
</Card.Section>
<Divider />
<Card.Section p="lg">
<Center>
<Button
variant="outline"
color="green"
onClick={() => navigate('/')}
>
<Trans>Return to the index page</Trans>
<IconArrowBack />
</Button>
</Center>
</Card.Section>
</Card>
</Container>
</Center>
</LanguageContext>
);
}

View File

@ -0,0 +1,12 @@
import { t } from '@lingui/macro';
import GenericErrorPage from './GenericErrorPage';
export default function NotAuthenticated() {
return (
<GenericErrorPage
title={t`Not Authenticated`}
message={t`You are not logged in.`}
/>
);
}

View File

@ -0,0 +1,12 @@
import { t } from '@lingui/macro';
import GenericErrorPage from './GenericErrorPage';
export default function NotFound() {
return (
<GenericErrorPage
title={t`Page Not Found`}
message={t`This page does not exist`}
/>
);
}

View File

@ -0,0 +1,12 @@
import { t } from '@lingui/macro';
import GenericErrorPage from './GenericErrorPage';
export default function PermissionDenied() {
return (
<GenericErrorPage
title={t`Permission Denied`}
message={t`You do not have permission to view this page.`}
/>
);
}

View File

@ -0,0 +1,13 @@
import { t } from '@lingui/macro';
import GenericErrorPage from './GenericErrorPage';
export default function ServerError({ status }: { status?: number }) {
return (
<GenericErrorPage
title={t`Server Error`}
message={t`A server error occurred`}
status={status}
/>
);
}

View File

@ -67,6 +67,7 @@ export interface ApiFormAction {
* @param successMessage : Optional message to display on successful form submission
* @param onFormSuccess : A callback function to call when the form is submitted successfully.
* @param onFormError : A callback function to call when the form is submitted with errors.
* @param processFormData : A callback function to process the form data before submission
* @param modelType : Define a model type for this form
* @param follow : Boolean, follow the result of the form (if possible)
* @param table : Table to update on success (if provided)
@ -91,6 +92,7 @@ export interface ApiFormProps {
successMessage?: string;
onFormSuccess?: (data: any) => void;
onFormError?: () => void;
processFormData?: (data: any) => any;
table?: TableState;
modelType?: ModelType;
follow?: boolean;
@ -123,7 +125,6 @@ export function OptionsApiForm({
const optionsQuery = useQuery({
enabled: true,
refetchOnMount: false,
refetchOnWindowFocus: false,
queryKey: [
'form-options-data',
id,
@ -138,6 +139,7 @@ export function OptionsApiForm({
if (!props.ignorePermissionCheck) {
fields = extractAvailableFields(response, props.method);
}
return fields;
},
throwOnError: (error: any) => {
@ -182,7 +184,7 @@ export function OptionsApiForm({
<ApiForm
id={id}
props={formProps}
optionsLoading={optionsQuery.isFetching}
optionsLoading={optionsQuery.isFetching || !optionsQuery.data}
/>
);
}
@ -202,9 +204,9 @@ export function ApiForm({
}) {
const navigate = useNavigate();
const fields: ApiFormFieldSet = useMemo(() => {
return props.fields ?? {};
}, [props.fields]);
const [fields, setFields] = useState<ApiFormFieldSet>(
() => props.fields ?? {}
);
const defaultValues: FieldValues = useMemo(() => {
let defaultValuesMap = mapFields(fields ?? {}, (_path, field) => {
@ -247,6 +249,31 @@ export function ApiForm({
[props.url, props.pk, props.pathParams]
);
// Define function to process API response
const processFields = (fields: ApiFormFieldSet, data: NestedDict) => {
const res: NestedDict = {};
for (const [k, field] of Object.entries(fields)) {
const dataValue = data[k];
if (
field.field_type === 'nested object' &&
field.children &&
typeof dataValue === 'object'
) {
res[k] = processFields(field.children, dataValue);
} else {
res[k] = dataValue;
if (field.onValueChange) {
field.onValueChange(dataValue, data);
}
}
}
return res;
};
// Query manager for retrieving initial data from the server
const initialDataQuery = useQuery({
enabled: false,
@ -259,66 +286,51 @@ export function ApiForm({
props.pathParams
],
queryFn: async () => {
try {
// Await API call
let response = await api.get(url);
return await api
.get(url)
.then((response: any) => {
// Process API response
const fetchedData: any = processFields(fields, response.data);
// Define function to process API response
const processFields = (fields: ApiFormFieldSet, data: NestedDict) => {
const res: NestedDict = {};
// TODO: replace with .map()
for (const [k, field] of Object.entries(fields)) {
const dataValue = data[k];
if (
field.field_type === 'nested object' &&
field.children &&
typeof dataValue === 'object'
) {
res[k] = processFields(field.children, dataValue);
} else {
res[k] = dataValue;
if (field.onValueChange) {
field.onValueChange(dataValue, data);
}
}
}
return res;
};
// Process API response
const initialData: any = processFields(fields, response.data);
// Update form values, but only for the fields specified for this form
form.reset(initialData);
// Update the field references, too
Object.keys(fields).forEach((fieldName) => {
if (fieldName in initialData) {
let field = fields[fieldName] ?? {};
fields[fieldName] = {
...field,
value: initialData[fieldName]
};
}
// Update form values, but only for the fields specified for this form
form.reset(fetchedData);
return fetchedData;
})
.catch(() => {
return {};
});
return response;
} catch (error) {
console.error('Error fetching initial data:', error);
// Re-throw error to allow react-query to handle error
throw error;
}
}
});
useEffect(() => {
let _fields: any = props.fields || {};
let _initialData: any = props.initialData || {};
let _fetchedData: any = initialDataQuery.data || {};
for (const k of Object.keys(_fields)) {
// Ensure default values override initial field spec
if (k in defaultValues) {
_fields[k].value = defaultValues[k];
}
// Ensure initial data overrides default values
if (_initialData && k in _initialData) {
_fields[k].value = _initialData[k];
}
// Ensure fetched data overrides also
if (_fetchedData && k in _fetchedData) {
_fields[k].value = _fetchedData[k];
}
}
setFields(_fields);
}, [props.fields, props.initialData, defaultValues, initialDataQuery.data]);
// Fetch initial data on form load
useEffect(() => {
// Fetch initial data if the fetchInitialData property is set
if (props.fetchInitialData) {
if (!optionsLoading && props.fetchInitialData) {
queryClient.removeQueries({
queryKey: [
'form-initial-data',
@ -331,22 +343,16 @@ export function ApiForm({
});
initialDataQuery.refetch();
}
}, [props.fetchInitialData]);
}, [props.fetchInitialData, optionsLoading]);
const isLoading = useMemo(
const isLoading: boolean = useMemo(
() =>
isFormLoading ||
initialDataQuery.isFetching ||
optionsLoading ||
isSubmitting ||
!fields,
[
isFormLoading,
initialDataQuery.isFetching,
isSubmitting,
fields,
optionsLoading
]
[isFormLoading, initialDataQuery, isSubmitting, fields, optionsLoading]
);
const [initialFocus, setInitialFocus] = useState<string>('');
@ -362,17 +368,22 @@ export function ApiForm({
return;
}
// Do not auto-focus on a 'choice' field
if (field.field_type == 'choice') {
return;
}
focusField = fieldName;
});
}
if (isLoading || initialFocus == focusField) {
if (isLoading) {
return;
}
form.setFocus(focusField);
setInitialFocus(focusField);
}, [props.focus, fields, form.setFocus, isLoading, initialFocus]);
}, [props.focus, form.setFocus, isLoading, initialFocus]);
const submitForm: SubmitHandler<FieldValues> = async (data) => {
setNonFieldErrors([]);
@ -380,16 +391,42 @@ export function ApiForm({
let method = props.method?.toLowerCase() ?? 'get';
let hasFiles = false;
mapFields(fields, (_path, field) => {
if (field.field_type === 'file upload') {
// Optionally pre-process the data before submitting it
if (props.processFormData) {
data = props.processFormData(data);
}
let dataForm = new FormData();
Object.keys(data).forEach((key: string) => {
let value: any = data[key];
let field_type = fields[key]?.field_type;
if (field_type == 'file upload' && !!value) {
hasFiles = true;
}
// Stringify any JSON objects
if (typeof value === 'object') {
switch (field_type) {
case 'file upload':
break;
default:
value = JSON.stringify(value);
break;
}
}
if (value != undefined) {
dataForm.append(key, value);
}
});
return api({
method: method,
url: url,
data: data,
data: hasFiles ? dataForm : data,
timeout: props.timeout,
headers: {
'Content-Type': hasFiles ? 'multipart/form-data' : 'application/json'
@ -453,7 +490,11 @@ export function ApiForm({
for (const [k, v] of Object.entries(errors)) {
const path = _path ? `${_path}.${k}` : k;
if (k === 'non_field_errors' || k === '__all__') {
// Determine if field "k" is valid (exists and is visible)
let field = fields[k];
let valid = field && !field.hidden;
if (!valid || k === 'non_field_errors' || k === '__all__') {
if (Array.isArray(v)) {
_nonFieldErrors.push(...v);
}
@ -490,6 +531,14 @@ export function ApiForm({
props.onFormError?.();
}, [props.onFormError]);
if (optionsLoading || initialDataQuery.isFetching) {
return (
<Paper mah={'65vh'}>
<LoadingOverlay visible zIndex={1010} />
</Paper>
);
}
return (
<Stack>
<Boundary label={`ApiForm-${id}`}>
@ -503,13 +552,15 @@ export function ApiForm({
{/* Form Fields */}
<Stack gap="sm">
{(!isValid || nonFieldErrors.length > 0) && (
<Alert radius="sm" color="red" title={t`Error`}>
{nonFieldErrors.length > 0 && (
<Alert radius="sm" color="red" title={t`Form Error`}>
{nonFieldErrors.length > 0 ? (
<Stack gap="xs">
{nonFieldErrors.map((message) => (
<Text key={message}>{message}</Text>
))}
</Stack>
) : (
<Text>{t`Errors exist for one or more form fields`}</Text>
)}
</Alert>
)}
@ -529,15 +580,18 @@ export function ApiForm({
<Boundary label={`ApiForm-${id}-FormContent`}>
<FormProvider {...form}>
<Stack gap="xs">
{!optionsLoading &&
Object.entries(fields).map(([fieldName, field]) => (
{Object.entries(fields).map(([fieldName, field]) => {
return (
<ApiFormField
key={fieldName}
fieldName={fieldName}
definition={field}
control={form.control}
url={url}
setFields={setFields}
/>
))}
);
})}
</Stack>
</FormProvider>
</Boundary>

View File

@ -5,10 +5,12 @@ import { ApiFormField, ApiFormFieldType } from './fields/ApiFormField';
export function StandaloneField({
fieldDefinition,
defaultValue
defaultValue,
hideLabels
}: {
fieldDefinition: ApiFormFieldType;
defaultValue?: any;
hideLabels?: boolean;
}) {
const defaultValues = useMemo(() => {
if (defaultValue)
@ -29,6 +31,7 @@ export function StandaloneField({
fieldName="field"
definition={fieldDefinition}
control={form.control}
hideLabels={hideLabels}
/>
</FormProvider>
);

View File

@ -1,15 +1,7 @@
import { t } from '@lingui/macro';
import {
Alert,
FileInput,
NumberInput,
Stack,
Switch,
TextInput
} from '@mantine/core';
import { Alert, FileInput, NumberInput, Stack, Switch } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { useId } from '@mantine/hooks';
import { IconX } from '@tabler/icons-react';
import { ReactNode, useCallback, useEffect, useMemo } from 'react';
import { Control, FieldValues, useController } from 'react-hook-form';
@ -17,9 +9,12 @@ import { ModelType } from '../../../enums/ModelType';
import { isTrue } from '../../../functions/conversion';
import { ChoiceField } from './ChoiceField';
import DateField from './DateField';
import { DependentField } from './DependentField';
import IconField from './IconField';
import { NestedObjectField } from './NestedObjectField';
import { RelatedModelField } from './RelatedModelField';
import { TableField } from './TableField';
import TextField from './TextField';
export type ApiFormData = UseFormReturnType<Record<string, unknown>>;
@ -64,6 +59,7 @@ export type ApiFormFieldType = {
| 'email'
| 'url'
| 'string'
| 'icon'
| 'boolean'
| 'date'
| 'datetime'
@ -74,12 +70,14 @@ export type ApiFormFieldType = {
| 'choice'
| 'file upload'
| 'nested object'
| 'dependent field'
| 'table';
api_url?: string;
pk_field?: string;
model?: ModelType;
modelRenderer?: (instance: any) => ReactNode;
filters?: any;
child?: ApiFormFieldType;
children?: { [key: string]: ApiFormFieldType };
required?: boolean;
choices?: any[];
@ -94,6 +92,7 @@ export type ApiFormFieldType = {
onValueChange?: (value: any, record?: any) => void;
adjustFilters?: (value: ApiFormAdjustFilterType) => any;
headers?: string[];
depends_on?: string[];
};
/**
@ -102,11 +101,17 @@ export type ApiFormFieldType = {
export function ApiFormField({
fieldName,
definition,
control
control,
hideLabels,
url,
setFields
}: {
fieldName: string;
definition: ApiFormFieldType;
control: Control<FieldValues, any>;
hideLabels?: boolean;
url?: string;
setFields?: React.Dispatch<React.SetStateAction<ApiFormFieldSet>>;
}) {
const fieldId = useId();
const controller = useController({
@ -120,7 +125,11 @@ export function ApiFormField({
const { value, ref } = field;
useEffect(() => {
if (definition.field_type === 'nested object') return;
if (
definition.field_type === 'nested object' ||
definition.field_type === 'dependent field'
)
return;
// hook up the value state to the input field
if (definition.value !== undefined) {
@ -128,18 +137,26 @@ export function ApiFormField({
}
}, [definition.value]);
const fieldDefinition: ApiFormFieldType = useMemo(() => {
return {
...definition,
label: hideLabels ? undefined : definition.label,
description: hideLabels ? undefined : definition.description
};
}, [hideLabels, definition]);
// pull out onValueChange as this can cause strange errors when passing the
// definition to the input components via spread syntax
const reducedDefinition = useMemo(() => {
return {
...definition,
...fieldDefinition,
onValueChange: undefined,
adjustFilters: undefined,
adjustValue: undefined,
read_only: undefined,
children: undefined
};
}, [definition]);
}, [fieldDefinition]);
// Callback helper when form value changes
const onChange = useCallback(
@ -179,11 +196,11 @@ export function ApiFormField({
}
return val;
}, [value]);
}, [definition.field_type, value]);
// Coerce the value to a (stringified) boolean value
const booleanValue: string = useMemo(() => {
return isTrue(value).toString();
const booleanValue: boolean = useMemo(() => {
return isTrue(value);
}, [value]);
// Construct the individual field
@ -193,7 +210,7 @@ export function ApiFormField({
return (
<RelatedModelField
controller={controller}
definition={definition}
definition={fieldDefinition}
fieldName={fieldName}
/>
);
@ -201,41 +218,36 @@ export function ApiFormField({
case 'url':
case 'string':
return (
<TextInput
{...reducedDefinition}
ref={field.ref}
id={fieldId}
aria-label={`text-field-${field.name}`}
type={definition.field_type}
value={value || ''}
error={error?.message}
radius="sm"
onChange={(event) => onChange(event.currentTarget.value)}
rightSection={
value && !definition.required ? (
<IconX size="1rem" color="red" onClick={() => onChange('')} />
) : null
}
<TextField
definition={reducedDefinition}
controller={controller}
fieldName={fieldName}
onChange={onChange}
/>
);
case 'icon':
return (
<IconField definition={fieldDefinition} controller={controller} />
);
case 'boolean':
return (
<Switch
{...reducedDefinition}
value={booleanValue}
checked={booleanValue}
ref={ref}
id={fieldId}
aria-label={`boolean-field-${field.name}`}
radius="lg"
size="sm"
checked={isTrue(value)}
error={error?.message}
onChange={(event) => onChange(event.currentTarget.checked)}
/>
);
case 'date':
case 'datetime':
return <DateField controller={controller} definition={definition} />;
return (
<DateField controller={controller} definition={fieldDefinition} />
);
case 'integer':
case 'decimal':
case 'float':
@ -259,7 +271,7 @@ export function ApiFormField({
<ChoiceField
controller={controller}
fieldName={fieldName}
definition={definition}
definition={fieldDefinition}
/>
);
case 'file upload':
@ -277,15 +289,27 @@ export function ApiFormField({
case 'nested object':
return (
<NestedObjectField
definition={definition}
definition={fieldDefinition}
fieldName={fieldName}
control={control}
url={url}
setFields={setFields}
/>
);
case 'dependent field':
return (
<DependentField
definition={fieldDefinition}
fieldName={fieldName}
control={control}
url={url}
setFields={setFields}
/>
);
case 'table':
return (
<TableField
definition={definition}
definition={fieldDefinition}
fieldName={fieldName}
control={controller}
/>
@ -293,8 +317,8 @@ export function ApiFormField({
default:
return (
<Alert color="red" title={t`Error`}>
Invalid field type for field '{fieldName}': '{definition.field_type}
'
Invalid field type for field '{fieldName}': '
{fieldDefinition.field_type}'
</Alert>
);
}

View File

@ -1,6 +1,6 @@
import { Select } from '@mantine/core';
import { useId } from '@mantine/hooks';
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { FieldValues, UseControllerReturn } from 'react-hook-form';
import { ApiFormFieldType } from './ApiFormField';
@ -10,7 +10,8 @@ import { ApiFormFieldType } from './ApiFormField';
*/
export function ChoiceField({
controller,
definition
definition,
fieldName
}: {
controller: UseControllerReturn<FieldValues, any>;
definition: ApiFormFieldType;
@ -23,6 +24,8 @@ export function ChoiceField({
fieldState: { error }
} = controller;
const { value } = field;
// Build a set of choices for the field
const choices: any[] = useMemo(() => {
let choices = definition.choices ?? [];
@ -48,6 +51,14 @@ export function ChoiceField({
[field.onChange, definition]
);
const choiceValue = useMemo(() => {
if (!value) {
return '';
} else {
return value.toString();
}
}, [value]);
return (
<Select
id={fieldId}
@ -57,7 +68,7 @@ export function ChoiceField({
{...field}
onChange={onChange}
data={choices}
value={field.value}
value={choiceValue}
label={definition.label}
description={definition.description}
placeholder={definition.placeholder}
@ -65,6 +76,7 @@ export function ChoiceField({
disabled={definition.disabled}
leftSection={definition.icon}
comboboxProps={{ withinPortal: true }}
searchable
/>
);
}

View File

@ -0,0 +1,89 @@
import { useEffect, useMemo } from 'react';
import { Control, FieldValues, useFormContext } from 'react-hook-form';
import { api } from '../../../App';
import {
constructField,
extractAvailableFields
} from '../../../functions/forms';
import {
ApiFormField,
ApiFormFieldSet,
ApiFormFieldType
} from './ApiFormField';
export function DependentField({
control,
fieldName,
definition,
url,
setFields
}: {
control: Control<FieldValues, any>;
definition: ApiFormFieldType;
fieldName: string;
url?: string;
setFields?: React.Dispatch<React.SetStateAction<ApiFormFieldSet>>;
}) {
const { watch, resetField } = useFormContext();
const mappedFieldNames = useMemo(
() =>
(definition.depends_on ?? []).map((f) =>
[...fieldName.split('.').slice(0, -1), f].join('.')
),
[definition.depends_on]
);
useEffect(() => {
const { unsubscribe } = watch(async (values, { name }) => {
// subscribe only to the fields that this field depends on
if (!name || !mappedFieldNames.includes(name)) return;
if (!url || !setFields) return;
const res = await api.options(url, {
data: values // provide the current form state to the API
});
const fields: Record<string, ApiFormFieldType> | null =
extractAvailableFields(res, 'POST');
// update the fields in the form state with the new fields
setFields((prevFields) => {
const newFields: Record<string, ReturnType<typeof constructField>> = {};
for (const [k, v] of Object.entries(prevFields)) {
newFields[k] = constructField({
field: v,
definition: fields?.[k]
});
}
return newFields;
});
// reset the current field and all nested values with undefined
resetField(fieldName, {
defaultValue: undefined,
keepDirty: true,
keepTouched: true
});
});
return () => unsubscribe();
}, [mappedFieldNames, url, setFields, resetField, fieldName]);
if (!definition.child) {
return null;
}
return (
<ApiFormField
control={control}
fieldName={fieldName}
definition={definition.child}
url={url}
setFields={setFields}
/>
);
}

View File

@ -0,0 +1,352 @@
import { Trans, t } from '@lingui/macro';
import {
Box,
CloseButton,
Combobox,
ComboboxStore,
Group,
Input,
InputBase,
Select,
Stack,
Text,
TextInput,
useCombobox
} from '@mantine/core';
import { useDebouncedValue, useElementSize } from '@mantine/hooks';
import { IconX } from '@tabler/icons-react';
import Fuse from 'fuse.js';
import { startTransition, useEffect, useMemo, useRef, useState } from 'react';
import { FieldValues, UseControllerReturn } from 'react-hook-form';
import { FixedSizeGrid as Grid } from 'react-window';
import { useIconState } from '../../../states/IconState';
import { ApiIcon } from '../../items/ApiIcon';
import { ApiFormFieldType } from './ApiFormField';
export default function IconField({
controller,
definition
}: Readonly<{
controller: UseControllerReturn<FieldValues, any>;
definition: ApiFormFieldType;
}>) {
const {
field,
fieldState: { error }
} = controller;
const { value } = field;
const [open, setOpen] = useState(false);
const combobox = useCombobox({
onOpenedChange: (opened) => setOpen(opened)
});
return (
<Combobox store={combobox}>
<Combobox.Target>
<InputBase
label={definition.label}
description={definition.description}
required={definition.required}
error={error?.message}
ref={field.ref}
component="button"
type="button"
pointer
rightSection={
value !== null && !definition.required ? (
<CloseButton
size="sm"
onMouseDown={(e) => e.preventDefault()}
onClick={() => field.onChange(null)}
/>
) : (
<Combobox.Chevron />
)
}
onClick={() => combobox.toggleDropdown()}
rightSectionPointerEvents={value === null ? 'none' : 'all'}
>
{field.value ? (
<Group gap="xs">
<ApiIcon name={field.value} />
<Text size="sm" c="dimmed">
{field.value}
</Text>
</Group>
) : (
<Input.Placeholder>
<Trans>No icon selected</Trans>
</Input.Placeholder>
)}
</InputBase>
</Combobox.Target>
<Combobox.Dropdown>
<ComboboxDropdown
definition={definition}
value={value}
combobox={combobox}
onChange={field.onChange}
open={open}
/>
</Combobox.Dropdown>
</Combobox>
);
}
type RenderIconType = {
package: string;
name: string;
tags: string[];
category: string;
variant: string;
};
function ComboboxDropdown({
definition,
value,
combobox,
onChange,
open
}: Readonly<{
definition: ApiFormFieldType;
value: null | string;
combobox: ComboboxStore;
onChange: (newVal: string | null) => void;
open: boolean;
}>) {
const iconPacks = useIconState((s) => s.packages);
const icons = useMemo<RenderIconType[]>(() => {
return iconPacks.flatMap((pack) =>
Object.entries(pack.icons).flatMap(([name, icon]) =>
Object.entries(icon.variants).map(([variant]) => ({
package: pack.prefix,
name: `${pack.prefix}:${name}:${variant}`,
tags: icon.tags,
category: icon.category,
variant: variant
}))
)
);
}, [iconPacks]);
const filter = useMemo(
() =>
new Fuse(icons, {
threshold: 0.2,
keys: ['name', 'tags', 'category', 'variant']
}),
[icons]
);
const [searchValue, setSearchValue] = useState('');
const [debouncedSearchValue] = useDebouncedValue(searchValue, 200);
const [category, setCategory] = useState<string | null>(null);
const [pack, setPack] = useState<string | null>(null);
const categories = useMemo(
() =>
Array.from(
new Set(
icons
.filter((i) => (pack !== null ? i.package === pack : true))
.map((i) => i.category)
)
).map((x) =>
x === ''
? { value: '', label: t`Uncategorized` }
: { value: x, label: x }
),
[icons, pack]
);
const packs = useMemo(
() => iconPacks.map((pack) => ({ value: pack.prefix, label: pack.name })),
[iconPacks]
);
const applyFilters = (
iconList: RenderIconType[],
category: string | null,
pack: string | null
) => {
if (category === null && pack === null) return iconList;
return iconList.filter(
(i) =>
(category !== null ? i.category === category : true) &&
(pack !== null ? i.package === pack : true)
);
};
const filteredIcons = useMemo(() => {
if (!debouncedSearchValue) {
return applyFilters(icons, category, pack);
}
const res = filter.search(debouncedSearchValue.trim()).map((r) => r.item);
return applyFilters(res, category, pack);
}, [debouncedSearchValue, filter, category, pack]);
// Reset category when pack changes and the current category is not available in the new pack
useEffect(() => {
if (value === null) return;
if (!categories.find((c) => c.value === category)) {
setCategory(null);
}
}, [pack]);
const { width, ref } = useElementSize();
return (
<Stack gap={6} ref={ref}>
<Group gap={4}>
<TextInput
value={searchValue}
onChange={(e) => setSearchValue(e.currentTarget.value)}
placeholder={t`Search...`}
rightSection={
searchValue && !definition.required ? (
<IconX size="1rem" onClick={() => setSearchValue('')} />
) : null
}
flex={1}
/>
<Select
value={category}
onChange={(c) => startTransition(() => setCategory(c))}
data={categories}
comboboxProps={{ withinPortal: false }}
clearable
placeholder={t`Select category`}
/>
<Select
value={pack}
onChange={(c) => startTransition(() => setPack(c))}
data={packs}
comboboxProps={{ withinPortal: false }}
clearable
placeholder={t`Select pack`}
/>
</Group>
<Text size="sm" c="dimmed" ta="center" mt={-4}>
<Trans>{filteredIcons.length} icons</Trans>
</Text>
<DropdownList
icons={filteredIcons}
onChange={onChange}
combobox={combobox}
value={value}
width={width}
open={open}
/>
</Stack>
);
}
function DropdownList({
icons,
onChange,
combobox,
value,
width,
open
}: Readonly<{
icons: RenderIconType[];
onChange: (newVal: string | null) => void;
combobox: ComboboxStore;
value: string | null;
width: number;
open: boolean;
}>) {
// Get the inner width of the dropdown (excluding the scrollbar) by using the outerRef provided by the react-window Grid element
const { width: innerWidth, ref: innerRef } = useElementSize();
const columnCount = Math.floor(innerWidth / 35);
const rowCount = columnCount > 0 ? Math.ceil(icons.length / columnCount) : 0;
const gridRef = useRef<Grid>(null);
const hasScrolledToPositionRef = useRef(true);
// Reset the has already scrolled to position state when the dropdown open state is changed
useEffect(() => {
const timeoutId = setTimeout(() => {
hasScrolledToPositionRef.current = false;
}, 100);
return () => clearTimeout(timeoutId);
}, [open]);
// Scroll to the selected icon if not already has scrolled to position
useEffect(() => {
// Do not scroll if the value is not set, columnCount is not set, the dropdown is not open, or the position has already been scrolled to
if (
!value ||
columnCount === 0 ||
hasScrolledToPositionRef.current ||
!open
)
return;
const iconIdx = icons.findIndex((i) => i.name === value);
if (iconIdx === -1) return;
gridRef.current?.scrollToItem({
align: 'start',
rowIndex: Math.floor(iconIdx / columnCount)
});
hasScrolledToPositionRef.current = true;
}, [value, columnCount, open]);
return (
<Grid
height={200}
width={width}
rowCount={rowCount}
columnCount={columnCount}
rowHeight={35}
columnWidth={35}
itemData={icons}
outerRef={innerRef}
ref={gridRef}
>
{({ columnIndex, rowIndex, data, style }) => {
const icon = data[rowIndex * columnCount + columnIndex];
// Grid has empty cells in the last row if the number of icons is not a multiple of columnCount
if (icon === undefined) return null;
const isSelected = value === icon.name;
return (
<Box
key={icon.name}
title={icon.name}
onClick={() => {
onChange(isSelected ? null : icon.name);
combobox.closeDropdown();
}}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
background: isSelected
? 'var(--mantine-color-blue-filled)'
: 'unset',
borderRadius: 'var(--mantine-radius-default)',
...style
}}
>
<ApiIcon name={icon.name} size={24} />
</Box>
);
}}
</Grid>
);
}

View File

@ -1,16 +1,24 @@
import { Accordion, Divider, Stack, Text } from '@mantine/core';
import { Control, FieldValues } from 'react-hook-form';
import { ApiFormField, ApiFormFieldType } from './ApiFormField';
import {
ApiFormField,
ApiFormFieldSet,
ApiFormFieldType
} from './ApiFormField';
export function NestedObjectField({
control,
fieldName,
definition
definition,
url,
setFields
}: {
control: Control<FieldValues, any>;
definition: ApiFormFieldType;
fieldName: string;
url?: string;
setFields?: React.Dispatch<React.SetStateAction<ApiFormFieldSet>>;
}) {
return (
<Accordion defaultValue={'OpenByDefault'} variant="contained">
@ -28,6 +36,8 @@ export function NestedObjectField({
fieldName={`${fieldName}.${childFieldName}`}
definition={field}
control={control}
url={url}
setFields={setFields}
/>
)
)}

View File

@ -236,25 +236,23 @@ export function RelatedModelField({
// Field doesn't follow Mantine theming
// Define color theme to pass to field based on Mantine theme
const theme = useMantineTheme();
const colorschema = vars.colors.primaryColors;
const { colorScheme } = useMantineColorScheme();
const colors = useMemo(() => {
let colors: any;
if (colorScheme === 'dark') {
colors = {
neutral0: colorschema[6],
neutral5: colorschema[4],
neutral10: colorschema[4],
neutral20: colorschema[4],
neutral30: colorschema[3],
neutral40: colorschema[2],
neutral50: colorschema[1],
neutral60: colorschema[0],
neutral70: colorschema[0],
neutral80: colorschema[0],
neutral90: colorschema[0],
neutral0: vars.colors.dark[6],
neutral5: vars.colors.dark[4],
neutral10: vars.colors.dark[4],
neutral20: vars.colors.dark[4],
neutral30: vars.colors.dark[3],
neutral40: vars.colors.dark[2],
neutral50: vars.colors.dark[1],
neutral60: vars.colors.dark[0],
neutral70: vars.colors.dark[0],
neutral80: vars.colors.dark[0],
neutral90: vars.colors.dark[0],
primary: vars.colors.primaryColors[7],
primary25: vars.colors.primaryColors[6],
primary50: vars.colors.primaryColors[5],

View File

@ -1,8 +1,10 @@
import { Trans, t } from '@lingui/macro';
import { Container, Flex, Group, Table } from '@mantine/core';
import { Container, Group, Table } from '@mantine/core';
import { useEffect, useMemo } from 'react';
import { FieldValues, UseControllerReturn } from 'react-hook-form';
import { InvenTreeIcon } from '../../../functions/icons';
import { StandaloneField } from '../StandaloneField';
import { ApiFormFieldType } from './ApiFormField';
export function TableField({
@ -83,23 +85,51 @@ export function TableField({
/*
* Display an "extra" row below the main table row, for additional information.
* - Each "row" can display an extra row of information below the main row
*/
export function TableFieldExtraRow({
visible,
content,
colSpan
fieldDefinition,
defaultValue,
emptyValue,
onValueChange
}: {
visible: boolean;
content: React.ReactNode;
colSpan?: number;
fieldDefinition: ApiFormFieldType;
defaultValue?: any;
emptyValue?: any;
onValueChange: (value: any) => void;
}) {
// Callback whenever the visibility of the sub-field changes
useEffect(() => {
if (!visible) {
// If the sub-field is hidden, reset the value to the "empty" value
onValueChange(emptyValue);
}
}, [visible]);
const field: ApiFormFieldType = useMemo(() => {
return {
...fieldDefinition,
default: defaultValue,
onValueChange: (value: any) => {
onValueChange(value);
}
};
}, [fieldDefinition]);
return (
visible && (
<Table.Tr>
<Table.Td colSpan={colSpan ?? 3}>
<Group justify="flex-start" grow>
<InvenTreeIcon icon="downright" />
{content}
<Table.Td colSpan={10}>
<Group grow preventGrowOverflow={false} justify="flex-apart" p="xs">
<Container flex={0} p="xs">
<InvenTreeIcon icon="downright" />
</Container>
<StandaloneField
fieldDefinition={field}
defaultValue={defaultValue}
/>
</Group>
</Table.Td>
</Table.Tr>

View File

@ -0,0 +1,69 @@
import { TextInput } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { IconX } from '@tabler/icons-react';
import { useCallback, useEffect, useId, useState } from 'react';
import { FieldValues, UseControllerReturn } from 'react-hook-form';
/*
* Custom implementation of the mantine <TextInput> component,
* used for rendering text input fields in forms.
* Uses a debounced value to prevent excessive re-renders.
*/
export default function TextField({
controller,
fieldName,
definition,
onChange
}: {
controller: UseControllerReturn<FieldValues, any>;
definition: any;
fieldName: string;
onChange: (value: any) => void;
}) {
const fieldId = useId();
const {
field,
fieldState: { error }
} = controller;
const { value } = field;
const [rawText, setRawText] = useState(value);
const [debouncedText] = useDebouncedValue(rawText, 250);
useEffect(() => {
setRawText(value);
}, [value]);
const onTextChange = useCallback((value: any) => {
setRawText(value);
}, []);
useEffect(() => {
if (debouncedText !== value) {
onChange(debouncedText);
}
}, [debouncedText]);
return (
<TextInput
{...definition}
ref={field.ref}
id={fieldId}
aria-label={`text-field-${field.name}`}
type={definition.field_type}
value={rawText || ''}
error={error?.message}
radius="sm"
onChange={(event) => onTextChange(event.currentTarget.value)}
onBlur={(event) => {
onChange(event.currentTarget.value);
}}
rightSection={
value && !definition.required ? (
<IconX size="1rem" color="red" onClick={() => onTextChange('')} />
) : null
}
/>
);
}

View File

@ -50,49 +50,3 @@ export function Thumbnail({
</Group>
);
}
export function ThumbnailHoverCard({
src,
text,
link = '',
alt = t`Thumbnail`,
size = 20
}: {
src: string;
text: string;
link?: string;
alt?: string;
size?: number;
}) {
const card = useMemo(() => {
return (
<Group justify="left" gap={10} wrap="nowrap">
<Thumbnail src={src} alt={alt} size={size} />
<Text>{text}</Text>
</Group>
);
}, [src, text, alt, size]);
if (link) {
return (
<Anchor href={link} style={{ textDecoration: 'none' }}>
{card}
</Anchor>
);
}
return <div>{card}</div>;
}
export function PartHoverCard({ part }: { part: any }) {
return part ? (
<ThumbnailHoverCard
src={part.thumbnail || part.image}
text={part.full_name}
alt={part.description}
link=""
/>
) : (
<Skeleton />
);
}

View File

@ -0,0 +1,427 @@
import { t } from '@lingui/macro';
import { Group, HoverCard, Paper, Space, Stack, Text } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
IconArrowRight,
IconCircleCheck,
IconCircleDashedCheck,
IconExclamationCircle
} from '@tabler/icons-react';
import { ReactNode, useCallback, useMemo, useState } from 'react';
import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { cancelEvent } from '../../functions/events';
import {
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { ImportSessionState } from '../../hooks/UseImportSession';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { TableColumn } from '../../tables/Column';
import { TableFilter } from '../../tables/Filter';
import { InvenTreeTable } from '../../tables/InvenTreeTable';
import { RowDeleteAction, RowEditAction } from '../../tables/RowActions';
import { ActionButton } from '../buttons/ActionButton';
import { YesNoButton } from '../buttons/YesNoButton';
import { ApiFormFieldSet } from '../forms/fields/ApiFormField';
import { ProgressBar } from '../items/ProgressBar';
import { RenderRemoteInstance } from '../render/Instance';
function ImporterDataCell({
session,
column,
row,
onEdit
}: {
session: ImportSessionState;
column: any;
row: any;
onEdit?: () => void;
}) {
const onRowEdit = useCallback(
(event: any) => {
cancelEvent(event);
if (!row.complete) {
onEdit?.();
}
},
[onEdit, row]
);
const cellErrors: string[] = useMemo(() => {
if (!row.errors) {
return [];
}
return row?.errors[column.field] ?? [];
}, [row.errors, column.field]);
const cellValue: ReactNode = useMemo(() => {
let field_def = session.availableFields[column.field];
if (!row?.data) {
return '-';
}
switch (field_def?.type) {
case 'boolean':
return (
<YesNoButton value={row.data ? row.data[column.field] : false} />
);
case 'related field':
if (field_def.model && row.data[column.field]) {
return (
<RenderRemoteInstance
model={field_def.model}
pk={row.data[column.field]}
/>
);
}
break;
default:
break;
}
let value = row.data ? row.data[column.field] ?? '' : '';
if (!value) {
value = '-';
}
return value;
}, [row.data, column.field, session.availableFields]);
const cellValid: boolean = useMemo(
() => cellErrors.length == 0,
[cellErrors]
);
return (
<HoverCard disabled={cellValid} openDelay={100} closeDelay={100}>
<HoverCard.Target>
<Group grow justify="apart" onClick={onRowEdit}>
<Group grow style={{ flex: 1 }}>
<Text size="xs" c={cellValid ? undefined : 'red'}>
{cellValue}
</Text>
</Group>
</Group>
</HoverCard.Target>
<HoverCard.Dropdown>
<Stack gap="xs">
{cellErrors.map((error: string) => (
<Text size="xs" c="red" key={error}>
{error}
</Text>
))}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
);
}
export default function ImporterDataSelector({
session
}: {
session: ImportSessionState;
}) {
const table = useTable('dataimporter');
const [selectedFieldNames, setSelectedFieldNames] = useState<string[]>([]);
const selectedFields: ApiFormFieldSet = useMemo(() => {
let fields: ApiFormFieldSet = {};
for (let field of selectedFieldNames) {
// Find the field definition in session.availableFields
let fieldDef = session.availableFields[field];
if (fieldDef) {
// Construct field filters based on session field filters
let filters = fieldDef.filters ?? {};
if (session.fieldFilters[field]) {
filters = {
...filters,
...session.fieldFilters[field]
};
}
fields[field] = {
...fieldDef,
field_type: fieldDef.type,
description: fieldDef.help_text,
filters: filters
};
}
}
return fields;
}, [selectedFieldNames, session.availableFields, session.fieldFilters]);
const importData = useCallback(
(rows: number[]) => {
notifications.show({
title: t`Importing Rows`,
message: t`Please wait while the data is imported`,
autoClose: false,
color: 'blue',
id: 'importing-rows',
icon: <IconArrowRight />
});
api
.post(
apiUrl(ApiEndpoints.import_session_accept_rows, session.sessionId),
{
rows: rows
}
)
.catch(() => {
notifications.show({
title: t`Error`,
message: t`An error occurred while importing data`,
color: 'red',
autoClose: true
});
})
.finally(() => {
table.clearSelectedRecords();
notifications.hide('importing-rows');
table.refreshTable();
session.refreshSession();
});
},
[session.sessionId, table.refreshTable]
);
const [selectedRow, setSelectedRow] = useState<any>({});
const editRow = useEditApiFormModal({
url: ApiEndpoints.import_session_row_list,
pk: selectedRow.pk,
title: t`Edit Data`,
fields: selectedFields,
initialData: selectedRow.data,
fetchInitialData: false,
processFormData: (data: any) => {
// Construct fields back into a single object
return {
data: {
...selectedRow.data,
...data
}
};
},
onFormSuccess: (row: any) => table.updateRecord(row)
});
const editCell = useCallback(
(row: any, col: any) => {
setSelectedRow(row);
setSelectedFieldNames([col.field]);
editRow.open();
},
[session, editRow]
);
const deleteRow = useDeleteApiFormModal({
url: ApiEndpoints.import_session_row_list,
pk: selectedRow.pk,
title: t`Delete Row`,
onFormSuccess: () => table.refreshTable()
});
const rowErrors = useCallback((row: any) => {
if (!row.errors) {
return [];
}
let errors: string[] = [];
for (const k of Object.keys(row.errors)) {
if (row.errors[k]) {
if (Array.isArray(row.errors[k])) {
row.errors[k].forEach((e: string) => {
errors.push(`${k}: ${e}`);
});
} else {
errors.push(row.errors[k].toString());
}
}
}
return errors;
}, []);
const columns: TableColumn[] = useMemo(() => {
let columns: TableColumn[] = [
{
accessor: 'row_index',
title: t`Row`,
sortable: true,
switchable: false,
render: (row: any) => {
return (
<Group justify="left" gap="xs">
<Text size="sm">{row.row_index}</Text>
{row.complete && <IconCircleCheck color="green" size={16} />}
{!row.complete && row.valid && (
<IconCircleDashedCheck color="blue" size={16} />
)}
{!row.complete && !row.valid && (
<HoverCard openDelay={50} closeDelay={100}>
<HoverCard.Target>
<IconExclamationCircle color="red" size={16} />
</HoverCard.Target>
<HoverCard.Dropdown>
<Stack gap="xs">
<Text>{t`Row contains errors`}:</Text>
{rowErrors(row).map((error: string) => (
<Text size="sm" c="red" key={error}>
{error}
</Text>
))}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
)}
</Group>
);
}
},
...session.mappedFields.map((column: any) => {
return {
accessor: column.field,
title: column.column ?? column.title,
sortable: false,
switchable: true,
render: (row: any) => {
return (
<ImporterDataCell
session={session}
column={column}
row={row}
onEdit={() => editCell(row, column)}
/>
);
}
};
})
];
return columns;
}, [session]);
const rowActions = useCallback(
(record: any) => {
return [
{
title: t`Accept`,
icon: <IconArrowRight />,
color: 'green',
hidden: record.complete || !record.valid,
onClick: () => {
importData([record.pk]);
}
},
RowEditAction({
hidden: record.complete,
onClick: () => {
setSelectedRow(record);
setSelectedFieldNames(
session.mappedFields.map((f: any) => f.field)
);
editRow.open();
}
}),
RowDeleteAction({
onClick: () => {
setSelectedRow(record);
deleteRow.open();
}
})
];
},
[session, importData]
);
const filters: TableFilter[] = useMemo(() => {
return [
{
name: 'valid',
label: t`Valid`,
description: t`Filter by row validation status`,
type: 'boolean'
},
{
name: 'complete',
label: t`Complete`,
description: t`Filter by row completion status`,
type: 'boolean'
}
];
}, []);
const tableActions = useMemo(() => {
// Can only "import" valid (and incomplete) rows
const canImport: boolean =
table.hasSelectedRecords &&
table.selectedRecords.every((row: any) => row.valid && !row.complete);
return [
<ActionButton
disabled={!canImport}
icon={<IconArrowRight />}
color="green"
tooltip={t`Import selected rows`}
onClick={() => {
importData(table.selectedRecords.map((row: any) => row.pk));
}}
/>
];
}, [table.hasSelectedRecords, table.selectedRecords]);
return (
<>
{editRow.modal}
{deleteRow.modal}
<Stack gap="xs">
<Paper shadow="xs" p="xs">
<Group grow justify="apart">
<Text size="lg">{t`Processing Data`}</Text>
<Space />
<ProgressBar
maximum={session.rowCount}
value={session.completedRowCount}
progressLabel
/>
<Space />
</Group>
</Paper>
<InvenTreeTable
tableState={table}
columns={columns}
url={apiUrl(ApiEndpoints.import_session_row_list)}
props={{
params: {
session: session.sessionId
},
rowActions: rowActions,
tableActions: tableActions,
tableFilters: filters,
enableColumnSwitching: true,
enableColumnCaching: false,
enableSelection: true,
enableBulkDelete: true,
afterBulkDelete: () => {
session.refreshSession();
}
}}
/>
</Stack>
</>
);
}

View File

@ -0,0 +1,234 @@
import { t } from '@lingui/macro';
import {
Alert,
Button,
Group,
Paper,
Select,
Space,
Stack,
Table,
Text
} from '@mantine/core';
import { IconCheck } from '@tabler/icons-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ImportSessionState } from '../../hooks/UseImportSession';
import { apiUrl } from '../../states/ApiState';
import { StandaloneField } from '../forms/StandaloneField';
import { ApiFormFieldType } from '../forms/fields/ApiFormField';
function ImporterColumn({ column, options }: { column: any; options: any[] }) {
const [errorMessage, setErrorMessage] = useState<string>('');
const [selectedColumn, setSelectedColumn] = useState<string>(
column.column ?? ''
);
useEffect(() => {
setSelectedColumn(column.column ?? '');
}, [column.column]);
const onChange = useCallback(
(value: any) => {
api
.patch(
apiUrl(ApiEndpoints.import_session_column_mapping_list, column.pk),
{
column: value || ''
}
)
.then((response) => {
setSelectedColumn(response.data?.column ?? value);
setErrorMessage('');
})
.catch((error) => {
const data = error.response.data;
setErrorMessage(
data.column ?? data.non_field_errors ?? t`An error occurred`
);
});
},
[column]
);
return (
<Select
error={errorMessage}
clearable
searchable
placeholder={t`Select column, or leave blank to ignore this field.`}
label={undefined}
data={options}
value={selectedColumn}
onChange={onChange}
/>
);
}
function ImporterDefaultField({
fieldName,
session
}: {
fieldName: string;
session: ImportSessionState;
}) {
const onChange = useCallback(
(value: any) => {
// Update the default value for the field
let defaults = {
...session.fieldDefaults,
[fieldName]: value
};
api
.patch(apiUrl(ApiEndpoints.import_session_list, session.sessionId), {
field_defaults: defaults
})
.then((response: any) => {
session.setSessionData(response.data);
})
.catch(() => {
// TODO: Error message?
});
},
[fieldName, session, session.fieldDefaults]
);
const fieldDef: ApiFormFieldType = useMemo(() => {
let def: any = session.availableFields[fieldName];
if (def) {
def = {
...def,
value: session.fieldDefaults[fieldName],
field_type: def.type,
description: def.help_text,
onValueChange: onChange
};
}
return def;
}, [fieldName, session.availableFields, session.fieldDefaults]);
return (
fieldDef && <StandaloneField fieldDefinition={fieldDef} hideLabels={true} />
);
}
function ImporterColumnTableRow({
session,
column,
options
}: {
session: ImportSessionState;
column: any;
options: any;
}) {
return (
<Table.Tr key={column.label ?? column.field}>
<Table.Td>
<Group gap="xs">
<Text fw={column.required ? 700 : undefined}>
{column.label ?? column.field}
</Text>
{column.required && (
<Text c="red" fw={700}>
*
</Text>
)}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm">{column.description}</Text>
</Table.Td>
<Table.Td>
<ImporterColumn column={column} options={options} />
</Table.Td>
<Table.Td>
<ImporterDefaultField fieldName={column.field} session={session} />
</Table.Td>
</Table.Tr>
);
}
export default function ImporterColumnSelector({
session
}: {
session: ImportSessionState;
}) {
const [errorMessage, setErrorMessage] = useState<string>('');
const acceptMapping = useCallback(() => {
const url = apiUrl(
ApiEndpoints.import_session_accept_fields,
session.sessionId
);
api
.post(url)
.then(() => {
session.refreshSession();
})
.catch((error) => {
setErrorMessage(error.response?.data?.error ?? t`An error occurred`);
});
}, [session.sessionId]);
const columnOptions: any[] = useMemo(() => {
return [
{ value: '', label: t`Ignore this field` },
...session.availableColumns.map((column: any) => {
return {
value: column,
label: column
};
})
];
}, [session.availableColumns]);
return (
<Stack gap="xs">
<Paper shadow="xs" p="xs">
<Group grow justify="apart">
<Text size="lg">{t`Mapping data columns to database fields`}</Text>
<Space />
<Button color="green" variant="filled" onClick={acceptMapping}>
<Group>
<IconCheck />
{t`Accept Column Mapping`}
</Group>
</Button>
</Group>
</Paper>
{errorMessage && (
<Alert color="red" title={t`Error`}>
<Text>{errorMessage}</Text>
</Alert>
)}
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>{t`Database Field`}</Table.Th>
<Table.Th>{t`Field Description`}</Table.Th>
<Table.Th>{t`Imported Column`}</Table.Th>
<Table.Th>{t`Default Value`}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{session.columnMappings.map((column: any) => {
return (
<ImporterColumnTableRow
session={session}
column={column}
options={columnOptions}
/>
);
})}
</Table.Tbody>
</Table>
</Stack>
);
}

View File

@ -0,0 +1,170 @@
import { t } from '@lingui/macro';
import {
Alert,
Button,
Divider,
Drawer,
Group,
Loader,
LoadingOverlay,
Paper,
Space,
Stack,
Stepper,
Text
} from '@mantine/core';
import { IconCheck } from '@tabler/icons-react';
import { ReactNode, useMemo } from 'react';
import { ModelType } from '../../enums/ModelType';
import { useImportSession } from '../../hooks/UseImportSession';
import useStatusCodes from '../../hooks/UseStatusCodes';
import { StylishText } from '../items/StylishText';
import ImporterDataSelector from './ImportDataSelector';
import ImporterColumnSelector from './ImporterColumnSelector';
import ImporterImportProgress from './ImporterImportProgress';
/*
* Stepper component showing the current step of the data import process.
*/
function ImportDrawerStepper({ currentStep }: { currentStep: number }) {
/* TODO: Enhance this with:
* - Custom icons
* - Loading indicators for "background" states
*/
return (
<Stepper
active={currentStep}
onStepClick={undefined}
allowNextStepsSelect={false}
iconSize={20}
size="xs"
>
<Stepper.Step label={t`Upload File`} />
<Stepper.Step label={t`Map Columns`} />
<Stepper.Step label={t`Import Data`} />
<Stepper.Step label={t`Process Data`} />
<Stepper.Step label={t`Complete Import`} />
</Stepper>
);
}
export default function ImporterDrawer({
sessionId,
opened,
onClose
}: {
sessionId: number;
opened: boolean;
onClose: () => void;
}) {
const session = useImportSession({ sessionId: sessionId });
const importSessionStatus = useStatusCodes({
modelType: ModelType.importsession
});
// Map from import steps to stepper steps
const currentStep = useMemo(() => {
switch (session.status) {
default:
case importSessionStatus.INITIAL:
return 0;
case importSessionStatus.MAPPING:
return 1;
case importSessionStatus.IMPORTING:
return 2;
case importSessionStatus.PROCESSING:
return 3;
case importSessionStatus.COMPLETE:
return 4;
}
}, [session.status]);
const widget = useMemo(() => {
if (session.sessionQuery.isLoading || session.sessionQuery.isFetching) {
return <Loader />;
}
switch (session.status) {
case importSessionStatus.INITIAL:
return <Text>Initial : TODO</Text>;
case importSessionStatus.MAPPING:
return <ImporterColumnSelector session={session} />;
case importSessionStatus.IMPORTING:
return <ImporterImportProgress session={session} />;
case importSessionStatus.PROCESSING:
return <ImporterDataSelector session={session} />;
case importSessionStatus.COMPLETE:
return (
<Stack gap="xs">
<Alert
color="green"
title={t`Import Complete`}
icon={<IconCheck />}
>
{t`Data has been imported successfully`}
</Alert>
<Button color="blue" onClick={onClose}>{t`Close`}</Button>
</Stack>
);
default:
return (
<Stack gap="xs">
<Alert color="red" title={t`Unknown Status`} icon={<IconCheck />}>
{t`Import session has unknown status`}: {session.status}
</Alert>
<Button color="red" onClick={onClose}>{t`Close`}</Button>
</Stack>
);
}
}, [session.status, session.sessionQuery]);
const title: ReactNode = useMemo(() => {
return (
<Stack gap="xs" style={{ width: '100%' }}>
<Group
gap="xs"
wrap="nowrap"
justify="space-apart"
grow
preventGrowOverflow={false}
>
<StylishText size="lg">
{session.sessionData?.statusText ?? t`Importing Data`}
</StylishText>
<ImportDrawerStepper currentStep={currentStep} />
<Space />
</Group>
<Divider />
</Stack>
);
}, [session.sessionData]);
return (
<Drawer
position="bottom"
size="80%"
title={title}
opened={opened}
onClose={onClose}
withCloseButton={true}
closeOnEscape={false}
closeOnClickOutside={false}
styles={{
header: {
width: '100%'
},
title: {
width: '100%'
}
}}
>
<Stack gap="xs">
<LoadingOverlay visible={session.sessionQuery.isFetching} />
<Paper p="md">{session.sessionQuery.isFetching || widget}</Paper>
</Stack>
</Drawer>
);
}

View File

@ -0,0 +1,45 @@
import { t } from '@lingui/macro';
import { Center, Container, Loader, Stack, Text } from '@mantine/core';
import { useInterval } from '@mantine/hooks';
import { useEffect } from 'react';
import { ModelType } from '../../enums/ModelType';
import { ImportSessionState } from '../../hooks/UseImportSession';
import useStatusCodes from '../../hooks/UseStatusCodes';
import { StylishText } from '../items/StylishText';
export default function ImporterImportProgress({
session
}: {
session: ImportSessionState;
}) {
const importSessionStatus = useStatusCodes({
modelType: ModelType.importsession
});
// Periodically refresh the import session data
const interval = useInterval(() => {
if (session.status == importSessionStatus.IMPORTING) {
session.refreshSession();
}
}, 1000);
useEffect(() => {
interval.start();
return interval.stop;
}, []);
return (
<Center>
<Container>
<Stack gap="xs">
<StylishText size="lg">{t`Importing Records`}</StylishText>
<Loader />
<Text size="lg">
{t`Imported rows`}: {session.sessionData.row_count}
</Text>
</Stack>
</Container>
</Center>
);
}

View File

@ -6,6 +6,7 @@ import {
Menu,
Tooltip
} from '@mantine/core';
import { modals } from '@mantine/modals';
import {
IconCopy,
IconEdit,
@ -16,9 +17,11 @@ import {
} from '@tabler/icons-react';
import { ReactNode, useMemo } from 'react';
import { ModelType } from '../../enums/ModelType';
import { identifierString } from '../../functions/conversion';
import { InvenTreeIcon } from '../../functions/icons';
import { notYetImplemented } from '../../functions/notifications';
import { InvenTreeQRCode } from './QRCode';
export type ActionDropdownItem = {
icon: ReactNode;
@ -39,12 +42,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 +63,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>
@ -66,7 +71,7 @@ export function ActionDropdown({
<ActionIcon
size="lg"
radius="sm"
variant="outline"
variant="transparent"
disabled={disabled}
aria-label={menuName}
>
@ -84,7 +89,11 @@ export function ActionDropdown({
{...action.indicator}
key={action.name}
>
<Tooltip label={action.tooltip} hidden={!action.tooltip}>
<Tooltip
label={action.tooltip}
hidden={!action.tooltip}
position="left"
>
<Menu.Item
aria-label={id}
leftSection={action.icon}
@ -126,11 +135,20 @@ export function BarcodeActionDropdown({
// Common action button for viewing a barcode
export function ViewBarcodeAction({
hidden = false,
onClick
model,
pk
}: {
hidden?: boolean;
onClick?: () => void;
model: ModelType;
pk: number;
}): ActionDropdownItem {
const onClick = () => {
modals.open({
title: t`View Barcode`,
children: <InvenTreeQRCode model={model} pk={pk} />
});
};
return {
icon: <IconQrcode />,
name: t`View`,
@ -215,6 +233,24 @@ export function DeleteItemAction({
};
}
export function HoldItemAction({
hidden = false,
tooltip,
onClick
}: {
hidden?: boolean;
tooltip?: string;
onClick?: () => void;
}): ActionDropdownItem {
return {
icon: <InvenTreeIcon icon="hold" iconProps={{ color: 'orange' }} />,
name: t`Hold`,
tooltip: tooltip ?? t`Hold`,
onClick: onClick,
hidden: hidden
};
}
export function CancelItemAction({
hidden = false,
tooltip,

View File

@ -0,0 +1,13 @@
import { style } from '@vanilla-extract/css';
export const icon = style({
fontStyle: 'normal',
fontWeight: 'normal',
fontVariant: 'normal',
textTransform: 'none',
lineHeight: 1,
width: 'fit-content',
// Better font rendering
WebkitFontSmoothing: 'antialiased',
MozOsxFontSmoothing: 'grayscale'
});

View File

@ -0,0 +1,27 @@
import { useIconState } from '../../states/IconState';
import * as classes from './ApiIcon.css';
type ApiIconProps = {
name: string;
size?: number;
};
export const ApiIcon = ({ name: _name, size = 22 }: ApiIconProps) => {
const [iconPackage, name, variant] = _name.split(':');
const icon = useIconState(
(s) => s.packagesMap[iconPackage]?.['icons'][name]?.['variants'][variant]
);
const unicode = icon ? String.fromCodePoint(parseInt(icon, 16)) : '';
return (
<i
className={classes.icon}
style={{
fontFamily: `inventree-icon-font-${iconPackage}`,
fontSize: size
}}
>
{unicode}
</i>
);
};

View File

@ -1,6 +1,6 @@
import { Trans } from '@lingui/macro';
import { Carousel } from '@mantine/carousel';
import { Anchor, Button, Paper, Text, Title, rem } from '@mantine/core';
import { Anchor, Button, Paper, Text, Title } from '@mantine/core';
import { DocumentationLinkItem } from './DocumentationLinks';
import * as classes from './GettingStartedCarousel.css';

View File

@ -0,0 +1,130 @@
import { Trans, t } from '@lingui/macro';
import {
Box,
Code,
Group,
Image,
Select,
Skeleton,
Stack,
Text
} from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import QR from 'qrcode';
import { useEffect, useMemo, useState } from 'react';
import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { apiUrl } from '../../states/ApiState';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { CopyButton } from '../buttons/CopyButton';
type QRCodeProps = {
ecl?: 'L' | 'M' | 'Q' | 'H';
margin?: number;
data?: string;
};
export const QRCode = ({ data, ecl = 'Q', margin = 1 }: QRCodeProps) => {
const [qrCode, setQRCode] = useState<string>();
useEffect(() => {
if (!data) return setQRCode(undefined);
QR.toString(data, { errorCorrectionLevel: ecl, type: 'svg', margin }).then(
(svg) => {
setQRCode(`data:image/svg+xml;utf8,${encodeURIComponent(svg)}`);
}
);
}, [data, ecl]);
return (
<Box>
{qrCode ? (
<Image src={qrCode} alt="QR Code" />
) : (
<Skeleton height={500} />
)}
</Box>
);
};
type InvenTreeQRCodeProps = {
model: ModelType;
pk: number;
showEclSelector?: boolean;
} & Omit<QRCodeProps, 'data'>;
export const InvenTreeQRCode = ({
showEclSelector = true,
model,
pk,
ecl: eclProp = 'Q',
...props
}: InvenTreeQRCodeProps) => {
const settings = useGlobalSettingsState();
const [ecl, setEcl] = useState(eclProp);
useEffect(() => {
if (eclProp) setEcl(eclProp);
}, [eclProp]);
const { data } = useQuery({
queryKey: ['qr-code', model, pk],
queryFn: async () => {
const res = await api.post(apiUrl(ApiEndpoints.generate_barcode), {
model,
pk
});
return res.data?.barcode as string;
}
});
const eclOptions = useMemo(
() => [
{ value: 'L', label: t`Low (7%)` },
{ value: 'M', label: t`Medium (15%)` },
{ value: 'Q', label: t`Quartile (25%)` },
{ value: 'H', label: t`High (30%)` }
],
[]
);
return (
<Stack>
<QRCode data={data} ecl={ecl} {...props} />
{data && settings.getSetting('BARCODE_SHOW_TEXT', 'false') && (
<Group
justify={showEclSelector ? 'space-between' : 'center'}
align="flex-start"
px={16}
>
<Stack gap={4} pt={2}>
<Text size="sm" fw={500}>
<Trans>Barcode Data:</Trans>
</Text>
<Group>
<Code>{data}</Code>
<CopyButton value={data} />
</Group>
</Stack>
{showEclSelector && (
<Select
allowDeselect={false}
label={t`Select Error Correction Level`}
value={ecl}
onChange={(v) =>
setEcl(v as Exclude<QRCodeProps['ecl'], undefined>)
}
data={eclOptions}
/>
)}
</Group>
)}
</Stack>
);
};

View File

@ -35,7 +35,7 @@ export function QrCodeModal({
key: 'camId',
defaultValue: null
});
const [ScanningEnabled, setIsScanning] = useState<boolean>(false);
const [scanningEnabled, setScanningEnabled] = useState<boolean>(false);
const [wasAutoPaused, setWasAutoPaused] = useState<boolean>(false);
const documentState = useDocumentVisibility();
@ -48,7 +48,7 @@ export function QrCodeModal({
// Stop/star when leaving or reentering page
useEffect(() => {
if (ScanningEnabled && documentState === 'hidden') {
if (scanningEnabled && documentState === 'hidden') {
stopScanning();
setWasAutoPaused(true);
} else if (wasAutoPaused && documentState === 'visible') {
@ -128,12 +128,12 @@ export function QrCodeModal({
icon: <IconX />
});
});
setIsScanning(true);
setScanningEnabled(true);
}
}
function stopScanning() {
if (qrCodeScanner && ScanningEnabled) {
if (qrCodeScanner && scanningEnabled) {
qrCodeScanner.stop().catch((err: string) => {
showNotification({
title: t`Error while stopping`,
@ -142,7 +142,7 @@ export function QrCodeModal({
icon: <IconX />
});
});
setIsScanning(false);
setScanningEnabled(false);
}
}
@ -151,7 +151,7 @@ export function QrCodeModal({
<Group>
<Text size="sm">{camId?.label}</Text>
<Space style={{ flex: 1 }} />
<Badge>{ScanningEnabled ? t`Scanning` : t`Not scanning`}</Badge>
<Badge>{scanningEnabled ? t`Scanning` : t`Not scanning`}</Badge>
</Group>
<Container px={0} id="reader" w={'100%'} mih="300px" />
{!camId ? (
@ -164,14 +164,14 @@ export function QrCodeModal({
<Button
style={{ flex: 1 }}
onClick={() => startScanning()}
disabled={camId != undefined && ScanningEnabled}
disabled={camId != undefined && scanningEnabled}
>
<Trans>Start scanning</Trans>
</Button>
<Button
style={{ flex: 1 }}
onClick={() => stopScanning()}
disabled={!ScanningEnabled}
disabled={!scanningEnabled}
>
<Trans>Stop scanning</Trans>
</Button>

View File

@ -91,7 +91,7 @@ export function ServerInfoModal({
</OnlyStaff>
</Table.Td>
</Table.Tr>
{server.worker_running != true && (
{server?.worker_running == false && (
<Table.Tr>
<Table.Td>
<Trans>Background Worker</Trans>
@ -103,7 +103,7 @@ export function ServerInfoModal({
</Table.Td>
</Table.Tr>
)}
{server.email_configured != true && (
{server?.email_configured == false && (
<Table.Tr>
<Table.Td>
<Trans>Email Settings</Trans>

View File

@ -14,6 +14,7 @@ import { identifierString } from '../../functions/conversion';
import { navigateToLink } from '../../functions/navigation';
export type Breadcrumb = {
icon?: React.ReactNode;
name: string;
url: string;
};
@ -69,7 +70,10 @@ export function BreadcrumbList({
navigateToLink(breadcrumb.url, navigate, event)
}
>
<Text size="sm">{breadcrumb.name}</Text>
<Group gap={4}>
{breadcrumb.icon}
<Text size="sm">{breadcrumb.name}</Text>
</Group>
</Anchor>
);
})}

View File

@ -2,7 +2,7 @@ import { ActionIcon, Container, Group, Indicator, Tabs } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconBell, IconSearch } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { ReactNode, useEffect, useMemo, useState } from 'react';
import { useMatch, useNavigate } from 'react-router-dom';
import { api } from '../../App';
@ -47,6 +47,10 @@ export function Header() {
queryKey: ['notification-count'],
enabled: isLoggedIn(),
queryFn: async () => {
if (!isLoggedIn()) {
return null;
}
try {
const params = {
params: {
@ -60,14 +64,13 @@ export function Header() {
return null;
});
setNotificationCount(response?.data?.count ?? 0);
return response?.data;
return response?.data ?? null;
} catch (error) {
return error;
return null;
}
},
refetchInterval: 30000,
refetchOnMount: true,
refetchOnWindowFocus: false
refetchOnMount: true
});
// Sync Navigation Drawer state with zustand
@ -129,10 +132,35 @@ export function Header() {
}
function NavTabs() {
const user = useUserState();
const navigate = useNavigate();
const match = useMatch(':tabName/*');
const tabValue = match?.params.tabName;
const tabs: ReactNode[] = useMemo(() => {
let _tabs: ReactNode[] = [];
mainNavTabs.forEach((tab) => {
if (tab.role && !user.hasViewRole(tab.role)) {
return;
}
_tabs.push(
<Tabs.Tab
value={tab.name}
key={tab.name}
onClick={(event: any) =>
navigateToLink(`/${tab.name}`, navigate, event)
}
>
{tab.text}
</Tabs.Tab>
);
});
return _tabs;
}, [mainNavTabs, user]);
return (
<Tabs
defaultValue="home"
@ -143,19 +171,7 @@ function NavTabs() {
}}
value={tabValue}
>
<Tabs.List>
{mainNavTabs.map((tab) => (
<Tabs.Tab
value={tab.name}
key={tab.name}
onClick={(event: any) =>
navigateToLink(`/${tab.name}`, navigate, event)
}
>
{tab.text}
</Tabs.Tab>
))}
</Tabs.List>
<Tabs.List>{tabs.map((tab) => tab)}</Tabs.List>
</Tabs>
);
}

View File

@ -0,0 +1,31 @@
import { LoadingOverlay } from '@mantine/core';
import { useUserState } from '../../states/UserState';
import ClientError from '../errors/ClientError';
import ServerError from '../errors/ServerError';
export default function InstanceDetail({
status,
loading,
children
}: {
status: number;
loading: boolean;
children: React.ReactNode;
}) {
const user = useUserState();
if (loading || !user.isLoggedIn()) {
return <LoadingOverlay />;
}
if (status >= 500) {
return <ServerError status={status} />;
}
if (status >= 400) {
return <ClientError status={status} />;
}
return <>{children}</>;
}

View File

@ -15,7 +15,6 @@ import {
import {
IconChevronDown,
IconChevronRight,
IconPoint,
IconSitemap
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
@ -28,6 +27,7 @@ import { ModelType } from '../../enums/ModelType';
import { navigateToLink } from '../../functions/navigation';
import { getDetailUrl } from '../../functions/urls';
import { apiUrl } from '../../states/ApiState';
import { ApiIcon } from '../items/ApiIcon';
import { StylishText } from '../items/StylishText';
/*
@ -100,7 +100,12 @@ export default function NavigationTree({
let node = {
...query.data[ii],
children: [],
label: query.data[ii].name,
label: (
<Group gap="xs">
<ApiIcon name={query.data[ii].icon} />
{query.data[ii].name}
</Group>
),
value: query.data[ii].pk.toString(),
selected: query.data[ii].pk === selectedId
};
@ -157,9 +162,7 @@ export default function NavigationTree({
) : (
<IconChevronRight />
)
) : (
<IconPoint />
)}
) : null}
</ActionIcon>
<Anchor
onClick={(event: any) => follow(payload.node, event)}

View File

@ -7,7 +7,6 @@ import {
Drawer,
Group,
Loader,
LoadingOverlay,
Space,
Stack,
Text,
@ -21,6 +20,7 @@ import { Link, useNavigate } from 'react-router-dom';
import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { StylishText } from '../items/StylishText';
/**
@ -33,10 +33,12 @@ export function NotificationDrawer({
opened: boolean;
onClose: () => void;
}) {
const { isLoggedIn } = useUserState();
const navigate = useNavigate();
const notificationQuery = useQuery({
enabled: opened,
enabled: opened && isLoggedIn(),
queryKey: ['notifications', opened],
queryFn: async () =>
api
@ -50,8 +52,7 @@ export function NotificationDrawer({
.catch((error) => {
return error;
}),
refetchOnMount: false,
refetchOnWindowFocus: false
refetchOnMount: false
});
const hasNotifications: boolean = useMemo(() => {

View File

@ -7,6 +7,7 @@ import { Breadcrumb, BreadcrumbList } from './BreadcrumbList';
interface PageDetailInterface {
title?: string;
icon?: ReactNode;
subtitle?: string;
imageUrl?: string;
detail?: ReactNode;
@ -24,6 +25,7 @@ interface PageDetailInterface {
*/
export function PageDetail({
title,
icon,
subtitle,
detail,
badges,
@ -50,9 +52,12 @@ export function PageDetail({
<Stack gap="xs">
{title && <StylishText size="lg">{title}</StylishText>}
{subtitle && (
<Text size="md" truncate>
{subtitle}
</Text>
<Group gap="xs">
{icon}
<Text size="md" truncate>
{subtitle}
</Text>
</Group>
)}
</Stack>
</Group>

View File

@ -119,6 +119,7 @@ function BasePanelGroup({
label={panel.label}
key={panel.name}
disabled={expanded}
position="right"
>
<Tabs.Tab
p="xs"

View File

@ -33,6 +33,7 @@ import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { navigateToLink } from '../../functions/navigation';
import { apiUrl } from '../../states/ApiState';
import { useUserSettingsState } from '../../states/SettingsState';
import { useUserState } from '../../states/UserState';
@ -58,7 +59,7 @@ function QueryResultGroup({
}: {
query: SearchQuery;
onRemove: (query: ModelType) => void;
onResultClick: (query: ModelType, pk: number) => void;
onResultClick: (query: ModelType, pk: number, event: any) => void;
}) {
if (query.results.count == 0) {
return null;
@ -92,7 +93,9 @@ function QueryResultGroup({
<Stack>
{query.results.results.map((result: any) => (
<Anchor
onClick={() => onResultClick(query.model, result.pk)}
onClick={(event: any) =>
onResultClick(query.model, result.pk, event)
}
key={result.pk}
>
<RenderInstance instance={result} model={query.model} />
@ -272,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
@ -316,11 +318,20 @@ export function SearchDrawer({
const navigate = useNavigate();
// Callback when one of the search results is clicked
function onResultClick(query: ModelType, pk: number) {
closeDrawer();
function onResultClick(query: ModelType, pk: number, event: any) {
const targetModel = ModelInformationDict[query];
if (targetModel.url_detail == undefined) return;
navigate(targetModel.url_detail.replace(':pk', pk.toString()));
if (targetModel.url_detail == undefined) {
return;
}
if (event?.ctrlKey || event?.shiftKey) {
// Keep the drawer open in this condition
} else {
closeDrawer();
}
let url = targetModel.url_detail.replace(':pk', pk.toString());
navigateToLink(url, navigate, event);
}
return (
@ -400,7 +411,9 @@ export function SearchDrawer({
key={idx}
query={query}
onRemove={(query) => removeResults(query)}
onResultClick={(query, pk) => onResultClick(query, pk)}
onResultClick={(query, pk, event) =>
onResultClick(query, pk, event)
}
/>
))}
</Stack>

View File

@ -46,3 +46,9 @@ export function RenderBuildLine({
/>
);
}
export function RenderBuildItem({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
return <RenderInlineModel primary={instance.pk} />;
}

View File

@ -14,3 +14,11 @@ export function RenderProjectCode({
)
);
}
export function RenderImportSession({
instance
}: {
instance: any;
}): ReactNode {
return instance && <RenderInlineModel primary={instance.data_file} />;
}

View File

@ -1,11 +1,14 @@
import { t } from '@lingui/macro';
import { Alert, Anchor, Group, Space, Text } from '@mantine/core';
import { Alert, Anchor, Group, Skeleton, Space, Text } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { ReactNode, useCallback } from 'react';
import { api } from '../../App';
import { ModelType } from '../../enums/ModelType';
import { navigateToLink } from '../../functions/navigation';
import { apiUrl } from '../../states/ApiState';
import { Thumbnail } from '../images/Thumbnail';
import { RenderBuildLine, RenderBuildOrder } from './Build';
import { RenderBuildItem, RenderBuildLine, RenderBuildOrder } from './Build';
import {
RenderAddress,
RenderCompany,
@ -13,10 +16,12 @@ import {
RenderManufacturerPart,
RenderSupplierPart
} from './Company';
import { RenderProjectCode } from './Generic';
import { RenderImportSession, RenderProjectCode } from './Generic';
import { ModelInformationDict } from './ModelType';
import {
RenderPurchaseOrder,
RenderReturnOrder,
RenderReturnOrderLineItem,
RenderSalesOrder,
RenderSalesOrderShipment
} from './Order';
@ -33,7 +38,7 @@ import {
RenderStockLocation,
RenderStockLocationType
} from './Stock';
import { RenderOwner, RenderUser } from './User';
import { RenderGroup, RenderOwner, RenderUser } from './User';
type EnumDictionary<T extends string | symbol | number, U> = {
[K in T]: U;
@ -43,6 +48,7 @@ export interface InstanceRenderInterface {
instance: any;
link?: boolean;
navigate?: any;
showSecondary?: boolean;
}
/**
@ -55,6 +61,7 @@ const RendererLookup: EnumDictionary<
[ModelType.address]: RenderAddress,
[ModelType.build]: RenderBuildOrder,
[ModelType.buildline]: RenderBuildLine,
[ModelType.builditem]: RenderBuildItem,
[ModelType.company]: RenderCompany,
[ModelType.contact]: RenderContact,
[ModelType.manufacturerpart]: RenderManufacturerPart,
@ -65,8 +72,9 @@ const RendererLookup: EnumDictionary<
[ModelType.parttesttemplate]: RenderPartTestTemplate,
[ModelType.projectcode]: RenderProjectCode,
[ModelType.purchaseorder]: RenderPurchaseOrder,
[ModelType.purchaseorderline]: RenderPurchaseOrder,
[ModelType.purchaseorderlineitem]: RenderPurchaseOrder,
[ModelType.returnorder]: RenderReturnOrder,
[ModelType.returnorderlineitem]: RenderReturnOrderLineItem,
[ModelType.salesorder]: RenderSalesOrder,
[ModelType.salesordershipment]: RenderSalesOrderShipment,
[ModelType.stocklocation]: RenderStockLocation,
@ -75,6 +83,8 @@ const RendererLookup: EnumDictionary<
[ModelType.stockhistory]: RenderStockItem,
[ModelType.supplierpart]: RenderSupplierPart,
[ModelType.user]: RenderUser,
[ModelType.group]: RenderGroup,
[ModelType.importsession]: RenderImportSession,
[ModelType.reporttemplate]: RenderReportTemplate,
[ModelType.labeltemplate]: RenderLabelTemplate,
[ModelType.pluginconfig]: RenderPlugin
@ -103,25 +113,65 @@ export function RenderInstance(props: RenderInstanceProps): ReactNode {
return <RenderComponent {...props} />;
}
export function RenderRemoteInstance({
model,
pk
}: {
model: ModelType;
pk: number;
}): ReactNode {
const { data, isLoading, isFetching } = useQuery({
queryKey: ['model', model, pk],
queryFn: async () => {
const url = apiUrl(ModelInformationDict[model].api_endpoint, pk);
return api
.get(url)
.then((response) => response.data)
.catch(() => null);
}
});
if (isLoading || isFetching) {
return <Skeleton />;
}
if (!data) {
return (
<Text>
{model}: {pk}
</Text>
);
}
return <RenderInstance model={model} instance={data} />;
}
/**
* Helper function for rendering an inline model in a consistent style
*/
export function RenderInlineModel({
primary,
secondary,
prefix,
suffix,
image,
labels,
url,
navigate
navigate,
showSecondary = true,
tooltip
}: {
primary: string;
secondary?: string;
showSecondary?: boolean;
prefix?: ReactNode;
suffix?: ReactNode;
image?: string;
labels?: string[];
url?: string;
navigate?: any;
tooltip?: string;
}): ReactNode {
// TODO: Handle labels
@ -135,9 +185,10 @@ export function RenderInlineModel({
);
return (
<Group gap="xs" justify="space-between" wrap="nowrap">
<Group gap="xs" justify="space-between" wrap="nowrap" title={tooltip}>
<Group gap="xs" justify="left" wrap="nowrap">
{image && Thumbnail({ src: image, size: 18 })}
{prefix}
{image && <Thumbnail src={image} size={18} />}
{url ? (
<Anchor href={url} onClick={(event: any) => onClick(event)}>
<Text size="sm">{primary}</Text>
@ -145,7 +196,7 @@ export function RenderInlineModel({
) : (
<Text size="sm">{primary}</Text>
)}
{secondary && <Text size="xs">{secondary}</Text>}
{showSecondary && secondary && <Text size="xs">{secondary}</Text>}
</Group>
{suffix && (
<>

View File

@ -113,6 +113,11 @@ export const ModelInformationDict: ModelDict = {
cui_detail: '/build/line/:pk/',
api_endpoint: ApiEndpoints.build_line_list
},
builditem: {
label: t`Build Item`,
label_multiple: t`Build Items`,
api_endpoint: ApiEndpoints.build_item_list
},
company: {
label: t`Company`,
label_multiple: t`Companies`,
@ -138,7 +143,7 @@ export const ModelInformationDict: ModelDict = {
api_endpoint: ApiEndpoints.purchase_order_list,
admin_url: '/order/purchaseorder/'
},
purchaseorderline: {
purchaseorderlineitem: {
label: t`Purchase Order Line`,
label_multiple: t`Purchase Order Lines`,
api_endpoint: ApiEndpoints.purchase_order_line_list
@ -168,6 +173,11 @@ export const ModelInformationDict: ModelDict = {
api_endpoint: ApiEndpoints.return_order_list,
admin_url: '/order/returnorder/'
},
returnorderlineitem: {
label: t`Return Order Line Item`,
label_multiple: t`Return Order Line Items`,
api_endpoint: ApiEndpoints.return_order_line_list
},
address: {
label: t`Address`,
label_multiple: t`Addresses`,
@ -196,6 +206,21 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/user/:pk/',
api_endpoint: ApiEndpoints.user_list
},
group: {
label: t`Group`,
label_multiple: t`Groups`,
url_overview: '/user/group',
url_detail: '/user/group-:pk',
api_endpoint: ApiEndpoints.group_list,
admin_url: '/auth/group/'
},
importsession: {
label: t`Import Session`,
label_multiple: t`Import Sessions`,
url_overview: '/import',
url_detail: '/import/:pk/',
api_endpoint: ApiEndpoints.import_session_list
},
labeltemplate: {
label: t`Label Template`,
label_multiple: t`Label Templates`,

View File

@ -62,6 +62,23 @@ export function RenderReturnOrder(
);
}
export function RenderReturnOrderLineItem(
props: Readonly<InstanceRenderInterface>
): ReactNode {
const { instance } = props;
return (
<RenderInlineModel
{...props}
primary={instance.reference}
suffix={StatusRenderer({
status: instance.outcome,
type: ModelType.returnorderlineitem
})}
/>
);
}
/**
* Inline rendering of a single SalesOrder instance
*/

View File

@ -1,8 +1,10 @@
import { t } from '@lingui/macro';
import { Badge } from '@mantine/core';
import { ReactNode } from 'react';
import { ModelType } from '../../enums/ModelType';
import { getDetailUrl } from '../../functions/urls';
import { ApiIcon } from '../items/ApiIcon';
import { InstanceRenderInterface, RenderInlineModel } from './Instance';
/**
@ -12,14 +14,35 @@ export function RenderPart(
props: Readonly<InstanceRenderInterface>
): ReactNode {
const { instance } = props;
const stock = t`Stock` + `: ${instance.in_stock}`;
let badgeText = '';
let badgeColor = '';
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}
/>
@ -33,12 +56,18 @@ export function RenderPartCategory(
props: Readonly<InstanceRenderInterface>
): ReactNode {
const { instance } = props;
const lvl = '-'.repeat(instance.level || 0);
return (
<RenderInlineModel
{...props}
primary={`${lvl} ${instance.name}`}
tooltip={instance.pathstring}
prefix={
<>
<div style={{ width: 10 * (instance.level || 0) }}></div>
{instance.icon && <ApiIcon name={instance.icon} />}
</>
}
primary={instance.name}
secondary={instance.description}
url={
props.link

View File

@ -2,11 +2,13 @@ import { Badge, Center, MantineSize } from '@mantine/core';
import { colorMap } from '../../defaults/backendMappings';
import { ModelType } from '../../enums/ModelType';
import { resolveItem } from '../../functions/conversion';
import { useGlobalStatusState } from '../../states/StatusState';
interface StatusCodeInterface {
key: string;
label: string;
name: string;
color: string;
}
@ -41,7 +43,9 @@ function renderStatusLabel(
}
if (!text) {
console.error(`renderStatusLabel could not find match for code ${key}`);
console.error(
`ERR: renderStatusLabel could not find match for code ${key}`
);
}
// Fallbacks
@ -59,6 +63,49 @@ function renderStatusLabel(
</Badge>
);
}
export function getStatusCodes(type: ModelType | string) {
const statusCodeList = useGlobalStatusState.getState().status;
if (statusCodeList === undefined) {
console.log('StatusRenderer: statusCodeList is undefined');
return null;
}
const statusCodes = statusCodeList[type];
if (statusCodes === undefined) {
console.log('StatusRenderer: statusCodes is undefined');
return null;
}
return statusCodes;
}
/*
* Return the name of a status code, based on the key
*/
export function getStatusCodeName(
type: ModelType | string,
key: string | number
) {
const statusCodes = getStatusCodes(type);
if (!statusCodes) {
return null;
}
for (let name in statusCodes) {
let entry = statusCodes[name];
if (entry.key == key) {
return entry.name;
}
}
return null;
}
/*
* Render the status for a object.
* Uses the values specified in "status_codes.py"
@ -72,14 +119,9 @@ export const StatusRenderer = ({
type: ModelType | string;
options?: RenderStatusLabelOptionsInterface;
}) => {
const statusCodeList = useGlobalStatusState.getState().status;
const statusCodes = getStatusCodes(type);
if (status === undefined || statusCodeList === undefined) {
return null;
}
const statusCodes = statusCodeList[type];
if (statusCodes === undefined) {
if (statusCodes === undefined || statusCodes === null) {
console.warn('StatusRenderer: statusCodes is undefined');
return null;
}
@ -91,10 +133,16 @@ export const StatusRenderer = ({
* Render the status badge in a table
*/
export function TableStatusRenderer(
type: ModelType
type: ModelType,
accessor?: string
): ((record: any) => any) | undefined {
return (record: any) =>
record.status && (
<Center>{StatusRenderer({ status: record.status, type: type })}</Center>
return (record: any) => {
const status = resolveItem(record, accessor ?? 'status');
return (
status && (
<Center>{StatusRenderer({ status: status, type: type })}</Center>
)
);
};
}

View File

@ -3,6 +3,7 @@ import { ReactNode } from 'react';
import { ModelType } from '../../enums/ModelType';
import { getDetailUrl } from '../../functions/urls';
import { ApiIcon } from '../items/ApiIcon';
import { InstanceRenderInterface, RenderInlineModel } from './Instance';
/**
@ -16,6 +17,13 @@ export function RenderStockLocation(
return (
<RenderInlineModel
{...props}
tooltip={instance.pathstring}
prefix={
<>
<div style={{ width: 10 * (instance.level || 0) }}></div>
{instance.icon && <ApiIcon name={instance.icon} />}
</>
}
primary={instance.name}
secondary={instance.description}
url={
@ -36,7 +44,7 @@ export function RenderStockLocationType({
return (
<RenderInlineModel
primary={instance.name}
// TODO: render location icon here too (ref: #7237)
prefix={instance.icon && <ApiIcon name={instance.icon} />}
secondary={instance.description + ` (${instance.location_count})`}
/>
);

View File

@ -10,7 +10,13 @@ export function RenderOwner({
instance && (
<RenderInlineModel
primary={instance.name}
suffix={instance.label == 'group' ? <IconUsersGroup /> : <IconUser />}
suffix={
instance.label == 'group' ? (
<IconUsersGroup size={16} />
) : (
<IconUser size={16} />
)
}
/>
)
);
@ -28,3 +34,9 @@ export function RenderUser({
)
);
}
export function RenderGroup({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
return instance && <RenderInlineModel primary={instance.name} />;
}

View File

@ -49,12 +49,12 @@ export function SettingList({
// Determine the field type of the setting
const fieldType = useMemo(() => {
if (setting?.type != undefined) {
return setting.type;
if (setting?.choices?.length) {
return 'choice';
}
if (setting?.choices != undefined && setting.choices.length > 0) {
return 'choice';
if (setting?.type != undefined) {
return setting.type;
}
return 'string';
@ -136,6 +136,10 @@ export function SettingList({
(s: any) => s.key === key
);
if (settingsState?.settings && !setting) {
console.error(`Setting ${key} not found`);
}
return (
<React.Fragment key={key}>
{setting ? (

View File

@ -17,6 +17,7 @@ export const defaultLocale = 'en';
*/
export const getSupportedLanguages = (): Record<string, string> => {
return {
ar: t`Arabic`,
bg: t`Bulgarian`,
cs: t`Czech`,
da: t`Danish`,
@ -25,6 +26,7 @@ export const getSupportedLanguages = (): Record<string, string> => {
en: t`English`,
es: t`Spanish`,
'es-mx': t`Spanish (Mexican)`,
et: t`Estonian`,
fa: t`Farsi / Persian`,
fi: t`Finnish`,
fr: t`French`,
@ -94,6 +96,12 @@ export function LanguageContext({ children }: { children: JSX.Element }) {
locales.push('en-us');
}
let new_locales = locales.join(', ');
if (new_locales == api.defaults.headers.common['Accept-Language']) {
return;
}
// Update default Accept-Language headers
api.defaults.headers.common['Accept-Language'] = locales.join(', ');

View File

@ -9,11 +9,12 @@ import { ModelType } from '../enums/ModelType';
export const statusCodeList: Record<string, ModelType> = {
BuildStatus: ModelType.build,
PurchaseOrderStatus: ModelType.purchaseorder,
ReturnOrderLineStatus: ModelType.purchaseorderline,
ReturnOrderStatus: ModelType.returnorder,
ReturnOrderLineStatus: ModelType.returnorderlineitem,
SalesOrderStatus: ModelType.salesorder,
StockHistoryCode: ModelType.stockhistory,
StockStatus: ModelType.stockitem
StockStatus: ModelType.stockitem,
DataImportStatusCode: ModelType.importsession
};
/*
@ -25,5 +26,7 @@ export const colorMap: { [key: string]: string } = {
success: 'green',
info: 'cyan',
danger: 'red',
primary: 'blue',
secondary: 'gray',
default: 'gray'
};

View File

@ -117,7 +117,23 @@ export function formatPriceRange(
)}`;
}
interface RenderDateOptionsInterface {
/*
* 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]}`;
}
interface FormatDateOptionsInterface {
showTime?: boolean;
showSeconds?: boolean;
}
@ -128,9 +144,9 @@ interface RenderDateOptionsInterface {
* The provided "date" variable is a string, nominally ISO format e.g. 2022-02-22
* The user-configured setting DATE_DISPLAY_FORMAT determines how the date should be displayed.
*/
export function renderDate(
export function formatDate(
date: string,
options: RenderDateOptionsInterface = {}
options: FormatDateOptionsInterface = {}
) {
if (!date) {
return '-';

View File

@ -3,6 +3,7 @@ import { openContextModal } from '@mantine/modals';
import { DocumentationLinkItem } from '../components/items/DocumentationLinks';
import { StylishText } from '../components/items/StylishText';
import { UserRoles } from '../enums/Roles';
import { IS_DEV_OR_DEMO } from '../main';
export const footerLinks = [
@ -25,12 +26,17 @@ export const footerLinks = [
export const navTabs = [
{ text: <Trans>Home</Trans>, name: 'home' },
{ text: <Trans>Dashboard</Trans>, name: 'dashboard' },
{ text: <Trans>Parts</Trans>, name: 'part' },
{ text: <Trans>Stock</Trans>, name: 'stock' },
{ text: <Trans>Build</Trans>, name: 'build' },
{ text: <Trans>Purchasing</Trans>, name: 'purchasing' },
{ text: <Trans>Sales</Trans>, name: 'sales' }
{ text: <Trans>Parts</Trans>, name: 'part', role: UserRoles.part },
{ text: <Trans>Stock</Trans>, name: 'stock', role: UserRoles.stock },
{ text: <Trans>Build</Trans>, name: 'build', role: UserRoles.build },
{
text: <Trans>Purchasing</Trans>,
name: 'purchasing',
role: UserRoles.purchase_order
},
{ text: <Trans>Sales</Trans>, name: 'sales', role: UserRoles.sales_order }
];
if (IS_DEV_OR_DEMO) {
navTabs.push({ text: <Trans>Playground</Trans>, name: 'playground' });
}

View File

@ -14,6 +14,7 @@ export enum ApiEndpoints {
user_me = 'user/me/',
user_roles = 'user/roles/',
user_token = 'user/token/',
user_tokens = 'user/tokens/',
user_simple_login = 'email/generate/',
user_reset = 'auth/password/reset/',
user_reset_set = 'auth/password/reset/confirm/',
@ -38,6 +39,7 @@ export enum ApiEndpoints {
settings_global_list = 'settings/global/',
settings_user_list = 'settings/user/',
barcode = 'barcode/',
generate_barcode = 'barcode/generate/',
news = 'news/',
global_status = 'generic/status/',
version = 'version/',
@ -45,6 +47,14 @@ export enum ApiEndpoints {
sso_providers = 'auth/providers/',
group_list = 'user/group/',
owner_list = 'user/owner/',
icons = 'icons/',
// Data import endpoints
import_session_list = 'importer/session/',
import_session_accept_fields = 'importer/session/:id/accept_fields/',
import_session_accept_rows = 'importer/session/:id/accept_rows/',
import_session_column_mapping_list = 'importer/column-mapping/',
import_session_row_list = 'importer/row/',
// Notification endpoints
notifications_list = 'notifications/',
@ -52,15 +62,20 @@ export enum ApiEndpoints {
// Build API endpoints
build_order_list = 'build/',
build_order_issue = 'build/:id/issue/',
build_order_cancel = 'build/:id/cancel/',
build_output_create = 'build/:id/create-output/',
build_order_hold = 'build/:id/hold/',
build_order_complete = 'build/:id/finish/',
build_output_complete = 'build/:id/complete/',
build_output_create = 'build/:id/create-output/',
build_output_scrap = 'build/:id/scrap-outputs/',
build_output_delete = 'build/:id/delete-outputs/',
build_order_attachment_list = 'build/attachment/',
build_line_list = 'build/line/',
build_item_list = 'build/item/',
bom_list = 'bom/',
bom_item_validate = 'bom/:id/validate/',
bom_validate = 'part/:id/bom-validate/',
// Part API endpoints
part_list = 'part/',
@ -76,18 +91,15 @@ export enum ApiEndpoints {
category_tree = 'part/category/tree/',
category_parameter_list = 'part/category/parameters/',
related_part_list = 'part/related/',
part_attachment_list = 'part/attachment/',
part_test_template_list = 'part/test-template/',
// Company API endpoints
company_list = 'company/',
contact_list = 'company/contact/',
address_list = 'company/address/',
company_attachment_list = 'company/attachment/',
supplier_part_list = 'company/part/',
supplier_part_pricing_list = 'company/price-break/',
manufacturer_part_list = 'company/part/manufacturer/',
manufacturer_part_attachment_list = 'company/part/manufacturer/attachment/',
manufacturer_part_parameter_list = 'company/part/manufacturer/parameter/',
// Stock API endpoints
@ -96,7 +108,6 @@ export enum ApiEndpoints {
stock_location_list = 'stock/location/',
stock_location_type_list = 'stock/location-type/',
stock_location_tree = 'stock/location/tree/',
stock_attachment_list = 'stock/attachment/',
stock_test_result_list = 'stock/test/',
stock_transfer = 'stock/transfer/',
stock_remove = 'stock/remove/',
@ -107,6 +118,8 @@ export enum ApiEndpoints {
stock_assign = 'stock/assign/',
stock_status = 'stock/status/',
stock_install = 'stock/:id/install',
build_test_statistics = 'test-statistics/by-build/:id',
part_test_statistics = 'test-statistics/by-part/:id',
// Generator API endpoints
generate_batch_code = 'generate/batch-code/',
@ -114,17 +127,29 @@ export enum ApiEndpoints {
// Order API endpoints
purchase_order_list = 'order/po/',
purchase_order_issue = 'order/po/:id/issue/',
purchase_order_hold = 'order/po/:id/hold/',
purchase_order_cancel = 'order/po/:id/cancel/',
purchase_order_complete = 'order/po/:id/complete/',
purchase_order_line_list = 'order/po-line/',
purchase_order_attachment_list = 'order/po/attachment/',
purchase_order_receive = 'order/po/:id/receive/',
sales_order_list = 'order/so/',
sales_order_issue = 'order/so/:id/issue/',
sales_order_hold = 'order/so/:id/hold/',
sales_order_cancel = 'order/so/:id/cancel/',
sales_order_ship = 'order/so/:id/ship/',
sales_order_complete = 'order/so/:id/complete/',
sales_order_line_list = 'order/so-line/',
sales_order_attachment_list = 'order/so/attachment/',
sales_order_allocation_list = 'order/so-allocation/',
sales_order_shipment_list = 'order/so/shipment/',
return_order_list = 'order/ro/',
return_order_attachment_list = 'order/ro/attachment/',
return_order_issue = 'order/ro/:id/issue/',
return_order_hold = 'order/ro/:id/hold/',
return_order_cancel = 'order/ro/:id/cancel/',
return_order_complete = 'order/ro/:id/complete/',
return_order_line_list = 'order/ro-line/',
// Template API endpoints
label_list = 'label/template/',
@ -156,6 +181,7 @@ export enum ApiEndpoints {
machine_setting_detail = 'machine/:machine/settings/:config_type/',
// Miscellaneous API endpoints
attachment_list = 'attachment/',
error_report_list = 'error-report/',
project_code_list = 'project-code/',
custom_unit_list = 'units/',

View File

@ -15,16 +15,20 @@ export enum ModelType {
stockhistory = 'stockhistory',
build = 'build',
buildline = 'buildline',
builditem = 'builditem',
company = 'company',
purchaseorder = 'purchaseorder',
purchaseorderline = 'purchaseorderline',
purchaseorderlineitem = 'purchaseorderlineitem',
salesorder = 'salesorder',
salesordershipment = 'salesordershipment',
returnorder = 'returnorder',
returnorderlineitem = 'returnorderlineitem',
importsession = 'importsession',
address = 'address',
contact = 'contact',
owner = 'owner',
user = 'user',
group = 'group',
reporttemplate = 'reporttemplate',
labeltemplate = 'labeltemplate',
pluginconfig = 'pluginconfig'

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { ActionIcon, Alert, Stack, Text } from '@mantine/core';
import { Alert, Stack, Text } from '@mantine/core';
import {
IconCalendar,
IconLink,
@ -21,6 +21,7 @@ import { InvenTreeIcon } from '../functions/icons';
import { useCreateApiFormModal } from '../hooks/UseForm';
import { useBatchCodeGenerator } from '../hooks/UseGenerator';
import { apiUrl } from '../states/ApiState';
import { useGlobalSettingsState } from '../states/SettingsState';
import { PartColumn, StatusColumn } from '../tables/ColumnRenderers';
/**
@ -43,6 +44,8 @@ export function useBuildOrderFields({
}
});
const globalSettings = useGlobalSettingsState();
return useMemo(() => {
return {
reference: {},
@ -50,7 +53,13 @@ export function useBuildOrderFields({
disabled: !create,
filters: {
assembly: true,
virtual: false
virtual: false,
active: globalSettings.isSet('BUILDORDER_REQUIRE_ACTIVE_PART')
? true
: undefined,
locked: globalSettings.isSet('BUILDORDER_REQUIRE_LOCKED_PART')
? true
: undefined
},
onValueChange(value: any, record?: any) {
// Adjust the destination location for the build order
@ -98,7 +107,10 @@ export function useBuildOrderFields({
icon: <IconLink />
},
issued_by: {
icon: <IconUser />
icon: <IconUser />,
filters: {
is_active: true
}
},
responsible: {
icon: <IconUsersGroup />,
@ -107,7 +119,7 @@ export function useBuildOrderFields({
}
}
};
}, [create, destination, batchCode]);
}, [create, destination, batchCode, globalSettings]);
}
export function useBuildOrderOutputFields({

View File

@ -41,7 +41,8 @@ export function useSupplierPartFields() {
},
supplier: {
filters: {
active: true
active: true,
is_supplier: true
}
},
SKU: {
@ -69,7 +70,12 @@ export function useManufacturerPartFields() {
return useMemo(() => {
const fields: ApiFormFieldSet = {
part: {},
manufacturer: {},
manufacturer: {
filters: {
active: true,
is_manufacturer: true
}
},
MPN: {},
description: {},
link: {}

View File

@ -0,0 +1,20 @@
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
export function dataImporterSessionFields(): ApiFormFieldSet {
return {
data_file: {},
model_type: {},
field_defaults: {
hidden: true,
value: {}
},
field_overrides: {
hidden: true,
value: {}
},
field_filters: {
hidden: true,
value: {}
}
};
}

View File

@ -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: {},
@ -46,6 +57,7 @@ export function usePartFields({
purchaseable: {},
salable: {},
virtual: {},
locked: {},
active: {}
};
@ -56,7 +68,9 @@ export function usePartFields({
fields.initial_stock = {
icon: <IconPackages />,
children: {
quantity: {},
quantity: {
value: 0
},
location: {}
}
};
@ -79,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]);
@ -94,7 +117,7 @@ export function usePartFields({
/**
* Construct a set of fields for creating / editing a PartCategory instance
*/
export function partCategoryFields({}: {}): ApiFormFieldSet {
export function partCategoryFields(): ApiFormFieldSet {
let fields: ApiFormFieldSet = {
parent: {
description: t`Parent part category`,
@ -109,7 +132,9 @@ export function partCategoryFields({}: {}): ApiFormFieldSet {
},
default_keywords: {},
structural: {},
icon: {}
icon: {
field_type: 'icon'
}
};
return fields;
@ -160,6 +185,7 @@ export function usePartParameterFields(): ApiFormFieldSet {
}
},
data: {
type: fieldType,
field_type: fieldType,
choices: fieldType === 'choice' ? choices : undefined,
adjustValue: (value: any) => {

View File

@ -1,7 +1,9 @@
import { t } from '@lingui/macro';
import {
Container,
Flex,
FocusTrap,
Group,
Modal,
NumberInput,
Table,
@ -39,7 +41,10 @@ import { ApiEndpoints } from '../enums/ApiEndpoints';
import { ModelType } from '../enums/ModelType';
import { InvenTreeIcon } from '../functions/icons';
import { useCreateApiFormModal } from '../hooks/UseForm';
import { useBatchCodeGenerator } from '../hooks/UseGenerator';
import {
useBatchCodeGenerator,
useSerialNumberGenerator
} from '../hooks/UseGenerator';
import { apiUrl } from '../states/ApiState';
/*
@ -173,6 +178,9 @@ export function usePurchaseOrderFields(): ApiFormFieldSet {
}
},
responsible: {
filters: {
is_active: true
},
icon: <IconUsers />
}
};
@ -219,12 +227,30 @@ function LineItemFormRow({
}
});
const serialNumberGenerator = useSerialNumberGenerator((value: any) => {
if (!serials) {
setSerials(value);
}
});
const [packagingOpen, packagingHandlers] = useDisclosure(false, {
onClose: () => {
input.changeFn(input.idx, 'packaging', undefined);
}
});
const [noteOpen, noteHandlers] = useDisclosure(false, {
onClose: () => {
input.changeFn(input.idx, 'note', undefined);
}
});
// State for serializing
const [batchCode, setBatchCode] = useState<string>('');
const [serials, setSerials] = useState<string>('');
const [batchOpen, batchHandlers] = useDisclosure(false, {
onClose: () => {
input.changeFn(input.idx, 'batch_code', '');
input.changeFn(input.idx, 'batch_code', undefined);
input.changeFn(input.idx, 'serial_numbers', '');
},
onOpen: () => {
@ -233,19 +259,14 @@ function LineItemFormRow({
part: record?.supplier_part_detail?.part,
order: record?.order
});
// Generate new serial numbers
serialNumberGenerator.update({
part: record?.supplier_part_detail?.part,
quantity: input.item.quantity
});
}
});
// Change form value when state is altered
useEffect(() => {
input.changeFn(input.idx, 'batch_code', batchCode);
}, [batchCode]);
// Change form value when state is altered
useEffect(() => {
input.changeFn(input.idx, 'serial_numbers', serials);
}, [serials]);
// Status value
const [statusOpen, statusHandlers] = useDisclosure(false, {
onClose: () => input.changeFn(input.idx, 'status', 10)
@ -361,27 +382,43 @@ function LineItemFormRow({
<Table.Td style={{ width: '1%', whiteSpace: 'nowrap' }}>
<Flex gap="1px">
<ActionButton
size="sm"
onClick={() => locationHandlers.toggle()}
icon={<InvenTreeIcon icon="location" />}
tooltip={t`Set Location`}
tooltipAlignment="top"
variant={locationOpen ? 'filled' : 'outline'}
variant={locationOpen ? 'filled' : 'transparent'}
/>
<ActionButton
size="sm"
onClick={() => batchHandlers.toggle()}
icon={<InvenTreeIcon icon="batch_code" />}
tooltip={t`Assign Batch Code${
record.trackable && ' and Serial Numbers'
}`}
tooltipAlignment="top"
variant={batchOpen ? 'filled' : 'outline'}
variant={batchOpen ? 'filled' : 'transparent'}
/>
<ActionButton
size="sm"
icon={<InvenTreeIcon icon="packaging" />}
tooltip={t`Adjust Packaging`}
onClick={() => packagingHandlers.toggle()}
variant={packagingOpen ? 'filled' : 'transparent'}
/>
<ActionButton
onClick={() => statusHandlers.toggle()}
icon={<InvenTreeIcon icon="status" />}
tooltip={t`Change Status`}
tooltipAlignment="top"
variant={statusOpen ? 'filled' : 'outline'}
variant={statusOpen ? 'filled' : 'transparent'}
/>
<ActionButton
icon={<InvenTreeIcon icon="note" />}
tooltip={t`Add Note`}
tooltipAlignment="top"
variant={noteOpen ? 'filled' : 'transparent'}
onClick={() => noteHandlers.toggle()}
/>
{barcode ? (
<ActionButton
@ -397,7 +434,7 @@ function LineItemFormRow({
icon={<InvenTreeIcon icon="barcode" />}
tooltip={t`Scan Barcode`}
tooltipAlignment="top"
variant="outline"
variant="transparent"
onClick={() => open()}
/>
)}
@ -413,33 +450,34 @@ function LineItemFormRow({
</Table.Tr>
{locationOpen && (
<Table.Tr>
<Table.Td colSpan={4}>
<Flex align="end" gap={5}>
<div style={{ flexGrow: '1' }}>
<StandaloneField
fieldDefinition={{
field_type: 'related field',
model: ModelType.stocklocation,
api_url: apiUrl(ApiEndpoints.stock_location_list),
filters: {
structural: false
},
onValueChange: (value) => {
setLocation(value);
},
description: locationDescription,
value: location,
label: t`Location`,
icon: <InvenTreeIcon icon="location" />
}}
defaultValue={
record.destination ??
(record.destination_detail
? record.destination_detail.pk
: null)
}
/>
</div>
<Table.Td colSpan={10}>
<Group grow preventGrowOverflow={false} justify="flex-apart" p="xs">
<Container flex={0} p="xs">
<InvenTreeIcon icon="downright" />
</Container>
<StandaloneField
fieldDefinition={{
field_type: 'related field',
model: ModelType.stocklocation,
api_url: apiUrl(ApiEndpoints.stock_location_list),
filters: {
structural: false
},
onValueChange: (value) => {
setLocation(value);
},
description: locationDescription,
value: location,
label: t`Location`,
icon: <InvenTreeIcon icon="location" />
}}
defaultValue={
record.destination ??
(record.destination_detail
? record.destination_detail.pk
: null)
}
/>
<Flex style={{ marginBottom: '7px' }}>
{(record.part_detail.default_location ||
record.part_detail.category_default_location) && (
@ -474,67 +512,57 @@ function LineItemFormRow({
/>
)}
</Flex>
</Flex>
</Table.Td>
<Table.Td>
<div
style={{
height: '100%',
display: 'grid',
gridTemplateColumns: 'repeat(6, 1fr)',
gridTemplateRows: 'auto',
alignItems: 'end'
}}
>
<InvenTreeIcon icon="downleft" />
</div>
</Group>
</Table.Td>
</Table.Tr>
)}
<TableFieldExtraRow
visible={batchOpen}
colSpan={4}
content={
<StandaloneField
fieldDefinition={{
field_type: 'string',
onValueChange: (value) => setBatchCode(value),
label: 'Batch Code',
value: batchCode
}}
/>
}
onValueChange={(value) => input.changeFn(input.idx, 'batch', value)}
fieldDefinition={{
field_type: 'string',
label: t`Batch Code`,
value: batchCode
}}
/>
<TableFieldExtraRow
visible={batchOpen && record.trackable}
colSpan={4}
content={
<StandaloneField
fieldDefinition={{
field_type: 'string',
onValueChange: (value) => setSerials(value),
label: 'Serial numbers',
value: serials
}}
/>
onValueChange={(value) =>
input.changeFn(input.idx, 'serial_numbers', value)
}
fieldDefinition={{
field_type: 'string',
label: t`Serial numbers`,
value: serials
}}
/>
<TableFieldExtraRow
visible={packagingOpen}
onValueChange={(value) => input.changeFn(input.idx, 'packaging', value)}
fieldDefinition={{
field_type: 'string',
label: t`Packaging`
}}
defaultValue={record?.supplier_part_detail?.packaging}
/>
<TableFieldExtraRow
visible={statusOpen}
colSpan={4}
content={
<StandaloneField
fieldDefinition={{
field_type: 'choice',
api_url: apiUrl(ApiEndpoints.stock_status),
choices: statuses,
label: 'Status',
onValueChange: (value) =>
input.changeFn(input.idx, 'status', value)
}}
defaultValue={10}
/>
}
defaultValue={10}
onValueChange={(value) => input.changeFn(input.idx, 'status', value)}
fieldDefinition={{
field_type: 'choice',
api_url: apiUrl(ApiEndpoints.stock_status),
choices: statuses,
label: t`Status`
}}
/>
<TableFieldExtraRow
visible={noteOpen}
onValueChange={(value) => input.changeFn(input.idx, 'note', value)}
fieldDefinition={{
field_type: 'string',
label: t`Note`
}}
/>
</>
);
@ -608,7 +636,7 @@ export function useReceiveLineItems(props: LineItemsForm) {
/>
);
},
headers: ['Part', 'SKU', 'Received', 'Quantity to receive', 'Actions']
headers: [t`Part`, t`SKU`, t`Received`, t`Quantity`, t`Actions`]
},
location: {
filters: {

View File

@ -0,0 +1,45 @@
import { IconUsers } from '@tabler/icons-react';
import { useMemo } from 'react';
export function useReturnOrderLineItemFields({
orderId,
customerId,
create
}: {
orderId: number;
customerId: number;
create?: boolean;
}) {
return useMemo(() => {
return {
order: {
disabled: true,
filters: {
customer_detail: true
}
},
item: {
filters: {
customer: customerId,
part_detail: true,
serialized: true
}
},
reference: {},
outcome: {
hidden: create == true
},
price: {},
price_currency: {},
target_date: {},
notes: {},
link: {},
responsible: {
filters: {
is_active: true
},
icon: <IconUsers />
}
};
}, [create, orderId, customerId]);
}

View File

@ -47,6 +47,60 @@ export function useSalesOrderFields(): ApiFormFieldSet {
}, []);
}
export function useSalesOrderLineItemFields({
customerId,
orderId,
create
}: {
customerId?: number;
orderId?: number;
create?: boolean;
}): ApiFormFieldSet {
const fields = useMemo(() => {
return {
order: {
filters: {
customer_detail: true
},
disabled: true,
value: create ? orderId : undefined
},
part: {
filters: {
active: true,
salable: true
}
},
reference: {},
quantity: {},
sale_price: {},
sale_price_currency: {},
target_date: {},
notes: {},
link: {}
};
}, []);
return fields;
}
export function useSalesOrderShipmentFields(): ApiFormFieldSet {
return useMemo(() => {
return {
order: {
disabled: true
},
reference: {},
shipment_date: {},
delivery_date: {},
tracking_number: {},
invoice_number: {},
link: {},
notes: {}
};
}, []);
}
export function useReturnOrderFields(): ApiFormFieldSet {
return useMemo(() => {
return {
@ -82,6 +136,9 @@ export function useReturnOrderFields(): ApiFormFieldSet {
}
},
responsible: {
filters: {
is_active: true
},
icon: <IconUsers />
}
};

View File

@ -1,5 +1,6 @@
import { t } from '@lingui/macro';
import { Flex, Group, NumberInput, Skeleton, Table, Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { modals } from '@mantine/modals';
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { Suspense, useCallback, useMemo, useState } from 'react';
@ -10,6 +11,7 @@ import {
ApiFormAdjustFilterType,
ApiFormFieldSet
} from '../components/forms/fields/ApiFormField';
import { TableFieldExtraRow } from '../components/forms/fields/TableField';
import { Thumbnail } from '../components/images/Thumbnail';
import { StylishText } from '../components/items/StylishText';
import { StatusRenderer } from '../components/render/StatusRenderer';
@ -26,6 +28,7 @@ import {
useSerialNumberGenerator
} from '../hooks/UseGenerator';
import { apiUrl } from '../states/ApiState';
import { useGlobalSettingsState } from '../states/SettingsState';
/**
* Construct a set of fields for creating / editing a StockItem instance
@ -319,10 +322,30 @@ function StockOperationsRow({
[item]
);
const changeSubItem = useCallback(
(key: string, value: any) => {
input.changeFn(input.idx, key, value);
},
[input]
);
const removeAndRefresh = () => {
input.removeFn(input.idx);
};
const [packagingOpen, packagingHandlers] = useDisclosure(false, {
onOpen: () => {
if (transfer) {
input.changeFn(input.idx, 'packaging', record?.packaging || undefined);
}
},
onClose: () => {
if (transfer) {
input.changeFn(input.idx, 'packaging', undefined);
}
}
});
const stockString: string = useMemo(() => {
if (!record) {
return '-';
@ -338,64 +361,91 @@ function StockOperationsRow({
return !record ? (
<div>{t`Loading...`}</div>
) : (
<Table.Tr>
<Table.Td>
<Flex gap="sm" align="center">
<Thumbnail
size={40}
src={record.part_detail?.thumbnail}
align="center"
/>
<div>{record.part_detail?.name}</div>
</Flex>
</Table.Td>
<Table.Td>
{record.location ? record.location_detail?.pathstring : '-'}
</Table.Td>
<Table.Td>
<Flex align="center" gap="xs">
<Group justify="space-between">
<Text>{stockString}</Text>
<StatusRenderer status={record.status} type={ModelType.stockitem} />
</Group>
</Flex>
</Table.Td>
{!merge && (
<>
<Table.Tr>
<Table.Td>
<NumberInput
value={value}
onChange={onChange}
disabled={!!record.serial && record.quantity == 1}
max={setMax ? record.quantity : undefined}
min={0}
style={{ maxWidth: '100px' }}
/>
</Table.Td>
)}
<Table.Td>
<Flex gap="3px">
{transfer && (
<ActionButton
onClick={() => moveToDefault(record, value, removeAndRefresh)}
icon={<InvenTreeIcon icon="default_location" />}
tooltip={t`Move to default location`}
tooltipAlignment="top"
disabled={
!record.part_detail?.default_location &&
!record.part_detail?.category_default_location
}
<Flex gap="sm" align="center">
<Thumbnail
size={40}
src={record.part_detail?.thumbnail}
align="center"
/>
)}
<ActionButton
onClick={() => input.removeFn(input.idx)}
icon={<InvenTreeIcon icon="square_x" />}
tooltip={t`Remove item from list`}
tooltipAlignment="top"
color="red"
/>
</Flex>
</Table.Td>
</Table.Tr>
<div>{record.part_detail?.name}</div>
</Flex>
</Table.Td>
<Table.Td>
{record.location ? record.location_detail?.pathstring : '-'}
</Table.Td>
<Table.Td>
<Flex align="center" gap="xs">
<Group justify="space-between">
<Text>{stockString}</Text>
<StatusRenderer
status={record.status}
type={ModelType.stockitem}
/>
</Group>
</Flex>
</Table.Td>
{!merge && (
<Table.Td>
<NumberInput
value={value}
onChange={onChange}
disabled={!!record.serial && record.quantity == 1}
max={setMax ? record.quantity : undefined}
min={0}
style={{ maxWidth: '100px' }}
/>
</Table.Td>
)}
<Table.Td>
<Flex gap="3px">
{transfer && (
<ActionButton
onClick={() => moveToDefault(record, value, removeAndRefresh)}
icon={<InvenTreeIcon icon="default_location" />}
tooltip={t`Move to default location`}
tooltipAlignment="top"
disabled={
!record.part_detail?.default_location &&
!record.part_detail?.category_default_location
}
/>
)}
{transfer && (
<ActionButton
size="sm"
icon={<InvenTreeIcon icon="packaging" />}
tooltip={t`Adjust Packaging`}
onClick={() => packagingHandlers.toggle()}
variant={packagingOpen ? 'filled' : 'transparent'}
/>
)}
<ActionButton
onClick={() => input.removeFn(input.idx)}
icon={<InvenTreeIcon icon="square_x" />}
tooltip={t`Remove item from list`}
tooltipAlignment="top"
color="red"
/>
</Flex>
</Table.Td>
</Table.Tr>
{transfer && (
<TableFieldExtraRow
visible={transfer && packagingOpen}
onValueChange={(value: any) => {
input.changeFn(input.idx, 'packaging', value || undefined);
}}
fieldDefinition={{
field_type: 'string',
label: t`Packaging`
}}
defaultValue={record.packaging}
/>
)}
</>
);
}
@ -850,7 +900,7 @@ export function useDeleteStockItem(props: StockOperationProps) {
});
}
export function stockLocationFields({}: {}): ApiFormFieldSet {
export function stockLocationFields(): ApiFormFieldSet {
let fields: ApiFormFieldSet = {
parent: {
description: t`Parent stock location`,
@ -860,7 +910,9 @@ export function stockLocationFields({}: {}): ApiFormFieldSet {
description: {},
structural: {},
external: {},
icon: {},
custom_icon: {
field_type: 'icon'
},
location_type: {}
};
@ -881,6 +933,13 @@ export function useTestResultFields({
// Field type for the "value" input
const [fieldType, setFieldType] = useState<'string' | 'choice'>('string');
const settings = useGlobalSettingsState.getState();
const includeTestStation = useMemo(
() => settings.isSet('TEST_STATION_DATA'),
[settings]
);
return useMemo(() => {
return {
stock_item: {
@ -921,8 +980,15 @@ export function useTestResultFields({
},
attachment: {},
notes: {},
started_datetime: {},
finished_datetime: {}
started_datetime: {
hidden: !includeTestStation
},
finished_datetime: {
hidden: !includeTestStation
},
test_station: {
hidden: !includeTestStation
}
};
}, [choices, fieldType, partId, itemId]);
}, [choices, fieldType, partId, itemId, includeTestStation]);
}

View File

@ -1,6 +1,7 @@
import { t } from '@lingui/macro';
import { notifications } from '@mantine/notifications';
import axios from 'axios';
import { NavigateFunction } from 'react-router-dom';
import { api, setApiDefaults } from '../App';
import { ApiEndpoints } from '../enums/ApiEndpoints';
@ -10,6 +11,35 @@ import { useUserState } from '../states/UserState';
import { fetchGlobalStates } from '../states/states';
import { showLoginNotification } from './notifications';
/**
* sends a request to the specified url from a form. this will change the window location.
* @param {string} path the path to send the post request to
* @param {object} params the parameters to add to the url
* @param {string} [method=post] the method to use on the form
*
* Source https://stackoverflow.com/questions/133925/javascript-post-request-like-a-form-submit/133997#133997
*/
function post(path: string, params: any, method = 'post') {
const form = document.createElement('form');
form.method = method;
form.action = path;
for (const key in params) {
if (params.hasOwnProperty(key)) {
const hiddenField = document.createElement('input');
hiddenField.type = 'hidden';
hiddenField.name = key;
hiddenField.value = params[key];
form.appendChild(hiddenField);
}
}
document.body.appendChild(form);
form.submit();
}
/**
* Attempt to login using username:password combination.
* If login is successful, an API token will be returned.
@ -17,8 +47,7 @@ import { showLoginNotification } from './notifications';
*/
export const doBasicLogin = async (username: string, password: string) => {
const { host } = useLocalState.getState();
const { clearUserState, setToken, fetchUserState, isLoggedIn } =
useUserState.getState();
const { clearUserState, setToken, fetchUserState } = useUserState.getState();
if (username.length == 0 || password.length == 0) {
return;
@ -50,11 +79,23 @@ export const doBasicLogin = async (username: string, password: string) => {
}
}
})
.catch(() => {});
.catch((err) => {
if (
err?.response?.status == 403 &&
err?.response?.data?.detail == 'MFA required for this user'
) {
post(apiUrl(ApiEndpoints.user_login), {
username: username,
password: password,
csrfmiddlewaretoken: getCsrfCookie(),
mfa: true
});
}
});
if (result) {
await fetchUserState();
await fetchGlobalStates();
fetchGlobalStates();
} else {
clearUserState();
}
@ -65,7 +106,7 @@ export const doBasicLogin = async (username: string, password: string) => {
*
* @arg deleteToken: If true, delete the token from the server
*/
export const doLogout = async (navigate: any) => {
export const doLogout = async (navigate: NavigateFunction) => {
const { clearUserState, isLoggedIn } = useUserState.getState();
// Logout from the server session

View File

@ -1,20 +1,12 @@
import { t } from '@lingui/macro';
import { Divider, Stack } from '@mantine/core';
import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications';
import { AxiosResponse } from 'axios';
import { api } from '../App';
import { ApiForm, ApiFormProps } from '../components/forms/ApiForm';
import {
ApiFormFieldSet,
ApiFormFieldType
} from '../components/forms/fields/ApiFormField';
import { StylishText } from '../components/items/StylishText';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { PathParams, apiUrl } from '../states/ApiState';
import { invalidResponse, permissionDenied } from './notifications';
import { generateUniqueId } from './uid';
/**
* Construct an API url from the provided ApiFormProps object
@ -64,32 +56,41 @@ export function extractAvailableFields(
return null;
}
const processField = (field: any, fieldName: string) => {
const resField: ApiFormFieldType = {
...field,
name: fieldName,
field_type: field.type,
description: field.help_text,
value: field.value ?? field.default,
disabled: field.read_only ?? false
};
// Remove the 'read_only' field - plays havoc with react components
delete resField.read_only;
if (resField.field_type === 'nested object' && resField.children) {
resField.children = processFields(resField.children, fieldName);
}
if (resField.field_type === 'dependent field' && resField.child) {
resField.child = processField(resField.child, fieldName);
// copy over the label from the dependent field to the child field
if (!resField.child.label) {
resField.child.label = resField.label;
}
}
return resField;
};
const processFields = (fields: any, _path?: string) => {
const _fields: ApiFormFieldSet = {};
for (const [fieldName, field] of Object.entries(fields) as any) {
const path = _path ? `${_path}.${fieldName}` : fieldName;
_fields[fieldName] = {
...field,
name: path,
field_type: field.type,
description: field.help_text,
value: field.value ?? field.default,
disabled: field.read_only ?? false
};
// Remove the 'read_only' field - plays havoc with react components
delete _fields[fieldName].read_only;
if (
_fields[fieldName].field_type === 'nested object' &&
_fields[fieldName].children
) {
_fields[fieldName].children = processFields(
_fields[fieldName].children,
path
);
}
_fields[fieldName] = processField(field, path);
}
return _fields;
@ -153,6 +154,14 @@ export function constructField({
});
}
break;
case 'dependent field':
if (!definition?.child) break;
def.child = constructField({
// use the raw definition here as field, since a dependent field cannot be influenced by the frontend
field: definition.child ?? {}
});
break;
default:
break;
}
@ -163,143 +172,3 @@ export function constructField({
return def;
}
export interface OpenApiFormProps extends ApiFormProps {
title: string;
cancelText?: string;
cancelColor?: string;
onClose?: () => void;
}
/*
* Construct and open a modal form
* @param title :
*/
export function openModalApiForm(props: OpenApiFormProps) {
// method property *must* be supplied
if (!props.method) {
notifications.show({
title: t`Invalid Form`,
message: t`method parameter not supplied`,
color: 'red'
});
return;
}
// Generate a random modal ID for controller
let modalId: string =
`modal-${props.title}-${props.url}-${props.method}` + generateUniqueId();
props.actions = [
...(props.actions || []),
{
text: props.cancelText ?? t`Cancel`,
color: props.cancelColor ?? 'blue',
onClick: () => {
modals.close(modalId);
}
}
];
const oldFormSuccess = props.onFormSuccess;
props.onFormSuccess = (data) => {
oldFormSuccess?.(data);
modals.close(modalId);
};
let url = constructFormUrl(props.url, props.pk, props.pathParams);
// Make OPTIONS request first
api
.options(url)
.then((response) => {
// Extract available fields from the OPTIONS response (and handle any errors)
let fields: Record<string, ApiFormFieldType> | null = {};
if (!props.ignorePermissionCheck) {
fields = extractAvailableFields(response, props.method);
if (fields == null) {
return;
}
}
const _props = { ...props };
if (_props.fields) {
for (const [k, v] of Object.entries(_props.fields)) {
_props.fields[k] = constructField({
field: v,
definition: fields?.[k]
});
}
}
modals.open({
title: <StylishText size="xl">{props.title}</StylishText>,
modalId: modalId,
size: 'xl',
onClose: () => {
props.onClose ? props.onClose() : null;
},
children: (
<Stack gap={'xs'}>
<Divider />
<ApiForm id={modalId} props={props} optionsLoading={false} />
</Stack>
)
});
})
.catch((error) => {
if (error.response) {
invalidResponse(error.response.status);
} else {
notifications.show({
title: t`Form Error`,
message: error.message,
color: 'red'
});
}
});
}
/**
* Opens a modal form to create a new model instance
*/
export function openCreateApiForm(props: OpenApiFormProps) {
let createProps: OpenApiFormProps = {
...props,
method: 'POST'
};
openModalApiForm(createProps);
}
/**
* Open a modal form to edit a model instance
*/
export function openEditApiForm(props: OpenApiFormProps) {
let editProps: OpenApiFormProps = {
...props,
fetchInitialData: props.fetchInitialData ?? true,
method: 'PATCH'
};
openModalApiForm(editProps);
}
/**
* Open a modal form to delete a model instancel
*/
export function openDeleteApiForm(props: OpenApiFormProps) {
let deleteProps: OpenApiFormProps = {
...props,
method: 'DELETE',
submitText: t`Delete`,
submitColor: 'red',
fields: {}
};
openModalApiForm(deleteProps);
}

View File

@ -6,6 +6,7 @@ import {
IconBinaryTree2,
IconBookmarks,
IconBox,
IconBrandTelegram,
IconBuilding,
IconBuildingFactory2,
IconBuildingStore,
@ -32,12 +33,15 @@ import {
IconFlagShare,
IconGitBranch,
IconGridDots,
IconHandStop,
IconHash,
IconHierarchy,
IconInfoCircle,
IconLayersLinked,
IconLink,
IconList,
IconListTree,
IconLock,
IconMail,
IconMapPin,
IconMapPinHeart,
@ -89,7 +93,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,
@ -140,6 +145,10 @@ const icons = {
plus: IconCirclePlus,
minus: IconCircleMinus,
cancel: IconCircleX,
hold: IconHandStop,
issue: IconBrandTelegram,
complete: IconCircleCheck,
deliver: IconTruckDelivery,
// Part Icons
active: IconCheck,
@ -153,6 +162,8 @@ const icons = {
inactive: IconX,
part: IconBox,
supplier_part: IconPackageImport,
lock: IconLock,
locked: IconLock,
calendar: IconCalendar,
external: IconExternalLink,

View File

@ -22,6 +22,7 @@ type UseFilterProps = {
export function useFilters(props: UseFilterProps) {
const query = useQuery({
enabled: true,
gcTime: 500,
queryKey: [props.url, props.method, props.params],
queryFn: async () => {
return await api
@ -44,7 +45,15 @@ export function useFilters(props: UseFilterProps) {
});
const choices: TableFilterChoice[] = useMemo(() => {
return query.data?.map(props.transform) ?? [];
let opts = query.data?.map(props.transform) ?? [];
// Ensure stringiness
return opts.map((opt: any) => {
return {
value: opt.value.toString(),
label: opt?.label?.toString() ?? opt.value.toString()
};
});
}, [props.transform, query.data]);
const refresh = useCallback(() => {
@ -72,6 +81,9 @@ export function useProjectCodeFilters() {
export function useUserFilters() {
return useFilters({
url: apiUrl(ApiEndpoints.user_list),
params: {
is_active: true
},
transform: (item) => ({
value: item.pk,
label: item.username
@ -83,6 +95,9 @@ export function useUserFilters() {
export function useOwnerFilters() {
return useFilters({
url: apiUrl(ApiEndpoints.owner_list),
params: {
is_active: true
},
transform: (item) => ({
value: item.pk,
label: item.name

View File

@ -42,13 +42,15 @@ export function useGenerator(
...params
}));
}
queryGenerator.refetch();
},
[]
);
// API query handler
const queryGenerator = useQuery({
enabled: true,
enabled: false,
queryKey: ['generator', key, endpoint, debouncedQuery],
queryFn: async () => {
return api.post(apiUrl(endpoint), debouncedQuery).then((response) => {

View File

@ -0,0 +1,139 @@
import { useCallback, useMemo } from 'react';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { ModelType } from '../enums/ModelType';
import { useInstance } from './UseInstance';
import useStatusCodes from './UseStatusCodes';
/*
* Custom hook for managing the state of a data import session
*/
export type ImportSessionState = {
sessionId: number;
sessionData: any;
setSessionData: (data: any) => void;
refreshSession: () => void;
sessionQuery: any;
status: number;
availableFields: Record<string, any>;
availableColumns: string[];
mappedFields: any[];
columnMappings: any[];
fieldDefaults: any;
fieldOverrides: any;
fieldFilters: any;
rowCount: number;
completedRowCount: number;
};
export function useImportSession({
sessionId
}: {
sessionId: number;
}): ImportSessionState {
// Query manager for the import session
const {
instance: sessionData,
setInstance,
refreshInstance: refreshSession,
instanceQuery: sessionQuery
} = useInstance({
endpoint: ApiEndpoints.import_session_list,
pk: sessionId,
defaultValue: {}
});
const setSessionData = useCallback((data: any) => {
setInstance(data);
}, []);
const importSessionStatus = useStatusCodes({
modelType: ModelType.importsession
});
// Current step of the import process
const status: number = useMemo(() => {
return sessionData?.status ?? importSessionStatus.INITIAL;
}, [sessionData, importSessionStatus]);
// List of available writeable database field definitions
const availableFields: any[] = useMemo(() => {
return sessionData?.available_fields ?? [];
}, [sessionData]);
// List of available data file columns
const availableColumns: string[] = useMemo(() => {
let cols = sessionData?.columns ?? [];
// Filter out any blank or duplicate columns
cols = cols.filter((col: string) => !!col);
cols = cols.filter(
(col: string, index: number) => cols.indexOf(col) === index
);
return cols;
}, [sessionData.columns]);
const columnMappings: any[] = useMemo(() => {
let mapping =
sessionData?.column_mappings?.map((mapping: any) => ({
...mapping,
...(availableFields[mapping.field] ?? {})
})) ?? [];
mapping = mapping.sort((a: any, b: any) => {
if (a?.required && !b?.required) return -1;
if (!a?.required && b?.required) return 1;
return 0;
});
return mapping;
}, [sessionData, availableColumns]);
// List of field which have been mapped to columns
const mappedFields: any[] = useMemo(() => {
return (
sessionData?.column_mappings?.filter((column: any) => !!column.column) ??
[]
);
}, [sessionData]);
const fieldDefaults: any = useMemo(() => {
return sessionData?.field_defaults ?? {};
}, [sessionData]);
const fieldOverrides: any = useMemo(() => {
return sessionData?.field_overrides ?? {};
}, [sessionData]);
const fieldFilters: any = useMemo(() => {
return sessionData?.field_filters ?? {};
}, [sessionData]);
const rowCount: number = useMemo(() => {
return sessionData?.row_count ?? 0;
}, [sessionData]);
const completedRowCount: number = useMemo(() => {
return sessionData?.completed_row_count ?? 0;
}, [sessionData]);
return {
sessionData,
setSessionData,
sessionId,
refreshSession,
sessionQuery,
status,
availableFields,
availableColumns,
columnMappings,
mappedFields,
fieldDefaults,
fieldOverrides,
fieldFilters,
rowCount,
completedRowCount
};
}

View File

@ -39,6 +39,8 @@ export function useInstance<T = any>({
}) {
const [instance, setInstance] = useState<T | undefined>(defaultValue);
const [requestStatus, setRequestStatus] = useState<number>(0);
const instanceQuery = useQuery<T>({
queryKey: ['instance', endpoint, pk, params, pathParams],
queryFn: async () => {
@ -62,6 +64,7 @@ export function useInstance<T = any>({
params: params
})
.then((response) => {
setRequestStatus(response.status);
switch (response.status) {
case 200:
setInstance(response.data);
@ -72,8 +75,9 @@ export function useInstance<T = any>({
}
})
.catch((error) => {
setRequestStatus(error.response?.status || 0);
setInstance(defaultValue);
console.error(`Error fetching instance ${url}:`, error);
console.error(`ERR: Error fetching instance ${url}:`, error);
if (throwError) throw error;
@ -81,7 +85,7 @@ export function useInstance<T = any>({
});
},
refetchOnMount: refetchOnMount,
refetchOnWindowFocus: refetchOnWindowFocus,
refetchOnWindowFocus: refetchOnWindowFocus ?? false,
refetchInterval: updateInterval
});
@ -89,5 +93,11 @@ export function useInstance<T = any>({
instanceQuery.refetch();
}, []);
return { instance, refreshInstance, instanceQuery };
return {
instance,
setInstance,
refreshInstance,
instanceQuery,
requestStatus
};
}

View File

@ -0,0 +1,46 @@
import { useMemo } from 'react';
import { getStatusCodes } from '../components/render/StatusRenderer';
import { ModelType } from '../enums/ModelType';
import { useGlobalStatusState } from '../states/StatusState';
/**
* Hook to access status codes, which are enumerated by the backend.
*
* This hook is used to return a map of status codes for a given model type.
* It is a memoized wrapper around getStatusCodes,
* and returns a simplified KEY:value map of status codes.
*
* e.g. for the "PurchaseOrderStatus" enumeration, returns a map like:
*
* {
* PENDING: 10
* PLACED: 20
* ON_HOLD: 25,
* COMPLETE: 30,
* CANCELLED: 40,
* LOST: 50,
* RETURNED: 60
* }
*/
export default function useStatusCodes({
modelType
}: {
modelType: ModelType | string;
}) {
const statusCodeList = useGlobalStatusState.getState().status;
const codes = useMemo(() => {
const statusCodes = getStatusCodes(modelType) || {};
let codesMap: Record<any, any> = {};
for (let name in statusCodes) {
codesMap[name] = statusCodes[name].key;
}
return codesMap;
}, [modelType, statusCodeList]);
return codes;
}

View File

@ -58,7 +58,7 @@ export function useTable(tableName: string): TableState {
// Callback used to refresh (reload) the table
const refreshTable = useCallback(() => {
setTableKey(generateTableName());
}, []);
}, [generateTableName]);
// Array of active filters (saved to local storage)
const [activeFilters, setActiveFilters] = useLocalStorage<TableFilter[]>({

View File

@ -0,0 +1,4 @@
import { Messages } from '@lingui/core';
declare const messages: Messages;
export { messages };

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,4 @@
import { Messages } from '@lingui/core';
declare const messages: Messages;
export { messages };

Some files were not shown because too many files have changed in this diff Show More