import "./ReportLauncher.css"; import { Button, Checkbox, Dialog, Dropdown, Heading, Tabs, Textfield } from "@digdir/designsystemet-react"; import { ChevronDownIcon, XMarkIcon } from "@navikt/aksel-icons"; import { convertFilter, createEmptyFilter, type FieldMetadata, type FilterTree, getLocalizedString, type ReportFormat, type ReportParameterValues, restoreParameterValues, type SavedFilter, serializeParameters, } from "@olenbetong/appframe-core"; import { useFilterFields, useReport, useReportRender } from "@olenbetong/appframe-react"; import clsx from "clsx"; import { useEffect, useId, useMemo, useState } from "react"; import { FilterBuilder } from "../filter/FilterBuilder.js"; import type { FilterEditorSize, GetFieldOptions } from "../filter/types.js"; import { ReportParameters } from "./ReportParameters.js"; /** The label shown on the save button for each format. */ const FORMAT_LABELS: Record = { CSV: "Save as CSV", HTML: "Save as HTML", IMAGE: "Save as image", MHT: "Save as MHT", PDF: "Save as PDF", RTF: "Save as RTF", TEXT: "Save as text", XLS: "Save as XLS (Excel)", XLSX: "Save as XLSX (Excel 2007)", }; export type ReportLauncherTab = "parameters" | "filter"; export type ReportLauncherProps = { /** The report to run, e.g. `arpt_Personnel_Persons`. */ reportId: string; /** * `"dialog"` wraps the launcher in a modal, `"inline"` renders it in place. * @default "dialog" */ variant?: "dialog" | "inline"; /** Whether the dialog is open. Ignored when `variant` is `"inline"`. */ open?: boolean; /** Called when the launcher is dismissed. */ onClose?: () => void; /** * Overrides the filter the report was designed with, as a criteria string. * An empty string starts the launcher with no filter at all. */ initialFilter?: string | null; /** * The fields the filter can be built from. Loaded from the report's view * when not supplied. */ fields?: FieldMetadata[]; /** * The tab shown first. * @default "parameters" */ defaultTab?: ReportLauncherTab; getFieldOptions?: GetFieldOptions; className?: string; "data-size"?: FilterEditorSize; }; /** * The web port of the Windows report launcher: the saved filters and criteria * editor of the filter builder, the report's parameters, the report options, * and the save/preview/print actions. * * Everything the launcher needs comes from the report itself, so a caller only * has to name it. A report that does not resolve is one the current user has no * access to — `sviw_WinClient_MyReports` is permission-resolved. * * @example * ```jsx * setOpen(false)} * /> * ``` */ export function ReportLauncher({ reportId, variant = "dialog", open = true, onClose, initialFilter: initialFilterOverride, fields: fieldsOverride, defaultTab = "parameters", getFieldOptions, className, "data-size": size = "sm", }: ReportLauncherProps) { let { report, parameters, initialFilter, defaultValues, loading, error } = useReport(reportId, { initialFilter: initialFilterOverride, }); let viewName = report?.viewName ?? undefined; let { fields: loadedFields } = useFilterFields(fieldsOverride ? null : viewName); let fields = fieldsOverride ?? loadedFields; let { open: openReport, print, download, isRendering, progress, error: renderError } = useReportRender(reportId); let [filter, setFilter] = useState(createEmptyFilter); let [filterString, setFilterString] = useState(""); let [values, setValues] = useState({}); let [reportTitle, setReportTitle] = useState(""); let [headerText, setHeaderText] = useState(""); let [hideFilterString, setHideFilterString] = useState(false); let [tab, setTab] = useState(defaultTab); let panelId = useId(); // The report's own defaults arrive in two rounds — the literal ones first, // then the ones a stored procedure produces — so they are copied into the // form whenever they change rather than only once. useEffect(() => { setValues(defaultValues); }, [defaultValues]); useEffect(() => { setReportTitle(report?.title ?? ""); }, [report?.title]); /* * The initial filter is a criteria string, so it has to be parsed into a * tree before the editor can show it. */ useEffect(() => { if (!initialFilter) { setFilter(createEmptyFilter()); return; } let controller = new AbortController(); convertFilter({ filterString: initialFilter, viewName }, controller.signal) .then((result) => { if (!controller.signal.aborted) setFilter(result.filterObject ?? createEmptyFilter()); }) .catch(() => { // An initial filter the converter rejects is better dropped than // blocking the launcher; the user can rebuild it. }); return () => { controller.abort(); }; }, [initialFilter, viewName]); /** Selecting a saved filter restores the report options stored with it. */ function handleSelectFilter(saved: SavedFilter | null) { if (!saved) { setValues(defaultValues); setHeaderText(""); setHideFilterString(false); setReportTitle(report?.title ?? ""); return; } setValues(restoreParameterValues(parameters, saved.parameters)); setHeaderText(saved.headerText ?? ""); setHideFilterString(saved.hideCriteria); setReportTitle(saved.reportTitle ?? report?.title ?? ""); } let saveValues = useMemo( () => ({ headerText: headerText || null, hideCriteria: hideFilterString, parameters: parameters.length > 0 ? serializeParameters(parameters, values) : null, }), [headerText, hideFilterString, parameters, values], ); let formats = report?.formats ?? []; // PDF is the only format worth previewing or printing in place; the rest are // downloads whatever the browser is asked to do with them. let canPreview = formats.includes("PDF"); function getRenderOptions(format: ReportFormat) { return { format, fileName: reportTitle || report?.title || reportId, filter: filterString, parameters: values, headerText: report?.hasHeader ? headerText : null, hideFilterString, }; } function run(action: (options: ReturnType) => Promise, format: ReportFormat) { void action(getRenderOptions(format)); } /* * The parameters and the criteria are too much to show at once, so they get * a tab each. The report options stay below the tabs: they apply to the * report whichever tab is open, and the hide-criteria flag in particular * reads as belonging to both. */ let options = (
{report?.hasTitle && ( setReportTitle(event.target.value)} /> )} {report?.hasHeader && ( setHeaderText(event.target.value)} /> )} setHideFilterString(event.currentTarget.checked)} />
); let actions = (
{isRendering && ( {progress?.bytesReceived ? `${Math.round(progress.bytesReceived / 1024)} kB` : getLocalizedString("Rendering...")} )} {formats.length > 0 && (
{formats.length > 1 && ( {formats.slice(1).map((format) => ( run(download, format)}> {getLocalizedString(FORMAT_LABELS[format])} ))} )}
)}
); let content = (
{loading &&

{getLocalizedString("Loading...")}

} {!loading && !report && (

{error ? error.message : getLocalizedString("The report does not exist, or you do not have access to run it.")}

)} {report && ( (
setTab(value as ReportLauncherTab)} > {parameters.length > 0 ? getLocalizedString("Parameters") : getLocalizedString("Report")} {getLocalizedString("Filter")} {/* * The panels are rendered outside `Tabs` rather than as * `Tabs.Panel`, so the criteria editor gets a flex parent * it can fill and scroll inside of. */} {tab === "parameters" ? (
{parameters.length > 0 ? ( // No legend: the tab is labelled "Parameters" already. ) : (

{getLocalizedString("The report has no parameters.")}

)}
) : (
{editor}
)} {options}
)} /> )} {renderError && (

{renderError.message}

)}
); if (variant === "inline") return content; return ( {/* As in the filter builder, `Dialog`'s floated close button does not survive a flex layout, so the header renders its own. */} {getLocalizedString("Report")}: {report?.title ?? reportId} {/* The report number is what users refer to a report by — it is printed in the footer of every report. */} {report?.shortId !== null && report?.shortId !== undefined && ` (ID: ${report.shortId})`} {content} ); }