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:
@ -1,5 +1,6 @@
|
||||
{
|
||||
"locales": [
|
||||
"ar",
|
||||
"bg",
|
||||
"cs",
|
||||
"da",
|
||||
@ -8,6 +9,7 @@
|
||||
"en",
|
||||
"es",
|
||||
"es-mx",
|
||||
"et",
|
||||
"fa",
|
||||
"fi",
|
||||
"fr",
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -29,4 +29,10 @@ export function setApiDefaults() {
|
||||
}
|
||||
}
|
||||
|
||||
export const queryClient = new QueryClient();
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -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);
|
||||
},
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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';
|
||||
|
@ -11,7 +11,7 @@ export function ButtonMenu({
|
||||
label = ''
|
||||
}: {
|
||||
icon: any;
|
||||
actions: any[];
|
||||
actions: React.ReactNode[];
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
}) {
|
||||
|
@ -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> </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>
|
||||
);
|
||||
|
41
src/frontend/src/components/buttons/PrimaryActionButton.tsx
Normal file
41
src/frontend/src/components/buttons/PrimaryActionButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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) {
|
||||
|
@ -22,6 +22,7 @@ export function PassFailButton({
|
||||
variant="filled"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
style={{ maxWidth: '50px' }}
|
||||
>
|
||||
{v ? pass : fail}
|
||||
</Badge>
|
||||
|
@ -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' }}>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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 = [
|
||||
|
@ -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'
|
||||
},
|
||||
|
@ -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" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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]);
|
||||
|
||||
|
28
src/frontend/src/components/errors/ClientError.tsx
Normal file
28
src/frontend/src/components/errors/ClientError.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
73
src/frontend/src/components/errors/GenericErrorPage.tsx
Normal file
73
src/frontend/src/components/errors/GenericErrorPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
12
src/frontend/src/components/errors/NotAuthenticated.tsx
Normal file
12
src/frontend/src/components/errors/NotAuthenticated.tsx
Normal 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.`}
|
||||
/>
|
||||
);
|
||||
}
|
12
src/frontend/src/components/errors/NotFound.tsx
Normal file
12
src/frontend/src/components/errors/NotFound.tsx
Normal 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`}
|
||||
/>
|
||||
);
|
||||
}
|
12
src/frontend/src/components/errors/PermissionDenied.tsx
Normal file
12
src/frontend/src/components/errors/PermissionDenied.tsx
Normal 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.`}
|
||||
/>
|
||||
);
|
||||
}
|
13
src/frontend/src/components/errors/ServerError.tsx
Normal file
13
src/frontend/src/components/errors/ServerError.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
89
src/frontend/src/components/forms/fields/DependentField.tsx
Normal file
89
src/frontend/src/components/forms/fields/DependentField.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
352
src/frontend/src/components/forms/fields/IconField.tsx
Normal file
352
src/frontend/src/components/forms/fields/IconField.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
@ -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],
|
||||
|
@ -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>
|
||||
|
69
src/frontend/src/components/forms/fields/TextField.tsx
Normal file
69
src/frontend/src/components/forms/fields/TextField.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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 />
|
||||
);
|
||||
}
|
||||
|
427
src/frontend/src/components/importer/ImportDataSelector.tsx
Normal file
427
src/frontend/src/components/importer/ImportDataSelector.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
234
src/frontend/src/components/importer/ImporterColumnSelector.tsx
Normal file
234
src/frontend/src/components/importer/ImporterColumnSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
170
src/frontend/src/components/importer/ImporterDrawer.tsx
Normal file
170
src/frontend/src/components/importer/ImporterDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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,
|
||||
|
13
src/frontend/src/components/items/ApiIcon.css.ts
Normal file
13
src/frontend/src/components/items/ApiIcon.css.ts
Normal 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'
|
||||
});
|
27
src/frontend/src/components/items/ApiIcon.tsx
Normal file
27
src/frontend/src/components/items/ApiIcon.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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';
|
||||
|
130
src/frontend/src/components/items/QRCode.tsx
Normal file
130
src/frontend/src/components/items/QRCode.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
31
src/frontend/src/components/nav/InstanceDetail.tsx
Normal file
31
src/frontend/src/components/nav/InstanceDetail.tsx
Normal 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}</>;
|
||||
}
|
@ -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)}
|
||||
|
@ -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(() => {
|
||||
|
@ -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>
|
||||
|
@ -119,6 +119,7 @@ function BasePanelGroup({
|
||||
label={panel.label}
|
||||
key={panel.name}
|
||||
disabled={expanded}
|
||||
position="right"
|
||||
>
|
||||
<Tabs.Tab
|
||||
p="xs"
|
||||
|
@ -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>
|
||||
|
@ -46,3 +46,9 @@ export function RenderBuildLine({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RenderBuildItem({
|
||||
instance
|
||||
}: Readonly<InstanceRenderInterface>): ReactNode {
|
||||
return <RenderInlineModel primary={instance.pk} />;
|
||||
}
|
||||
|
@ -14,3 +14,11 @@ export function RenderProjectCode({
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function RenderImportSession({
|
||||
instance
|
||||
}: {
|
||||
instance: any;
|
||||
}): ReactNode {
|
||||
return instance && <RenderInlineModel primary={instance.data_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 && (
|
||||
<>
|
||||
|
@ -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`,
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
@ -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})`}
|
||||
/>
|
||||
);
|
||||
|
@ -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} />;
|
||||
}
|
||||
|
@ -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 ? (
|
||||
|
@ -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(', ');
|
||||
|
||||
|
@ -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'
|
||||
};
|
||||
|
@ -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 '-';
|
||||
|
@ -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' });
|
||||
}
|
||||
|
@ -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/',
|
||||
|
@ -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'
|
||||
|
@ -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({
|
||||
|
@ -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: {}
|
||||
|
20
src/frontend/src/forms/ImporterForms.tsx
Normal file
20
src/frontend/src/forms/ImporterForms.tsx
Normal 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: {}
|
||||
}
|
||||
};
|
||||
}
|
@ -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) => {
|
||||
|
@ -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: {
|
||||
|
45
src/frontend/src/forms/ReturnOrderForms.tsx
Normal file
45
src/frontend/src/forms/ReturnOrderForms.tsx
Normal 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]);
|
||||
}
|
@ -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 />
|
||||
}
|
||||
};
|
||||
|
@ -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]);
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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) => {
|
||||
|
139
src/frontend/src/hooks/UseImportSession.tsx
Normal file
139
src/frontend/src/hooks/UseImportSession.tsx
Normal 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
|
||||
};
|
||||
}
|
@ -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
|
||||
};
|
||||
}
|
||||
|
46
src/frontend/src/hooks/UseStatusCodes.tsx
Normal file
46
src/frontend/src/hooks/UseStatusCodes.tsx
Normal 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;
|
||||
}
|
@ -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[]>({
|
||||
|
4
src/frontend/src/locales/ar/messages.d.ts
vendored
Normal file
4
src/frontend/src/locales/ar/messages.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
import { Messages } from '@lingui/core';
|
||||
declare const messages: Messages;
|
||||
export { messages };
|
||||
|
7845
src/frontend/src/locales/ar/messages.po
Normal file
7845
src/frontend/src/locales/ar/messages.po
Normal file
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
4
src/frontend/src/locales/et/messages.d.ts
vendored
Normal file
4
src/frontend/src/locales/et/messages.d.ts
vendored
Normal 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
Reference in New Issue
Block a user