2
0
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:
Oliver
2025-03-18 11:35:44 +11:00
committed by GitHub
parent 947a1bcc3a
commit 8d51aa1563
122 changed files with 2434 additions and 1504 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
}
/**

View File

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

View 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;
}

View 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 {};
})
});
}

View File

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

View File

@ -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]);

View File

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

View File

@ -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 />;
}

View File

@ -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 />;
}

View File

@ -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}
/>
);
}

View File

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

View 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
}}
/>
</>
);
}

View File

@ -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 })
},
{

View File

@ -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
}}
/>
</>
);
}

View File

@ -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 }) => {

View 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();
});

View File

@ -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();
});