@olenbetong/appframe-ds 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -0
- package/package.json +50 -6
- package/scripts/copyAssets.mjs +13 -0
- package/src/AfLookup/LookupCombobox.tsx +3 -1
- package/src/filter/ChipListInput.tsx +79 -0
- package/src/filter/FieldFilterPanel.css +127 -0
- package/src/filter/FieldFilterPanel.tsx +309 -0
- package/src/filter/FilterBuilder.css +152 -0
- package/src/filter/FilterBuilder.test.tsx +153 -0
- package/src/filter/FilterBuilder.tsx +435 -0
- package/src/filter/FilterEditor.css +231 -0
- package/src/filter/FilterEditor.test.tsx +259 -0
- package/src/filter/FilterEditor.tsx +74 -0
- package/src/filter/FilterGroupEditor.tsx +160 -0
- package/src/filter/FilterNameDialog.css +22 -0
- package/src/filter/FilterNameDialog.tsx +81 -0
- package/src/filter/FilterRow.tsx +117 -0
- package/src/filter/FilterShareDialog.css +28 -0
- package/src/filter/FilterShareDialog.tsx +201 -0
- package/src/filter/FilterStringField.tsx +77 -0
- package/src/filter/FilterValueEditor.tsx +220 -0
- package/src/filter/SavedFilterTree.css +107 -0
- package/src/filter/SavedFilterTree.test.tsx +94 -0
- package/src/filter/SavedFilterTree.tsx +222 -0
- package/src/filter/fieldFilter.test.ts +158 -0
- package/src/filter/fieldFilter.ts +139 -0
- package/src/filter/index.ts +27 -0
- package/src/filter/types.ts +38 -0
- package/src/filter/useDistinctValues.test.ts +102 -0
- package/src/filter/useDistinctValues.ts +230 -0
- package/src/global.d.ts +11 -0
- package/src/grid/AfGridColumnsPanel.tsx +118 -0
- package/src/grid/AfGridContext.tsx +51 -0
- package/src/grid/AfGridError.tsx +46 -0
- package/src/grid/AfHeaderFilterCell.tsx +151 -0
- package/src/grid/AfHeaderFilterPanel.tsx +111 -0
- package/src/grid/Toolbar.tsx +233 -0
- package/src/grid/editing.ts +90 -0
- package/src/grid/filter.ts +78 -0
- package/src/grid/formatters.ts +48 -0
- package/src/grid/index.css +187 -0
- package/src/grid/index.tsx +376 -0
- package/src/grid/license.ts +48 -0
- package/src/grid/localization.ts +369 -0
- package/src/grid/slots/index.tsx +275 -0
- package/src/grid/slots/slots.css +53 -0
- package/src/grid/theme.tsx +185 -0
- package/src/grid/useAfColumns.tsx +313 -0
- package/src/grid/useAfCurrentIndex.ts +55 -0
- package/src/grid/useAfData.ts +20 -0
- package/src/grid/useAfFilter.ts +160 -0
- package/src/grid/useAfFilterFields.ts +55 -0
- package/src/grid/useAfGridApi.ts +66 -0
- package/src/grid/useAfKeyBindings.ts +36 -0
- package/src/grid/useAfPagination.ts +53 -0
- package/src/grid/useAfPersistedState.ts +222 -0
- package/src/grid/useAfRowEditModel.ts +137 -0
- package/src/grid/useAfSortModel.ts +72 -0
- package/src/index.ts +1 -0
- package/src/input/InputAdornments.tsx +6 -1
- package/src/report-downloader/ReportDownloader.css +67 -0
- package/src/report-downloader/components/StatusItem.tsx +86 -0
- package/src/report-downloader/components/StatusList.tsx +28 -0
- package/src/report-downloader/index.ts +41 -0
- package/src/report-downloader/status.tsx +92 -0
- package/src/test/setup.ts +8 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type DataGridProProps as DataGridProps,
|
|
3
|
+
type GridApi,
|
|
4
|
+
type GridEventListener,
|
|
5
|
+
type GridRowId,
|
|
6
|
+
GridRowModes,
|
|
7
|
+
gridFocusCellSelector,
|
|
8
|
+
gridVisibleColumnFieldsSelector,
|
|
9
|
+
} from "@mui/x-data-grid-pro";
|
|
10
|
+
import type { DataObject } from "@olenbetong/appframe-data";
|
|
11
|
+
import { setDataObjectField } from "@olenbetong/appframe-react";
|
|
12
|
+
|
|
13
|
+
import type React from "react";
|
|
14
|
+
import { useCallback, useEffect } from "react";
|
|
15
|
+
|
|
16
|
+
function localDateAsUtcDate(date: Date) {
|
|
17
|
+
const utcDate = new Date(date);
|
|
18
|
+
console.log("year", utcDate, utcDate.getUTCFullYear(), date.getFullYear());
|
|
19
|
+
utcDate.setUTCFullYear(date.getFullYear());
|
|
20
|
+
console.log("month", utcDate, utcDate.getUTCMonth(), date.getMonth());
|
|
21
|
+
utcDate.setUTCMonth(date.getMonth());
|
|
22
|
+
console.log("date", utcDate, utcDate.getUTCDate(), date.getDate());
|
|
23
|
+
utcDate.setUTCDate(date.getDate());
|
|
24
|
+
console.log("hours", utcDate, utcDate.getUTCHours(), date.getHours());
|
|
25
|
+
utcDate.setUTCHours(date.getHours());
|
|
26
|
+
console.log("done", utcDate);
|
|
27
|
+
|
|
28
|
+
return utcDate;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type AfRowEditModelProps<T extends Record<string, unknown> = Record<string, unknown>> = {
|
|
32
|
+
apiRef: React.RefObject<GridApi | null>;
|
|
33
|
+
dataObject: DataObject<T>;
|
|
34
|
+
idField: string;
|
|
35
|
+
disableEdit: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const uid: any = globalThis.af?.data?.uid ?? Symbol();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Binds a MUI DataGridPro row editing model to a data object. Editing will not affect the
|
|
42
|
+
* data object until a save event is triggered. If saving fails, will attempt to go back
|
|
43
|
+
* into edit mode with the unsaved changes.
|
|
44
|
+
*/
|
|
45
|
+
export function useAfRowEditModel<T extends Record<string, unknown> = Record<string, unknown>>({
|
|
46
|
+
apiRef,
|
|
47
|
+
dataObject,
|
|
48
|
+
idField,
|
|
49
|
+
disableEdit,
|
|
50
|
+
}: AfRowEditModelProps<T>): Partial<DataGridProps> {
|
|
51
|
+
let processRowUpdate = useCallback(
|
|
52
|
+
async (newRow: any, oldRow: any) => {
|
|
53
|
+
let { id, isNew, ...record } = newRow;
|
|
54
|
+
id = (record as any)[(af as any).data.uid] ?? (record as any)[idField] ?? id;
|
|
55
|
+
|
|
56
|
+
if (isNew) {
|
|
57
|
+
let savedRow = await dataObject.addRow(newRow);
|
|
58
|
+
if (savedRow) {
|
|
59
|
+
Object.assign(savedRow, { isNew: false });
|
|
60
|
+
return savedRow;
|
|
61
|
+
} else {
|
|
62
|
+
return oldRow;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let index = dataObject.findIndex((row) => (row as any)[uid] === id || (row as any)[idField] === id);
|
|
67
|
+
if (index < 0) {
|
|
68
|
+
throw Error(`Failed to find the row to save in the data object (${idField}=${id})`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
dataObject.setCurrentIndex(index);
|
|
72
|
+
for (let field in record as Partial<T>) {
|
|
73
|
+
if (dataObject.getFields(field)) {
|
|
74
|
+
let value = record[field];
|
|
75
|
+
if (value === undefined) continue;
|
|
76
|
+
if (value instanceof Date && value.getTime() !== (dataObject.currentRow(field) as Date | null)?.getTime()) {
|
|
77
|
+
value = localDateAsUtcDate(value);
|
|
78
|
+
}
|
|
79
|
+
setDataObjectField<T, keyof T>(dataObject, field, value);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let result = await dataObject.endEdit();
|
|
84
|
+
let savedNewRow = { id, isNew: false };
|
|
85
|
+
if (result === false) {
|
|
86
|
+
Object.assign(savedNewRow, dataObject.getData(index));
|
|
87
|
+
} else {
|
|
88
|
+
Object.assign(savedNewRow, result);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return savedNewRow;
|
|
92
|
+
},
|
|
93
|
+
[dataObject, idField],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
// The row added with addRow is not managed by data objects, but by the grid, so we need
|
|
98
|
+
// to remove it manually if editing is cancelled
|
|
99
|
+
const handleRowModesModelChange: GridEventListener<"rowModesModelChange"> = (model) => {
|
|
100
|
+
for (let id of Object.keys(model)) {
|
|
101
|
+
if (model[id].mode === GridRowModes.View && (model[id] as any)?.ignoreModifications === true) {
|
|
102
|
+
let row = apiRef.current?.getRow(id);
|
|
103
|
+
if (row?.isNew) {
|
|
104
|
+
let focusBefore = gridFocusCellSelector(apiRef);
|
|
105
|
+
|
|
106
|
+
apiRef.current?.updateRows([{ [(af as any).data.uid]: id, id, _action: "delete" } as any]);
|
|
107
|
+
|
|
108
|
+
setTimeout(() => {
|
|
109
|
+
if (focusBefore && dataObject.getDataLength()) {
|
|
110
|
+
let lastRow = dataObject.getData(dataObject.getDataLength() - 1);
|
|
111
|
+
apiRef.current?.setCellFocus(lastRow[uid] as GridRowId, focusBefore.field);
|
|
112
|
+
} else {
|
|
113
|
+
apiRef.current?.setColumnHeaderFocus(gridVisibleColumnFieldsSelector(apiRef)[0]);
|
|
114
|
+
}
|
|
115
|
+
}, 1);
|
|
116
|
+
} else {
|
|
117
|
+
// MUI grid only reverts changes on editable fields, while for example lookups can have edited
|
|
118
|
+
// values that are not editable, so we need to manually revert those changes
|
|
119
|
+
let index = dataObject.findIndex((r) => (r as any)[uid] === id || (r as any)[idField] === id);
|
|
120
|
+
apiRef.current?.updateRows([
|
|
121
|
+
{ [(af as any).data.uid]: id, id, ...(dataObject.getData(index) as any) } as any,
|
|
122
|
+
]);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return apiRef.current?.subscribeEvent("rowModesModelChange", handleRowModesModelChange);
|
|
129
|
+
}, [dataObject, apiRef, idField]);
|
|
130
|
+
|
|
131
|
+
return disableEdit
|
|
132
|
+
? {}
|
|
133
|
+
: {
|
|
134
|
+
editMode: "row",
|
|
135
|
+
processRowUpdate,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { DataGridProProps, GridCallbackDetails, GridSortModel } from "@mui/x-data-grid-pro";
|
|
2
|
+
import type { DataObject, RetrieveParameters, SortOrder } from "@olenbetong/appframe-data";
|
|
3
|
+
import { useParameter } from "@olenbetong/appframe-react";
|
|
4
|
+
import { useCallback, useMemo } from "react";
|
|
5
|
+
import type { AfGridColDef, AfGridColumn } from "./useAfColumns";
|
|
6
|
+
|
|
7
|
+
const convertMap: Record<SortOrder, "asc" | "desc"> = {
|
|
8
|
+
asc: "asc",
|
|
9
|
+
desc: "desc",
|
|
10
|
+
ascnullslast: "asc",
|
|
11
|
+
descnullsfirst: "desc",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function afSortOrderToMuiSortModel(
|
|
15
|
+
dataObject: DataObject<any>,
|
|
16
|
+
sortOrder?: RetrieveParameters<any>["sortOrder"],
|
|
17
|
+
): GridSortModel {
|
|
18
|
+
return (
|
|
19
|
+
sortOrder?.flatMap((x) => {
|
|
20
|
+
let itemFields = Object.keys(x);
|
|
21
|
+
let items: GridSortModel = itemFields
|
|
22
|
+
.filter((field) => dataObject.getFields(field))
|
|
23
|
+
.map((field) => ({ field, sort: convertMap[x[field]] }));
|
|
24
|
+
|
|
25
|
+
return items;
|
|
26
|
+
}) ?? []
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Converts between DataObject sort parameter and DataGrid sort model.
|
|
32
|
+
* Updates the data when the sort model changes.
|
|
33
|
+
*
|
|
34
|
+
* @param dataObject Data object grid is used with
|
|
35
|
+
*/
|
|
36
|
+
export function useAfSortModel({
|
|
37
|
+
dataObject,
|
|
38
|
+
columns,
|
|
39
|
+
disableServerSorting = false,
|
|
40
|
+
}: {
|
|
41
|
+
dataObject: DataObject<any>;
|
|
42
|
+
columns: AfGridColumn[];
|
|
43
|
+
disableServerSorting?: boolean;
|
|
44
|
+
}): Partial<DataGridProProps> {
|
|
45
|
+
let sortOrder = useParameter(dataObject, "sortOrder");
|
|
46
|
+
let sortModel = useMemo(() => {
|
|
47
|
+
let sortModel = afSortOrderToMuiSortModel(dataObject, sortOrder);
|
|
48
|
+
sortModel = sortModel.filter((s) => columns.find((c) => (typeof c === "string" ? c : c.field) === s.field));
|
|
49
|
+
|
|
50
|
+
return sortModel;
|
|
51
|
+
}, [dataObject, sortOrder, columns]);
|
|
52
|
+
|
|
53
|
+
let onSortModelChange = useCallback(
|
|
54
|
+
(sortModel: GridSortModel, details: GridCallbackDetails) => {
|
|
55
|
+
let sortOrder: Record<string, SortOrder>[] = [];
|
|
56
|
+
for (let item of sortModel) {
|
|
57
|
+
let colDef = columns.find((c) => typeof c !== "string" && c.field === item.field) as AfGridColDef | undefined;
|
|
58
|
+
if (item.sort) {
|
|
59
|
+
sortOrder.push({
|
|
60
|
+
[colDef?.sortColumn ?? item.field]: item.sort as SortOrder,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
dataObject.setParameter("sortOrder", sortOrder);
|
|
66
|
+
dataObject.refreshDataSource();
|
|
67
|
+
},
|
|
68
|
+
[dataObject, columns],
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return disableServerSorting ? {} : { sortingMode: "server", sortModel, onSortModelChange };
|
|
72
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./AfLookup/index.js";
|
|
|
2
2
|
export * from "./autocomplete/index.js";
|
|
3
3
|
export * from "./binding/index.js";
|
|
4
4
|
export * from "./container/index.js";
|
|
5
|
+
export * from "./filter/index.js";
|
|
5
6
|
export * from "./input/index.js";
|
|
6
7
|
export * from "./layout/index.js";
|
|
7
8
|
export * from "./list/index.js";
|
|
@@ -2,7 +2,7 @@ import "./InputAdornments.css";
|
|
|
2
2
|
|
|
3
3
|
import { Input, type InputProps } from "@digdir/designsystemet-react";
|
|
4
4
|
import clsx from "clsx";
|
|
5
|
-
import { Children, isValidElement, type ReactElement, type ReactNode } from "react";
|
|
5
|
+
import { Children, isValidElement, type ReactElement, type ReactNode, type Ref } from "react";
|
|
6
6
|
|
|
7
7
|
export type InputSlotProps = {
|
|
8
8
|
/**
|
|
@@ -44,6 +44,11 @@ export type InputAdornmentsProps = Omit<InputProps, "children"> & {
|
|
|
44
44
|
* Classes for the bare input element itself
|
|
45
45
|
*/
|
|
46
46
|
inputClassName?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Ref to the input element. The wrapper is an implementation detail, so the
|
|
49
|
+
* ref points at the input rather than at the element carrying the styling.
|
|
50
|
+
*/
|
|
51
|
+
ref?: Ref<HTMLInputElement>;
|
|
47
52
|
};
|
|
48
53
|
|
|
49
54
|
/**
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
.ObReportDownloader-root {
|
|
2
|
+
position: fixed;
|
|
3
|
+
bottom: 0;
|
|
4
|
+
left: 0;
|
|
5
|
+
z-index: 1060;
|
|
6
|
+
display: flex;
|
|
7
|
+
justify-content: center;
|
|
8
|
+
width: 100%;
|
|
9
|
+
padding: var(--ds-size-2);
|
|
10
|
+
pointer-events: none;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.ObReportDownloader-list {
|
|
14
|
+
width: 100%;
|
|
15
|
+
max-width: 60rem;
|
|
16
|
+
padding: var(--ds-size-2);
|
|
17
|
+
background: var(--ds-color-neutral-background-default);
|
|
18
|
+
border: 1px solid var(--ds-color-neutral-border-subtle);
|
|
19
|
+
border-radius: var(--ds-border-radius-md);
|
|
20
|
+
box-shadow: var(--ds-shadow-md);
|
|
21
|
+
opacity: 1;
|
|
22
|
+
transform: translateY(0);
|
|
23
|
+
transition:
|
|
24
|
+
opacity 150ms ease,
|
|
25
|
+
transform 150ms ease;
|
|
26
|
+
pointer-events: auto;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.ObReportDownloader-list.closed {
|
|
30
|
+
padding: 0;
|
|
31
|
+
border: none;
|
|
32
|
+
box-shadow: none;
|
|
33
|
+
opacity: 0;
|
|
34
|
+
transform: translateY(100%);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.ObReportDownloader-item {
|
|
38
|
+
display: grid;
|
|
39
|
+
grid-template-columns: 3rem 1fr auto;
|
|
40
|
+
gap: var(--ds-size-3);
|
|
41
|
+
align-items: center;
|
|
42
|
+
justify-items: center;
|
|
43
|
+
padding: var(--ds-size-2);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.ObReportDownloader-itemText {
|
|
47
|
+
justify-self: start;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.ObReportDownloader-icon {
|
|
51
|
+
display: flex;
|
|
52
|
+
align-items: center;
|
|
53
|
+
justify-content: center;
|
|
54
|
+
font-size: 1.5rem;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.ObReportDownloader-icon.cancelled {
|
|
58
|
+
color: var(--ds-color-warning-base-default);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.ObReportDownloader-icon.error {
|
|
62
|
+
color: var(--ds-color-danger-base-default);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.ObReportDownloader-icon.completed {
|
|
66
|
+
color: var(--ds-color-success-base-default);
|
|
67
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Button, Paragraph, Spinner } from "@digdir/designsystemet-react";
|
|
2
|
+
import { CheckmarkCircleFillIcon, ExclamationmarkTriangleFillIcon, XMarkOctagonFillIcon } from "@navikt/aksel-icons";
|
|
3
|
+
import { type DownloadItem, getLocalizedString } from "@olenbetong/appframe-core";
|
|
4
|
+
|
|
5
|
+
import clsx from "clsx";
|
|
6
|
+
|
|
7
|
+
const statuses: Record<string, { icon: React.ReactNode; text: string }> = {
|
|
8
|
+
cancelled: {
|
|
9
|
+
icon: <ExclamationmarkTriangleFillIcon aria-hidden />,
|
|
10
|
+
text: "Report cancelled",
|
|
11
|
+
},
|
|
12
|
+
error: {
|
|
13
|
+
icon: <XMarkOctagonFillIcon aria-hidden />,
|
|
14
|
+
text: "An error occurred",
|
|
15
|
+
},
|
|
16
|
+
requested: {
|
|
17
|
+
icon: <Spinner aria-hidden data-size="sm" />,
|
|
18
|
+
text: "Report request sent. Please wait while our server generates the report",
|
|
19
|
+
},
|
|
20
|
+
downloading: {
|
|
21
|
+
icon: <Spinner aria-hidden data-size="sm" />,
|
|
22
|
+
text: "Downloading report",
|
|
23
|
+
},
|
|
24
|
+
downloaded: {
|
|
25
|
+
icon: <Spinner aria-hidden data-size="sm" />,
|
|
26
|
+
text: "Sending report to browser",
|
|
27
|
+
},
|
|
28
|
+
completed: {
|
|
29
|
+
icon: <CheckmarkCircleFillIcon aria-hidden />,
|
|
30
|
+
text: "The report was successfully generated and downloaded",
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function formatBytes(bytes: number, decimals: number) {
|
|
35
|
+
if (bytes === 0) return "0 Bytes";
|
|
36
|
+
|
|
37
|
+
let k = 1024;
|
|
38
|
+
let dm = decimals || 2;
|
|
39
|
+
let sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
|
40
|
+
let i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
41
|
+
|
|
42
|
+
return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type StatusItemProps = {
|
|
46
|
+
item: DownloadItem;
|
|
47
|
+
onCancelItem: () => void;
|
|
48
|
+
onCloseItem: () => void;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function StatusItem({ onCancelItem, onCloseItem, item }: StatusItemProps) {
|
|
52
|
+
let { bytesReceived = 0, error, status = "requested" } = item;
|
|
53
|
+
|
|
54
|
+
let icon = statuses[status]?.icon;
|
|
55
|
+
let text = statuses[status] ? getLocalizedString(statuses[status].text) : "";
|
|
56
|
+
let working = ["requested", "downloading"].includes(status);
|
|
57
|
+
let stopped = ["cancelled", "completed", "error"].includes(status);
|
|
58
|
+
|
|
59
|
+
if (working) {
|
|
60
|
+
text += "...";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (status === "error" && error) {
|
|
64
|
+
text = error;
|
|
65
|
+
} else if (status === "downloading" && bytesReceived) {
|
|
66
|
+
text += ` (${formatBytes(bytesReceived, 2)})`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className="ObReportDownloader-item">
|
|
71
|
+
<div className={clsx("ObReportDownloader-icon", status)}>{icon}</div>
|
|
72
|
+
<Paragraph className="ObReportDownloader-itemText">{text}</Paragraph>
|
|
73
|
+
<div>
|
|
74
|
+
{stopped ? (
|
|
75
|
+
<Button variant="secondary" data-size="sm" onClick={onCloseItem}>
|
|
76
|
+
{getLocalizedString("Close")}
|
|
77
|
+
</Button>
|
|
78
|
+
) : (
|
|
79
|
+
<Button variant="secondary" data-size="sm" onClick={onCancelItem}>
|
|
80
|
+
{getLocalizedString("Cancel")}
|
|
81
|
+
</Button>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { DownloadItem } from "@olenbetong/appframe-core";
|
|
2
|
+
|
|
3
|
+
import clsx from "clsx";
|
|
4
|
+
|
|
5
|
+
import { StatusItem } from "./StatusItem.js";
|
|
6
|
+
|
|
7
|
+
export type StatusListProps = {
|
|
8
|
+
onCloseItem: (id: number) => void;
|
|
9
|
+
onCancelItem: (id: number) => void;
|
|
10
|
+
items: (DownloadItem & { id: number })[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function StatusList({ items, onCancelItem, onCloseItem }: StatusListProps) {
|
|
14
|
+
return (
|
|
15
|
+
<div className="ObReportDownloader-root">
|
|
16
|
+
<div className={clsx("ObReportDownloader-list", items.length === 0 && "closed")}>
|
|
17
|
+
{items.map((item) => (
|
|
18
|
+
<StatusItem
|
|
19
|
+
key={item.id}
|
|
20
|
+
onCancelItem={() => onCancelItem(item.id)}
|
|
21
|
+
onCloseItem={() => onCloseItem(item.id)}
|
|
22
|
+
item={item}
|
|
23
|
+
/>
|
|
24
|
+
))}
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { downloadRequest } from "@olenbetong/appframe-core";
|
|
2
|
+
import type { DataObjectReportParams } from "@olenbetong/appframe-data";
|
|
3
|
+
import { exportOptionsFromDataObject, getDataObjectReportRequest } from "@olenbetong/appframe-data";
|
|
4
|
+
|
|
5
|
+
import downloader, { mountStatusDisplay } from "./status.js";
|
|
6
|
+
|
|
7
|
+
export function openDataObjectReportWithProgress(params: DataObjectReportParams) {
|
|
8
|
+
let { dataObject, fileName, format } = params;
|
|
9
|
+
let { controller, request } = getDataObjectReportRequest(params);
|
|
10
|
+
|
|
11
|
+
return downloadRequestWithProgress(request, {
|
|
12
|
+
controller,
|
|
13
|
+
fileName: `${fileName ?? dataObject}.${format}`,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type DownloadParameters = {
|
|
18
|
+
controller?: AbortController;
|
|
19
|
+
fileName: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function downloadRequestWithProgress(request: Request, { controller, fileName }: DownloadParameters) {
|
|
23
|
+
let abortController = controller ?? new AbortController();
|
|
24
|
+
|
|
25
|
+
mountStatusDisplay();
|
|
26
|
+
|
|
27
|
+
let id = downloader.start({}, controller);
|
|
28
|
+
|
|
29
|
+
function updateProgress(progress: any) {
|
|
30
|
+
downloader.update(id, progress);
|
|
31
|
+
|
|
32
|
+
if (["cancelled", "completed"].includes(progress.status)) {
|
|
33
|
+
setTimeout(() => downloader.remove(id), 1500);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
downloadRequest(request, { onProgress: updateProgress, fileName, signal: abortController.signal });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type { DataObjectReportParams };
|
|
41
|
+
export { exportOptionsFromDataObject, getDataObjectReportRequest };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import "./ReportDownloader.css";
|
|
2
|
+
|
|
3
|
+
import type { DownloadItem } from "@olenbetong/appframe-core";
|
|
4
|
+
|
|
5
|
+
import mitt from "mitt";
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
import { createRoot } from "react-dom/client";
|
|
8
|
+
|
|
9
|
+
import { StatusList } from "./components/StatusList.js";
|
|
10
|
+
|
|
11
|
+
class Downloader {
|
|
12
|
+
eventHandler = mitt<{ update: undefined }>();
|
|
13
|
+
#items: (DownloadItem & { id: number })[] = [];
|
|
14
|
+
#id = 1;
|
|
15
|
+
#controllers: Record<number, AbortController> = {};
|
|
16
|
+
|
|
17
|
+
start(item: Partial<DownloadItem>, controller?: AbortController) {
|
|
18
|
+
this.eventHandler.emit("update");
|
|
19
|
+
|
|
20
|
+
let id = this.#id++;
|
|
21
|
+
this.#items.push({ id, status: "requested", ...item });
|
|
22
|
+
this.#controllers[id] = controller ?? new AbortController();
|
|
23
|
+
|
|
24
|
+
return id;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
cancel(id: number) {
|
|
28
|
+
this.#controllers[id]?.abort();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
update(id: number, item: DownloadItem) {
|
|
32
|
+
this.#items = this.#items.map((i) => (i.id === id ? { ...i, ...item } : i));
|
|
33
|
+
this.eventHandler.emit("update");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
remove(id: number) {
|
|
37
|
+
this.#items = this.#items.filter((i) => i.id !== id);
|
|
38
|
+
this.eventHandler.emit("update");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get items() {
|
|
42
|
+
return this.#items;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const downloader = new Downloader();
|
|
47
|
+
|
|
48
|
+
function StatusDisplay() {
|
|
49
|
+
let [items, setItems] = useState<(DownloadItem & { id: number })[]>([]);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
function handleUpdate() {
|
|
53
|
+
setItems([...downloader.items]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
handleUpdate();
|
|
57
|
+
|
|
58
|
+
downloader.eventHandler.on("update", handleUpdate);
|
|
59
|
+
|
|
60
|
+
return () => {
|
|
61
|
+
downloader.eventHandler.off("update", handleUpdate);
|
|
62
|
+
};
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<StatusList
|
|
67
|
+
items={items}
|
|
68
|
+
onCloseItem={(id: number) => downloader.remove(id)}
|
|
69
|
+
onCancelItem={(id: number) => downloader.cancel(id)}
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let mounted = false;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Mounts the download status list into its own root at the end of `<body>`.
|
|
78
|
+
* Called the first time a download is started, so importing the module doesn't
|
|
79
|
+
* touch the DOM (which would break server rendering and tests).
|
|
80
|
+
*/
|
|
81
|
+
function mountStatusDisplay() {
|
|
82
|
+
if (mounted || typeof document === "undefined") return;
|
|
83
|
+
|
|
84
|
+
mounted = true;
|
|
85
|
+
|
|
86
|
+
let element = document.createElement("div");
|
|
87
|
+
document.body.append(element);
|
|
88
|
+
createRoot(element).render(<StatusDisplay />);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export { mountStatusDisplay };
|
|
92
|
+
export default downloader;
|
package/vitest.config.ts
ADDED