@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/package.json +50 -6
  3. package/scripts/copyAssets.mjs +13 -0
  4. package/src/AfLookup/LookupCombobox.tsx +3 -1
  5. package/src/filter/ChipListInput.tsx +79 -0
  6. package/src/filter/FieldFilterPanel.css +127 -0
  7. package/src/filter/FieldFilterPanel.tsx +309 -0
  8. package/src/filter/FilterBuilder.css +152 -0
  9. package/src/filter/FilterBuilder.test.tsx +153 -0
  10. package/src/filter/FilterBuilder.tsx +435 -0
  11. package/src/filter/FilterEditor.css +231 -0
  12. package/src/filter/FilterEditor.test.tsx +259 -0
  13. package/src/filter/FilterEditor.tsx +74 -0
  14. package/src/filter/FilterGroupEditor.tsx +160 -0
  15. package/src/filter/FilterNameDialog.css +22 -0
  16. package/src/filter/FilterNameDialog.tsx +81 -0
  17. package/src/filter/FilterRow.tsx +117 -0
  18. package/src/filter/FilterShareDialog.css +28 -0
  19. package/src/filter/FilterShareDialog.tsx +201 -0
  20. package/src/filter/FilterStringField.tsx +77 -0
  21. package/src/filter/FilterValueEditor.tsx +220 -0
  22. package/src/filter/SavedFilterTree.css +107 -0
  23. package/src/filter/SavedFilterTree.test.tsx +94 -0
  24. package/src/filter/SavedFilterTree.tsx +222 -0
  25. package/src/filter/fieldFilter.test.ts +158 -0
  26. package/src/filter/fieldFilter.ts +139 -0
  27. package/src/filter/index.ts +27 -0
  28. package/src/filter/types.ts +38 -0
  29. package/src/filter/useDistinctValues.test.ts +102 -0
  30. package/src/filter/useDistinctValues.ts +230 -0
  31. package/src/global.d.ts +11 -0
  32. package/src/grid/AfGridColumnsPanel.tsx +118 -0
  33. package/src/grid/AfGridContext.tsx +51 -0
  34. package/src/grid/AfGridError.tsx +46 -0
  35. package/src/grid/AfHeaderFilterCell.tsx +151 -0
  36. package/src/grid/AfHeaderFilterPanel.tsx +111 -0
  37. package/src/grid/Toolbar.tsx +233 -0
  38. package/src/grid/editing.ts +90 -0
  39. package/src/grid/filter.ts +78 -0
  40. package/src/grid/formatters.ts +48 -0
  41. package/src/grid/index.css +187 -0
  42. package/src/grid/index.tsx +376 -0
  43. package/src/grid/license.ts +48 -0
  44. package/src/grid/localization.ts +369 -0
  45. package/src/grid/slots/index.tsx +275 -0
  46. package/src/grid/slots/slots.css +53 -0
  47. package/src/grid/theme.tsx +185 -0
  48. package/src/grid/useAfColumns.tsx +313 -0
  49. package/src/grid/useAfCurrentIndex.ts +55 -0
  50. package/src/grid/useAfData.ts +20 -0
  51. package/src/grid/useAfFilter.ts +160 -0
  52. package/src/grid/useAfFilterFields.ts +55 -0
  53. package/src/grid/useAfGridApi.ts +66 -0
  54. package/src/grid/useAfKeyBindings.ts +36 -0
  55. package/src/grid/useAfPagination.ts +53 -0
  56. package/src/grid/useAfPersistedState.ts +222 -0
  57. package/src/grid/useAfRowEditModel.ts +137 -0
  58. package/src/grid/useAfSortModel.ts +72 -0
  59. package/src/index.ts +1 -0
  60. package/src/input/InputAdornments.tsx +6 -1
  61. package/src/report-downloader/ReportDownloader.css +67 -0
  62. package/src/report-downloader/components/StatusItem.tsx +86 -0
  63. package/src/report-downloader/components/StatusList.tsx +28 -0
  64. package/src/report-downloader/index.ts +41 -0
  65. package/src/report-downloader/status.tsx +92 -0
  66. package/src/test/setup.ts +8 -0
  67. 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;
@@ -0,0 +1,8 @@
1
+ import "@testing-library/jest-dom/vitest";
2
+
3
+ import { cleanup } from "@testing-library/react";
4
+ import { afterEach } from "vitest";
5
+
6
+ afterEach(() => {
7
+ cleanup();
8
+ });
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ["src/**/*.test.tsx", "src/**/*.test.ts"],
6
+ environment: "happy-dom",
7
+ setupFiles: ["src/test/setup.ts"],
8
+ env: { NODE_ENV: "test" },
9
+ },
10
+ });