mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-16 12:05:53 +00:00
[Feature] Data export plugins (#9096)
* Move data export code out of "importer" directory * Refactoring to allow data export via plugin * Add brief docs framework * Add basic DataExportMixin class * Pass context data through to the serializer * Extract custom serializer * Refactoring * Add builtin plugin for BomExport * More refactoring * Cleanup for UseForm hooks * Allow GET methods in forms * Create new 'exporter' app * Refactor imports * Run cleanup task on boot * Add enumeration for plugin mixin types * Refactor with_mixin call * Generate export options serializer * Pass plugin information through * Offload export functionality to the plugin * Generate output * Download generated file * Refactor frontend code * Generate params for downloading * Pass custom fields through to the plugin * Implement multi-level export for BOM data * Export supplier and manufacturer information * Export substitute data * Remove old BOM exporter * Export part parameter data * Try different app order * Use GET instead of POST request - Less 'dangerous' - no chance of performing a destructive operation * Fix for constructing query parameters - Ignore any undefined values! * Trying something * Revert to POST - Required, other query data are ignored * Fix spelling mistakes * Remove SettingsMixin * Revert python version * Fix for settings.py * Fix missing return * Fix for label mixin code * Run playwright tests in --host mode * Fix for choice field - Prevent empty value if field is required * Remove debug prints * Update table header * Playwright tests for data export * Rename app from "exporter" to "data_exporter" * Add frontend table for export sessions * Updated playwright testing * Fix for unit test * Fix build order unit test * Back to using GET instead of POST - Otherwise, users need POST permissions to export! - A bit of trickery with the forms architecture * Fix remaining unit tests * Implement unit test for BOM export - Including test for custom plugin * Fix unit test * Bump API version * Enhanced playwright tests * Add debug for CI testing * Single unit test only (for debugging) * Fix typo * typo fix * Remove debugs * Docs updates * Revert typo * Update tests * Serializer fix * Fix typo * Offload data export to the background worker - Requires mocking the original request object - Will need some further unit testing! * Refactor existing models into DataOutput - Remove LabelOutput table - Remove ReportOutput table - Remove ExportOutput table - Consolidate into single API endpoint * Remove "output" tables from frontend * Refactor frontend hook to be generic * Frontend now works with background data export * Fix tasks.py * Adjust unit tests * Revert 'plugin_key' to 'plugin' * Improve user checking when printing * Updates * Remove erroneous migration file * Tweak plugin registry * Adjust playwright tests * Refactor data export - Convert into custom hook - Enable for calendar view also * Add playwright tests * Adjust unit testing * Tweak unit tests * Add extra timeout to data export * Fix for RUF045
This commit is contained in:
@ -1,19 +1,12 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { notifications, showNotification } from '@mantine/notifications';
|
||||
import {
|
||||
IconCircleCheck,
|
||||
IconPrinter,
|
||||
IconReport,
|
||||
IconTags
|
||||
} from '@tabler/icons-react';
|
||||
import { IconPrinter, IconReport, IconTags } from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { api } from '../../App';
|
||||
import { useApi } from '../../contexts/ApiContext';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import type { ModelType } from '../../enums/ModelType';
|
||||
import { extractAvailableFields } from '../../functions/forms';
|
||||
import { generateUrl } from '../../functions/urls';
|
||||
import useDataOutput from '../../hooks/UseDataOutput';
|
||||
import { useCreateApiFormModal } from '../../hooks/UseForm';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import {
|
||||
@ -22,94 +15,6 @@ import {
|
||||
} from '../../states/SettingsState';
|
||||
import type { ApiFormFieldSet } from '../forms/fields/ApiFormField';
|
||||
import { ActionDropdown } from '../items/ActionDropdown';
|
||||
import { ProgressBar } from '../items/ProgressBar';
|
||||
|
||||
/**
|
||||
* Hook to track the progress of a printing operation
|
||||
*/
|
||||
function usePrintingProgress({
|
||||
title,
|
||||
outputId,
|
||||
endpoint
|
||||
}: {
|
||||
title: string;
|
||||
outputId?: number;
|
||||
endpoint: ApiEndpoints;
|
||||
}) {
|
||||
const api = useApi();
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!!outputId) {
|
||||
setLoading(true);
|
||||
showNotification({
|
||||
id: `printing-progress-${endpoint}-${outputId}`,
|
||||
title: title,
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
withCloseButton: false,
|
||||
message: <ProgressBar size='lg' value={0} progressLabel />
|
||||
});
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [outputId, endpoint, title]);
|
||||
|
||||
const progress = useQuery({
|
||||
enabled: !!outputId && loading,
|
||||
refetchInterval: 750,
|
||||
queryKey: ['printingProgress', endpoint, outputId],
|
||||
queryFn: () =>
|
||||
api
|
||||
.get(apiUrl(endpoint, outputId))
|
||||
.then((response) => {
|
||||
const data = response?.data ?? {};
|
||||
|
||||
if (data.pk && data.pk == outputId) {
|
||||
if (data.complete) {
|
||||
setLoading(false);
|
||||
notifications.hide(`printing-progress-${endpoint}-${outputId}`);
|
||||
notifications.hide('print-success');
|
||||
|
||||
notifications.show({
|
||||
id: 'print-success',
|
||||
title: t`Printing`,
|
||||
message: t`Printing completed successfully`,
|
||||
color: 'green',
|
||||
icon: <IconCircleCheck />
|
||||
});
|
||||
|
||||
if (data.output) {
|
||||
const url = generateUrl(data.output);
|
||||
window.open(url.toString(), '_blank');
|
||||
}
|
||||
} else {
|
||||
notifications.update({
|
||||
id: `printing-progress-${endpoint}-${outputId}`,
|
||||
autoClose: false,
|
||||
withCloseButton: false,
|
||||
message: (
|
||||
<ProgressBar
|
||||
size='lg'
|
||||
value={data.progress}
|
||||
maximum={data.items}
|
||||
progressLabel
|
||||
/>
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
})
|
||||
.catch(() => {
|
||||
notifications.hide(`printing-progress-${endpoint}-${outputId}`);
|
||||
setLoading(false);
|
||||
return {};
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function PrintingActions({
|
||||
items,
|
||||
@ -142,16 +47,14 @@ export function PrintingActions({
|
||||
const [labelId, setLabelId] = useState<number | undefined>(undefined);
|
||||
const [reportId, setReportId] = useState<number | undefined>(undefined);
|
||||
|
||||
const labelProgress = usePrintingProgress({
|
||||
const labelProgress = useDataOutput({
|
||||
title: t`Printing Labels`,
|
||||
outputId: labelId,
|
||||
endpoint: ApiEndpoints.label_output
|
||||
id: labelId
|
||||
});
|
||||
|
||||
const reportProgress = usePrintingProgress({
|
||||
const reportProgress = useDataOutput({
|
||||
title: t`Printing Reports`,
|
||||
outputId: reportId,
|
||||
endpoint: ApiEndpoints.report_output
|
||||
id: reportId
|
||||
});
|
||||
|
||||
// Fetch available printing fields via OPTIONS request
|
||||
|
@ -21,12 +21,12 @@ import {
|
||||
IconCalendarMonth,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconDownload,
|
||||
IconFilter
|
||||
} from '@tabler/icons-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { CalendarState } from '../../hooks/UseCalendar';
|
||||
import { useLocalState } from '../../states/LocalState';
|
||||
import { DownloadAction } from '../../tables/DownloadAction';
|
||||
import type { TableFilter } from '../../tables/Filter';
|
||||
import { FilterSelectDrawer } from '../../tables/FilterSelectDrawer';
|
||||
import { TableSearchInput } from '../../tables/Search';
|
||||
@ -35,7 +35,6 @@ import { ActionButton } from '../buttons/ActionButton';
|
||||
import { StylishText } from '../items/StylishText';
|
||||
|
||||
export interface InvenTreeCalendarProps extends CalendarOptions {
|
||||
downloadData?: (fileFormat: string) => void;
|
||||
enableDownload?: boolean;
|
||||
enableFilters?: boolean;
|
||||
enableSearch?: boolean;
|
||||
@ -45,7 +44,6 @@ export interface InvenTreeCalendarProps extends CalendarOptions {
|
||||
}
|
||||
|
||||
export default function Calendar({
|
||||
downloadData,
|
||||
enableDownload,
|
||||
enableFilters = false,
|
||||
enableSearch,
|
||||
@ -88,6 +86,7 @@ export default function Calendar({
|
||||
|
||||
return (
|
||||
<>
|
||||
{state.exportModal.modal}
|
||||
{enableFilters && filters && (filters?.length ?? 0) > 0 && (
|
||||
<Boundary label={`InvenTreeCalendarFilterDrawer-${state.name}`}>
|
||||
<FilterSelectDrawer
|
||||
@ -154,7 +153,7 @@ export default function Calendar({
|
||||
variant='transparent'
|
||||
aria-label='calendar-select-filters'
|
||||
>
|
||||
<Tooltip label={t`Calendar Filters`}>
|
||||
<Tooltip label={t`Calendar Filters`} position='top-end'>
|
||||
<IconFilter
|
||||
onClick={() => setFiltersVisible(!filtersVisible)}
|
||||
/>
|
||||
@ -163,10 +162,14 @@ export default function Calendar({
|
||||
</Indicator>
|
||||
)}
|
||||
{enableDownload && (
|
||||
<DownloadAction
|
||||
key='download-action'
|
||||
downloadCallback={downloadData}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant='transparent'
|
||||
aria-label='calendar-export-data'
|
||||
>
|
||||
<Tooltip label={t`Download data`} position='top-end'>
|
||||
<IconDownload onClick={state.exportModal.open} />
|
||||
</Tooltip>
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
@ -80,6 +80,7 @@ export interface ApiFormProps {
|
||||
pk?: number | string;
|
||||
pk_field?: string;
|
||||
pathParams?: PathParams;
|
||||
queryParams?: URLSearchParams;
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
fields?: ApiFormFieldSet;
|
||||
focus?: string;
|
||||
@ -123,8 +124,14 @@ export function OptionsApiForm({
|
||||
const id = useId(pId);
|
||||
|
||||
const url = useMemo(
|
||||
() => constructFormUrl(props.url, props.pk, props.pathParams),
|
||||
[props.url, props.pk, props.pathParams]
|
||||
() =>
|
||||
constructFormUrl(
|
||||
props.url,
|
||||
props.pk,
|
||||
props.pathParams,
|
||||
props.queryParams
|
||||
),
|
||||
[props.url, props.pk, props.pathParams, props.queryParams]
|
||||
);
|
||||
|
||||
const optionsQuery = useQuery({
|
||||
@ -252,7 +259,13 @@ export function ApiForm({
|
||||
|
||||
// Cache URL
|
||||
const url = useMemo(
|
||||
() => constructFormUrl(props.url, props.pk, props.pathParams),
|
||||
() =>
|
||||
constructFormUrl(
|
||||
props.url,
|
||||
props.pk,
|
||||
props.pathParams,
|
||||
props.queryParams
|
||||
),
|
||||
[props.url, props.pk, props.pathParams]
|
||||
);
|
||||
|
||||
@ -445,6 +458,7 @@ export function ApiForm({
|
||||
return api({
|
||||
method: method,
|
||||
url: url,
|
||||
params: method.toLowerCase() == 'get' ? jsonData : undefined,
|
||||
data: hasFiles ? formData : jsonData,
|
||||
timeout: timeout,
|
||||
headers: {
|
||||
|
@ -43,6 +43,11 @@ export function ChoiceField({
|
||||
// Update form values when the selected value changes
|
||||
const onChange = useCallback(
|
||||
(value: any) => {
|
||||
// Prevent blank values if the field is required
|
||||
if (definition.required && !value) {
|
||||
return;
|
||||
}
|
||||
|
||||
field.onChange(value);
|
||||
|
||||
// Run custom callback for this field (if provided)
|
||||
|
@ -6,6 +6,7 @@ export type ProgressBarProps = {
|
||||
maximum?: number;
|
||||
label?: string;
|
||||
progressLabel?: boolean;
|
||||
animated?: boolean;
|
||||
size?: string;
|
||||
};
|
||||
|
||||
@ -37,6 +38,7 @@ export function ProgressBar(props: Readonly<ProgressBarProps>) {
|
||||
color={progress < 100 ? 'orange' : progress > 100 ? 'blue' : 'green'}
|
||||
size={props.size ?? 'md'}
|
||||
radius='sm'
|
||||
animated={props.animated}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
@ -67,6 +67,9 @@ export enum ApiEndpoints {
|
||||
barcode_unlink = 'barcode/unlink/',
|
||||
barcode_generate = 'barcode/generate/',
|
||||
|
||||
// Data output endpoints
|
||||
data_output = 'data-output/',
|
||||
|
||||
// Data import endpoints
|
||||
import_session_list = 'importer/session/',
|
||||
import_session_accept_fields = 'importer/session/:id/accept_fields/',
|
||||
@ -191,10 +194,8 @@ export enum ApiEndpoints {
|
||||
// Template API endpoints
|
||||
label_list = 'label/template/',
|
||||
label_print = 'label/print/',
|
||||
label_output = 'label/output/',
|
||||
report_list = 'report/template/',
|
||||
report_print = 'report/print/',
|
||||
report_output = 'report/output/',
|
||||
report_snippet = 'report/snippet/',
|
||||
report_asset = 'report/asset/',
|
||||
|
||||
|
@ -14,9 +14,16 @@ import { invalidResponse, permissionDenied } from './notifications';
|
||||
export function constructFormUrl(
|
||||
url: ApiEndpoints | string,
|
||||
pk?: string | number,
|
||||
pathParams?: PathParams
|
||||
pathParams?: PathParams,
|
||||
queryParams?: URLSearchParams
|
||||
): string {
|
||||
return apiUrl(url, pk, pathParams);
|
||||
let formUrl = apiUrl(url, pk, pathParams);
|
||||
|
||||
if (queryParams) {
|
||||
formUrl += `?${queryParams.toString()}`;
|
||||
}
|
||||
|
||||
return formUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -7,7 +7,9 @@ import { api } from '../App';
|
||||
import type { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { showApiErrorMessage } from '../functions/notifications';
|
||||
import { apiUrl } from '../states/ApiState';
|
||||
import useDataExport from './UseDataExport';
|
||||
import { type FilterSetState, useFilterSet } from './UseFilterSet';
|
||||
import type { UseModalReturn } from './UseModal';
|
||||
|
||||
/*
|
||||
* Type definition for representing the state of a calendar:
|
||||
@ -44,6 +46,7 @@ export type CalendarState = {
|
||||
currentMonth: () => void;
|
||||
selectMonth: (date: DateValue) => void;
|
||||
query: UseQueryResult;
|
||||
exportModal: UseModalReturn;
|
||||
data: any;
|
||||
};
|
||||
|
||||
@ -69,7 +72,7 @@ export default function useCalendar({
|
||||
const [endDate, setEndDate] = useState<Date | null>(null);
|
||||
|
||||
// Generate a set of API query filters
|
||||
const queryFilters = useMemo(() => {
|
||||
const queryFilters: Record<string, any> = useMemo(() => {
|
||||
// Expand date range by one month, to ensure we capture all events
|
||||
|
||||
let params = {
|
||||
@ -144,6 +147,13 @@ export default function useCalendar({
|
||||
[ref]
|
||||
);
|
||||
|
||||
// Modal for exporting data from the calendar
|
||||
const exportModal = useDataExport({
|
||||
url: apiUrl(endpoint),
|
||||
enabled: true,
|
||||
filters: queryFilters
|
||||
});
|
||||
|
||||
return {
|
||||
name,
|
||||
filterSet,
|
||||
@ -160,6 +170,7 @@ export default function useCalendar({
|
||||
setStartDate,
|
||||
endDate,
|
||||
setEndDate,
|
||||
exportModal,
|
||||
query: query,
|
||||
data: query.data
|
||||
};
|
||||
|
125
src/frontend/src/hooks/UseDataExport.tsx
Normal file
125
src/frontend/src/hooks/UseDataExport.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
|
||||
import { useApi } from '../contexts/ApiContext';
|
||||
import { extractAvailableFields } from '../functions/forms';
|
||||
import useDataOutput from './UseDataOutput';
|
||||
import { useCreateApiFormModal } from './UseForm';
|
||||
|
||||
/**
|
||||
* Custom hook for managing data export functionality
|
||||
* This is intended to be used from a table or calendar view,
|
||||
* to export the data displayed in the table or calendar
|
||||
*/
|
||||
export default function useDataExport({
|
||||
url,
|
||||
enabled,
|
||||
filters,
|
||||
searchTerm
|
||||
}: {
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
filters: any;
|
||||
searchTerm?: string;
|
||||
}) {
|
||||
const api = useApi();
|
||||
|
||||
// Selected plugin to use for data export
|
||||
const [pluginKey, setPluginKey] = useState<string>('inventree-exporter');
|
||||
|
||||
const [exportId, setExportId] = useState<number | undefined>(undefined);
|
||||
|
||||
const progress = useDataOutput({
|
||||
title: t`Exporting Data`,
|
||||
id: exportId
|
||||
});
|
||||
|
||||
// Construct a set of export parameters
|
||||
const exportParams = useMemo(() => {
|
||||
const queryParams: Record<string, any> = {
|
||||
export: true
|
||||
};
|
||||
|
||||
if (!!pluginKey) {
|
||||
queryParams.export_plugin = pluginKey;
|
||||
}
|
||||
|
||||
// Add in any additional parameters which have a defined value
|
||||
for (const [key, value] of Object.entries(filters ?? {})) {
|
||||
if (value != undefined) {
|
||||
queryParams[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!!searchTerm) {
|
||||
queryParams.search = searchTerm;
|
||||
}
|
||||
|
||||
return queryParams;
|
||||
}, [pluginKey, filters, searchTerm]);
|
||||
|
||||
// Fetch available export fields via OPTIONS request
|
||||
const extraExportFields = useQuery({
|
||||
enabled: !!url && enabled,
|
||||
queryKey: ['export-fields', pluginKey, url, exportParams],
|
||||
gcTime: 500,
|
||||
queryFn: () =>
|
||||
api
|
||||
.options(url, {
|
||||
params: exportParams
|
||||
})
|
||||
.then((response: any) => {
|
||||
return extractAvailableFields(response, 'GET') || {};
|
||||
})
|
||||
.catch(() => {
|
||||
return {};
|
||||
})
|
||||
});
|
||||
|
||||
// Construct a field set for the export form
|
||||
const exportFields: ApiFormFieldSet = useMemo(() => {
|
||||
const extraFields: ApiFormFieldSet = extraExportFields.data || {};
|
||||
|
||||
const fields: ApiFormFieldSet = {
|
||||
export_format: {},
|
||||
export_plugin: {},
|
||||
...extraFields
|
||||
};
|
||||
|
||||
fields.export_format = {
|
||||
...fields.export_format,
|
||||
required: true
|
||||
};
|
||||
|
||||
fields.export_plugin = {
|
||||
...fields.export_plugin,
|
||||
required: true,
|
||||
onValueChange: (value: string) => {
|
||||
if (!!value) {
|
||||
setPluginKey(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return fields;
|
||||
}, [extraExportFields]);
|
||||
|
||||
// Modal for exporting data
|
||||
const exportModal = useCreateApiFormModal({
|
||||
url: url,
|
||||
queryParams: new URLSearchParams(exportParams),
|
||||
title: t`Export Data`,
|
||||
method: 'GET',
|
||||
fields: exportFields,
|
||||
submitText: t`Export`,
|
||||
successMessage: null,
|
||||
timeout: 30 * 1000,
|
||||
onFormSuccess: (response: any) => {
|
||||
setExportId(response.pk);
|
||||
setPluginKey('inventree-exporter');
|
||||
}
|
||||
});
|
||||
|
||||
return exportModal;
|
||||
}
|
109
src/frontend/src/hooks/UseDataOutput.tsx
Normal file
109
src/frontend/src/hooks/UseDataOutput.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { notifications, showNotification } from '@mantine/notifications';
|
||||
import { IconCircleCheck } from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ProgressBar } from '../components/items/ProgressBar';
|
||||
import { useApi } from '../contexts/ApiContext';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { generateUrl } from '../functions/urls';
|
||||
import { apiUrl } from '../states/ApiState';
|
||||
|
||||
/**
|
||||
* Hook for monitoring a data output process running on the server
|
||||
*/
|
||||
export default function useDataOutput({
|
||||
title,
|
||||
id
|
||||
}: {
|
||||
title: string;
|
||||
id?: number;
|
||||
}) {
|
||||
const api = useApi();
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!!id) {
|
||||
setLoading(true);
|
||||
showNotification({
|
||||
id: `data-output-${id}`,
|
||||
title: title,
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
withCloseButton: false,
|
||||
message: <ProgressBar size='lg' value={0} progressLabel />
|
||||
});
|
||||
} else setLoading(false);
|
||||
}, [id, title]);
|
||||
|
||||
const progress = useQuery({
|
||||
enabled: !!id && loading,
|
||||
refetchInterval: 500,
|
||||
queryKey: ['data-output', id, title],
|
||||
queryFn: () =>
|
||||
api
|
||||
.get(apiUrl(ApiEndpoints.data_output, id))
|
||||
.then((response) => {
|
||||
const data = response?.data ?? {};
|
||||
|
||||
if (data.complete) {
|
||||
setLoading(false);
|
||||
notifications.update({
|
||||
id: `data-output-${id}`,
|
||||
loading: false,
|
||||
autoClose: 2500,
|
||||
title: title,
|
||||
message: t`Process completed successfully`,
|
||||
color: 'green',
|
||||
icon: <IconCircleCheck />
|
||||
});
|
||||
|
||||
if (data.output) {
|
||||
const url = generateUrl(data.output);
|
||||
window.open(url.toString(), '_blank');
|
||||
}
|
||||
} else if (!!data.error) {
|
||||
setLoading(false);
|
||||
notifications.update({
|
||||
id: `data-output-${id}`,
|
||||
loading: false,
|
||||
autoClose: 2500,
|
||||
title: title,
|
||||
message: t`Process failed`,
|
||||
color: 'red'
|
||||
});
|
||||
} else {
|
||||
notifications.update({
|
||||
id: `data-output-${id}`,
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
withCloseButton: false,
|
||||
message: (
|
||||
<ProgressBar
|
||||
size='lg'
|
||||
maximum={data.total}
|
||||
value={data.progress}
|
||||
progressLabel={data.total > 0}
|
||||
animated
|
||||
/>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
notifications.update({
|
||||
id: `data-output-${id}`,
|
||||
loading: false,
|
||||
autoClose: 2500,
|
||||
title: title,
|
||||
message: t`Process failed`,
|
||||
color: 'red'
|
||||
});
|
||||
return {};
|
||||
})
|
||||
});
|
||||
}
|
@ -108,7 +108,7 @@ export function useEditApiFormModal(props: ApiFormModalProps) {
|
||||
props.successMessage === null
|
||||
? null
|
||||
: (props.successMessage ?? t`Item Updated`),
|
||||
method: 'PATCH'
|
||||
method: props.method ?? 'PATCH'
|
||||
}),
|
||||
[props]
|
||||
);
|
||||
@ -158,7 +158,7 @@ export function useDeleteApiFormModal(props: ApiFormModalProps) {
|
||||
const deleteProps = useMemo<ApiFormModalProps>(
|
||||
() => ({
|
||||
...props,
|
||||
method: 'DELETE',
|
||||
method: props.method ?? 'DELETE',
|
||||
submitText: t`Delete`,
|
||||
submitColor: 'red',
|
||||
successMessage:
|
||||
|
@ -15,7 +15,14 @@ export interface UseModalProps {
|
||||
closeOnClickOutside?: boolean;
|
||||
}
|
||||
|
||||
export function useModal(props: UseModalProps) {
|
||||
export interface UseModalReturn {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
modal: React.ReactElement;
|
||||
}
|
||||
|
||||
export function useModal(props: UseModalProps): UseModalReturn {
|
||||
const onOpen = useCallback(() => {
|
||||
props.onOpen?.();
|
||||
}, [props.onOpen]);
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
IconCpu,
|
||||
IconDevicesPc,
|
||||
IconExclamationCircle,
|
||||
IconFileDownload,
|
||||
IconFileUpload,
|
||||
IconList,
|
||||
IconListDetails,
|
||||
@ -69,7 +70,11 @@ const BarcodeScanHistoryTable = Loadable(
|
||||
lazy(() => import('../../../../tables/settings/BarcodeScanHistoryTable'))
|
||||
);
|
||||
|
||||
const ImportSesssionTable = Loadable(
|
||||
const ExportSessionTable = Loadable(
|
||||
lazy(() => import('../../../../tables/settings/ExportSessionTable'))
|
||||
);
|
||||
|
||||
const ImportSessionTable = Loadable(
|
||||
lazy(() => import('../../../../tables/settings/ImportSessionTable'))
|
||||
);
|
||||
|
||||
@ -114,7 +119,13 @@ export default function AdminCenter() {
|
||||
name: 'import',
|
||||
label: t`Data Import`,
|
||||
icon: <IconFileUpload />,
|
||||
content: <ImportSesssionTable />
|
||||
content: <ImportSessionTable />
|
||||
},
|
||||
{
|
||||
name: 'export',
|
||||
label: t`Data Export`,
|
||||
icon: <IconFileDownload />,
|
||||
content: <ExportSessionTable />
|
||||
},
|
||||
{
|
||||
name: 'barcode-history',
|
||||
|
@ -1,12 +1,6 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Accordion } from '@mantine/core';
|
||||
import { StylishText } from '../../../../components/items/StylishText';
|
||||
import { ApiEndpoints } from '../../../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../../../enums/ModelType';
|
||||
import {
|
||||
TemplateOutputTable,
|
||||
TemplateTable
|
||||
} from '../../../../tables/settings/TemplateTable';
|
||||
import { TemplateTable } from '../../../../tables/settings/TemplateTable';
|
||||
|
||||
function LabelTemplateTable() {
|
||||
return (
|
||||
@ -25,27 +19,5 @@ function LabelTemplateTable() {
|
||||
}
|
||||
|
||||
export default function LabelTemplatePanel() {
|
||||
return (
|
||||
<Accordion defaultValue={['templates']} multiple>
|
||||
<Accordion.Item value='templates'>
|
||||
<Accordion.Control>
|
||||
<StylishText size='lg'>{t`Label Templates`}</StylishText>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<LabelTemplateTable />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value='outputs'>
|
||||
<Accordion.Control>
|
||||
<StylishText size='lg'>{t`Generated Labels`}</StylishText>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<TemplateOutputTable
|
||||
endpoint={ApiEndpoints.label_output}
|
||||
withPlugins
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
return <LabelTemplateTable />;
|
||||
}
|
||||
|
@ -1,14 +1,8 @@
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import { Accordion } from '@mantine/core';
|
||||
import { YesNoButton } from '../../../../components/buttons/YesNoButton';
|
||||
import { StylishText } from '../../../../components/items/StylishText';
|
||||
import { ApiEndpoints } from '../../../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../../../enums/ModelType';
|
||||
import {
|
||||
TemplateOutputTable,
|
||||
TemplateTable
|
||||
} from '../../../../tables/settings/TemplateTable';
|
||||
import { TemplateTable } from '../../../../tables/settings/TemplateTable';
|
||||
|
||||
function ReportTemplateTable() {
|
||||
return (
|
||||
@ -40,24 +34,5 @@ function ReportTemplateTable() {
|
||||
}
|
||||
|
||||
export default function ReportTemplatePanel() {
|
||||
return (
|
||||
<Accordion defaultValue={['templates']} multiple>
|
||||
<Accordion.Item value='templates'>
|
||||
<Accordion.Control>
|
||||
<StylishText size='lg'>{t`Report Templates`}</StylishText>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<ReportTemplateTable />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value='outputs'>
|
||||
<Accordion.Control>
|
||||
<StylishText size='lg'>{t`Generated Reports`}</StylishText>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<TemplateOutputTable endpoint={ApiEndpoints.report_output} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
return <ReportTemplateTable />;
|
||||
}
|
||||
|
@ -1,42 +0,0 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
IconDownload,
|
||||
IconFileSpreadsheet,
|
||||
IconFileText,
|
||||
IconFileTypeCsv
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
ActionDropdown,
|
||||
type ActionDropdownItem
|
||||
} from '../components/items/ActionDropdown';
|
||||
|
||||
export function DownloadAction({
|
||||
downloadCallback
|
||||
}: Readonly<{
|
||||
downloadCallback?: (fileFormat: string) => void;
|
||||
}>) {
|
||||
const formatOptions = [
|
||||
{ value: 'csv', label: t`CSV`, icon: <IconFileTypeCsv /> },
|
||||
{ value: 'tsv', label: t`TSV`, icon: <IconFileText /> },
|
||||
{ value: 'xlsx', label: t`Excel (.xlsx)`, icon: <IconFileSpreadsheet /> }
|
||||
];
|
||||
|
||||
const actions: ActionDropdownItem[] = useMemo(() => {
|
||||
return formatOptions.map((format) => ({
|
||||
name: format.label,
|
||||
icon: format.icon,
|
||||
onClick: () => downloadCallback?.(format.value)
|
||||
}));
|
||||
}, [formatOptions, downloadCallback]);
|
||||
|
||||
return (
|
||||
<ActionDropdown
|
||||
tooltip={t`Download Data`}
|
||||
tooltipPosition='top-end'
|
||||
icon={<IconDownload />}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
}
|
@ -9,6 +9,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBarcode,
|
||||
IconDownload,
|
||||
IconExclamationCircle,
|
||||
IconFilter,
|
||||
IconRefresh,
|
||||
@ -22,11 +23,10 @@ import { Boundary } from '../components/Boundary';
|
||||
import { ActionButton } from '../components/buttons/ActionButton';
|
||||
import { ButtonMenu } from '../components/buttons/ButtonMenu';
|
||||
import { PrintingActions } from '../components/buttons/PrintingActions';
|
||||
import { useApi } from '../contexts/ApiContext';
|
||||
import useDataExport from '../hooks/UseDataExport';
|
||||
import { useDeleteApiFormModal } from '../hooks/UseForm';
|
||||
import type { TableState } from '../hooks/UseTable';
|
||||
import { TableColumnSelect } from './ColumnSelect';
|
||||
import { DownloadAction } from './DownloadAction';
|
||||
import type { TableFilter } from './Filter';
|
||||
import { FilterSelectDrawer } from './FilterSelectDrawer';
|
||||
import type { InvenTreeTableProps } from './InvenTreeTable';
|
||||
@ -52,48 +52,43 @@ export default function InvenTreeTableHeader({
|
||||
filters: TableFilter[];
|
||||
toggleColumn: (column: string) => void;
|
||||
}>) {
|
||||
const api = useApi();
|
||||
|
||||
// Filter list visibility
|
||||
const [filtersVisible, setFiltersVisible] = useState<boolean>(false);
|
||||
|
||||
const downloadData = (fileFormat: string) => {
|
||||
// Download entire dataset (no pagination)
|
||||
// Construct export filters
|
||||
const exportFilters = useMemo(() => {
|
||||
const filters: Record<string, any> = {};
|
||||
|
||||
const queryParams = {
|
||||
...tableProps.params
|
||||
};
|
||||
// Add in any additional parameters which have a defined value
|
||||
for (const [key, value] of Object.entries(tableProps.params ?? {})) {
|
||||
if (value != undefined) {
|
||||
filters[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Add in active filters
|
||||
if (tableState.filterSet.activeFilters) {
|
||||
tableState.filterSet.activeFilters.forEach((filter) => {
|
||||
queryParams[filter.name] = filter.value;
|
||||
filters[filter.name] = filter.value;
|
||||
});
|
||||
}
|
||||
|
||||
// Allow overriding of query parameters
|
||||
if (tableState.queryFilters) {
|
||||
for (const [key, value] of tableState.queryFilters) {
|
||||
queryParams[key] = value;
|
||||
if (value != undefined) {
|
||||
filters[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [tableProps.params, tableState.filterSet, tableState.queryFilters]);
|
||||
|
||||
// Add custom search term
|
||||
if (tableState.searchTerm) {
|
||||
queryParams.search = tableState.searchTerm;
|
||||
}
|
||||
|
||||
// Specify file format
|
||||
queryParams.export = fileFormat;
|
||||
|
||||
const downloadUrl = api.getUri({
|
||||
url: tableUrl,
|
||||
params: queryParams
|
||||
});
|
||||
|
||||
// Download file in a new window (to force download)
|
||||
window.open(downloadUrl, '_blank');
|
||||
};
|
||||
const exportModal = useDataExport({
|
||||
url: tableUrl ?? '',
|
||||
enabled: !!tableUrl && tableProps?.enableDownload != false,
|
||||
filters: exportFilters,
|
||||
searchTerm: tableState.searchTerm
|
||||
});
|
||||
|
||||
const deleteRecords = useDeleteApiFormModal({
|
||||
url: tableUrl ?? '',
|
||||
@ -149,6 +144,7 @@ export default function InvenTreeTableHeader({
|
||||
|
||||
return (
|
||||
<>
|
||||
{exportModal.modal}
|
||||
{deleteRecords.modal}
|
||||
{tableProps.enableFilters && (filters.length ?? 0) > 0 && (
|
||||
<Boundary label={`InvenTreeTableFilterDrawer-${tableState.tableKey}`}>
|
||||
@ -245,11 +241,12 @@ export default function InvenTreeTableHeader({
|
||||
</ActionIcon>
|
||||
</Indicator>
|
||||
)}
|
||||
{tableProps.enableDownload && (
|
||||
<DownloadAction
|
||||
key='download-action'
|
||||
downloadCallback={downloadData}
|
||||
/>
|
||||
{tableUrl && tableProps.enableDownload && (
|
||||
<ActionIcon variant='transparent' aria-label='table-export-data'>
|
||||
<Tooltip label={t`Download data`} position='bottom'>
|
||||
<IconDownload onClick={exportModal.open} />
|
||||
</Tooltip>
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
60
src/frontend/src/tables/settings/ExportSessionTable.tsx
Normal file
60
src/frontend/src/tables/settings/ExportSessionTable.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { useMemo } from 'react';
|
||||
import { AttachmentLink } from '../../components/items/AttachmentLink';
|
||||
import { RenderUser } from '../../components/render/User';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import type { TableColumn } from '../Column';
|
||||
import { DateColumn } from '../ColumnRenderers';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
|
||||
export default function ExportSessionTable() {
|
||||
const table = useTable('exportsession');
|
||||
|
||||
const columns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'output',
|
||||
sortable: false,
|
||||
render: (record: any) => <AttachmentLink attachment={record.output} />
|
||||
},
|
||||
{
|
||||
accessor: 'output_type',
|
||||
title: t`Output Type`,
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
accessor: 'plugin',
|
||||
title: t`Plugin`,
|
||||
sortable: true
|
||||
},
|
||||
DateColumn({
|
||||
accessor: 'created',
|
||||
title: t`Exported On`,
|
||||
sortable: true
|
||||
}),
|
||||
{
|
||||
accessor: 'user',
|
||||
sortable: true,
|
||||
title: t`User`,
|
||||
render: (record: any) => RenderUser({ instance: record.user_detail })
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.data_output)}
|
||||
tableState={table}
|
||||
columns={columns}
|
||||
props={{
|
||||
enableBulkDelete: true,
|
||||
enableSelection: true,
|
||||
enableSearch: false
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@ -22,7 +22,7 @@ import { StatusFilterOptions, type TableFilter, UserFilter } from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import { type RowAction, RowDeleteAction } from '../RowActions';
|
||||
|
||||
export default function ImportSesssionTable() {
|
||||
export default function ImportSessionTable() {
|
||||
const table = useTable('importsession');
|
||||
|
||||
const [opened, setOpened] = useState<boolean>(false);
|
||||
@ -71,6 +71,7 @@ export default function ImportSesssionTable() {
|
||||
{
|
||||
accessor: 'user',
|
||||
sortable: false,
|
||||
title: t`User`,
|
||||
render: (record: any) => RenderUser({ instance: record.user_detail })
|
||||
},
|
||||
{
|
||||
|
@ -42,7 +42,7 @@ import { useTable } from '../../hooks/UseTable';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import type { TableColumn } from '../Column';
|
||||
import { BooleanColumn, DateColumn } from '../ColumnRenderers';
|
||||
import { BooleanColumn } from '../ColumnRenderers';
|
||||
import type { TableFilter } from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import {
|
||||
@ -401,78 +401,3 @@ export function TemplateTable({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TemplateOutputTable({
|
||||
endpoint,
|
||||
withPlugins = false
|
||||
}: {
|
||||
endpoint: ApiEndpoints;
|
||||
withPlugins?: boolean;
|
||||
}) {
|
||||
const table = useTable(`${endpoint}-output`);
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'output',
|
||||
sortable: false,
|
||||
switchable: false,
|
||||
title: t`Report Output`,
|
||||
noWrap: true,
|
||||
noContext: true,
|
||||
render: (record: any) => {
|
||||
if (record.output) {
|
||||
return <AttachmentLink attachment={record.output} />;
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'template_detail.name',
|
||||
sortable: false,
|
||||
switchable: false,
|
||||
title: t`Template`
|
||||
},
|
||||
{
|
||||
accessor: 'model_type',
|
||||
sortable: true,
|
||||
switchable: false,
|
||||
title: t`Model Type`
|
||||
},
|
||||
DateColumn({
|
||||
accessor: 'created',
|
||||
title: t`Creation Date`,
|
||||
switchable: false,
|
||||
sortable: true
|
||||
}),
|
||||
{
|
||||
accessor: 'plugin',
|
||||
title: t`Plugin`,
|
||||
hidden: !withPlugins
|
||||
},
|
||||
{
|
||||
accessor: 'user_detail.username',
|
||||
sortable: true,
|
||||
ordering: 'user',
|
||||
title: t`Created By`
|
||||
}
|
||||
];
|
||||
}, [withPlugins]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<InvenTreeTable
|
||||
url={apiUrl(endpoint)}
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
enableSearch: false,
|
||||
enableColumnSwitching: false,
|
||||
enableSelection: true,
|
||||
enableBulkDelete: true
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -97,6 +97,11 @@ test('Build Order - Calendar', async ({ page }) => {
|
||||
await navigate(page, 'manufacturing/index/buildorders');
|
||||
await activateCalendarView(page);
|
||||
|
||||
// Export calendar data
|
||||
await page.getByLabel('calendar-export-data').click();
|
||||
await page.getByRole('button', { name: 'Export', exact: true }).click();
|
||||
await page.getByText('Process completed successfully').waitFor();
|
||||
|
||||
// Check "part category" filter
|
||||
await page.getByLabel('calendar-select-filters').click();
|
||||
await page.getByRole('button', { name: 'Add Filter' }).click();
|
||||
@ -104,6 +109,9 @@ test('Build Order - Calendar', async ({ page }) => {
|
||||
await page.getByRole('option', { name: 'Category', exact: true }).click();
|
||||
await page.getByLabel('related-field-filter-category').click();
|
||||
await page.getByText('Part category, level 1').waitFor();
|
||||
|
||||
// Required because we downloaded a file
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('Build Order - Edit', async ({ page }) => {
|
||||
|
120
src/frontend/tests/pui_exporting.spec.ts
Normal file
120
src/frontend/tests/pui_exporting.spec.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import test from '@playwright/test';
|
||||
import { globalSearch, loadTab, navigate } from './helpers';
|
||||
import { doQuickLogin } from './login';
|
||||
|
||||
// Helper function to open the export data dialog
|
||||
const openExportDialog = async (page) => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByLabel('table-export-data').click();
|
||||
await page.getByText('Export Format *', { exact: true }).waitFor();
|
||||
await page.getByText('Export Plugin *', { exact: true }).waitFor();
|
||||
};
|
||||
|
||||
// Test data export for various order types
|
||||
test('Exporting - Orders', async ({ page }) => {
|
||||
await doQuickLogin(page, 'steven', 'wizardstaff');
|
||||
|
||||
// Download list of purchase orders
|
||||
await navigate(page, 'purchasing/index/purchase-orders');
|
||||
|
||||
await openExportDialog(page);
|
||||
|
||||
// // Select export format
|
||||
await page.getByLabel('choice-field-export_format').click();
|
||||
await page.getByRole('option', { name: 'Excel' }).click();
|
||||
|
||||
// // Select export plugin (should only be one option here)
|
||||
await page.getByLabel('choice-field-export_plugin').click();
|
||||
await page.getByRole('option', { name: 'InvenTree Exporter' }).click();
|
||||
|
||||
// // Export the data
|
||||
await page.getByRole('button', { name: 'Export', exact: true }).click();
|
||||
await page.getByText('Process completed successfully').waitFor();
|
||||
|
||||
// Download list of purchase order items
|
||||
await page.getByRole('cell', { name: 'PO0011' }).click();
|
||||
await loadTab(page, 'Line Items');
|
||||
await openExportDialog(page);
|
||||
await page.getByRole('button', { name: 'Export', exact: true }).click();
|
||||
await page.getByText('Process completed successfully').waitFor();
|
||||
|
||||
// Download a list of build orders
|
||||
await navigate(page, 'manufacturing/index/buildorders/');
|
||||
await openExportDialog(page);
|
||||
await page.getByRole('button', { name: 'Export', exact: true }).click();
|
||||
await page.getByText('Process completed successfully').waitFor();
|
||||
|
||||
// Finally, navigate to the admin center and ensure the export data is available
|
||||
await navigate(page, 'settings/admin/export/');
|
||||
|
||||
// Check for expected outputs
|
||||
await page
|
||||
.getByRole('link', { name: /InvenTree_Build_.*\.csv/ })
|
||||
.first()
|
||||
.waitFor();
|
||||
await page
|
||||
.getByRole('link', { name: /InvenTree_PurchaseOrder_.*\.xlsx/ })
|
||||
.first()
|
||||
.waitFor();
|
||||
await page
|
||||
.getByRole('link', { name: /InvenTree_PurchaseOrderLineItem_.*\.csv/ })
|
||||
.first()
|
||||
.waitFor();
|
||||
|
||||
// Delete all exported file outputs
|
||||
await page.getByRole('cell', { name: 'Select all records' }).click();
|
||||
await page.getByLabel('action-button-delete-selected').click();
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await page.getByText('Items Deleted').waitFor();
|
||||
});
|
||||
|
||||
// Test for custom BOM exporter
|
||||
test('Exporting - BOM', async ({ page }) => {
|
||||
await doQuickLogin(page, 'steven', 'wizardstaff');
|
||||
|
||||
await globalSearch(page, 'MAST');
|
||||
await page.getByLabel('search-group-results-part').locator('a').click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await loadTab(page, 'Bill of Materials');
|
||||
await openExportDialog(page);
|
||||
|
||||
// Select export format
|
||||
await page.getByLabel('choice-field-export_format').click();
|
||||
await page.getByRole('option', { name: 'TSV' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Select BOM plugin
|
||||
await page.getByLabel('choice-field-export_plugin').click();
|
||||
await page.getByRole('option', { name: 'BOM Exporter' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Now, adjust the settings specific to the BOM exporter
|
||||
await page.getByLabel('number-field-export_levels').fill('3');
|
||||
await page
|
||||
.locator('label')
|
||||
.filter({ hasText: 'Pricing DataInclude part' })
|
||||
.locator('span')
|
||||
.nth(1)
|
||||
.click();
|
||||
await page
|
||||
.locator('label')
|
||||
.filter({ hasText: 'Parameter DataInclude part' })
|
||||
.locator('span')
|
||||
.nth(1)
|
||||
.click();
|
||||
|
||||
await page.getByRole('button', { name: 'Export', exact: true }).click();
|
||||
await page.getByText('Process completed successfully').waitFor();
|
||||
|
||||
// Finally, navigate to the admin center and ensure the export data is available
|
||||
await navigate(page, 'settings/admin/export/');
|
||||
|
||||
await page.getByRole('cell', { name: 'bom-exporter' }).first().waitFor();
|
||||
await page
|
||||
.getByRole('link', { name: /InvenTree_BomItem_.*\.tsv/ })
|
||||
.first()
|
||||
.waitFor();
|
||||
|
||||
// Required because we downloaded a file
|
||||
await page.context().close();
|
||||
});
|
@ -41,7 +41,7 @@ test('Label Printing', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Print', exact: true }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Print', exact: true }).click();
|
||||
|
||||
await page.getByText('Printing completed successfully').first().waitFor();
|
||||
await page.getByText('Process completed successfully').first().waitFor();
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
@ -77,7 +77,7 @@ test('Report Printing', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Print', exact: true }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Print', exact: true }).click();
|
||||
|
||||
await page.getByText('Printing completed successfully').first().waitFor();
|
||||
await page.getByText('Process completed successfully').first().waitFor();
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
|
Reference in New Issue
Block a user