feat: implement governed reporting vertical
This commit is contained in:
@@ -0,0 +1,573 @@
|
||||
import {
|
||||
BarChart3,
|
||||
CalendarClock,
|
||||
Download,
|
||||
FileJson,
|
||||
History,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
Table2
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type FormEvent
|
||||
} from "react";
|
||||
import {
|
||||
Button,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
IconButton,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
SegmentedControl,
|
||||
StatusBadge,
|
||||
hasScope,
|
||||
type DataGridColumn,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
createIntervalSchedule,
|
||||
downloadExecution,
|
||||
getDefinition,
|
||||
listDefinitions,
|
||||
listExecutions,
|
||||
listSavedViews,
|
||||
reportPayload,
|
||||
runReport,
|
||||
saveView,
|
||||
semanticPayload,
|
||||
type ReportExecution,
|
||||
type ReportingDefinition,
|
||||
type ReportingQuery,
|
||||
type ReportingQueryMode,
|
||||
type ReportingSavedView,
|
||||
type SemanticModelPayload
|
||||
} from "../../api/reporting";
|
||||
|
||||
|
||||
type OutputMode = "visual" | "table";
|
||||
|
||||
export default function ReportingPage({ settings, auth }: PlatformRouteContext) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [submittedSearch, setSubmittedSearch] = useState("");
|
||||
const [reports, setReports] = useState<ReportingDefinition[]>([]);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [semantic, setSemantic] = useState<SemanticModelPayload | null>(null);
|
||||
const [query, setQuery] = useState<ReportingQuery>(emptyQuery());
|
||||
const [parameters, setParameters] = useState<Record<string, unknown>>({});
|
||||
const [execution, setExecution] = useState<ReportExecution | null>(null);
|
||||
const [history, setHistory] = useState<ReportExecution[]>([]);
|
||||
const [savedViews, setSavedViews] = useState<ReportingSavedView[]>([]);
|
||||
const [outputMode, setOutputMode] = useState<OutputMode>("visual");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
|
||||
const canRun = hasScope(auth, "reporting:report:run");
|
||||
const canSchedule = hasScope(auth, "reporting:schedule:write");
|
||||
|
||||
const selected = useMemo(
|
||||
() => reports.find((item) => item.definition_id === selectedId) ?? null,
|
||||
[reports, selectedId]
|
||||
);
|
||||
const report = selected ? reportPayload(selected) : null;
|
||||
|
||||
function reload(signal?: AbortSignal) {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
return listDefinitions(settings, {
|
||||
kinds: ["report"],
|
||||
status: ["active"],
|
||||
query: submittedSearch,
|
||||
limit: 200
|
||||
}, signal).
|
||||
then((result) => {
|
||||
setReports(result.definitions);
|
||||
setSelectedId((current) => result.definitions.some((item) => item.definition_id === current) ?
|
||||
current : result.definitions[0]?.definition_id ?? "");
|
||||
}).
|
||||
catch((reason) => {
|
||||
if ((reason as Error).name !== "AbortError") setError(message(reason));
|
||||
}).
|
||||
finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void reload(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [settings, submittedSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected || !report) {
|
||||
setSemantic(null);
|
||||
setExecution(null);
|
||||
setHistory([]);
|
||||
setSavedViews([]);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setQuery(normalizeQuery(report.default_query));
|
||||
setParameters(defaultParameters(report.parameters));
|
||||
setExecution(null);
|
||||
Promise.all([
|
||||
getDefinition(settings, "semantic_model", report.semantic_model_id, report.semantic_model_revision, controller.signal),
|
||||
canRun ? listExecutions(settings, selected.definition_id, controller.signal) : Promise.resolve({ executions: [] }),
|
||||
listSavedViews(settings, selected.definition_id, controller.signal)
|
||||
]).
|
||||
then(([semanticDefinition, executions, views]) => {
|
||||
setSemantic(semanticPayload(semanticDefinition));
|
||||
setHistory(executions.executions);
|
||||
setSavedViews(views.views);
|
||||
setExecution(executions.executions.find((item) => item.status === "succeeded") ?? null);
|
||||
}).
|
||||
catch((reason) => {
|
||||
if ((reason as Error).name !== "AbortError") setError(message(reason));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [settings, selectedId]);
|
||||
|
||||
function submitSearch(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setSubmittedSearch(search.trim());
|
||||
}
|
||||
|
||||
async function execute() {
|
||||
if (!selected) return;
|
||||
setRunning(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await runReport(settings, selected, query, parameters);
|
||||
setExecution(result);
|
||||
setHistory((current) => [result, ...current.filter((item) => item.execution_id !== result.execution_id)]);
|
||||
setOutputMode(result.visualization?.kind === "table" ? "table" : "visual");
|
||||
} catch (reason) {
|
||||
setError(message(reason));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
function applySavedView(view: ReportingSavedView) {
|
||||
if (view.state.query) setQuery(normalizeQuery(view.state.query));
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="reporting-page">
|
||||
<div className="reporting-shell">
|
||||
<div className="reporting-toolbar">
|
||||
<form className="reporting-search" onSubmit={submitSearch}>
|
||||
<Search size={17} aria-hidden="true" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
aria-label="Search reports"
|
||||
placeholder="Search reports"
|
||||
/>
|
||||
</form>
|
||||
<span className="reporting-count">{reports.length} reports</span>
|
||||
<IconButton
|
||||
label="Reload reports"
|
||||
icon={<RefreshCw size={17} />}
|
||||
variant="ghost"
|
||||
onClick={() => void reload()}
|
||||
/>
|
||||
</div>
|
||||
{error &&
|
||||
<DismissibleAlert tone="danger" resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
}
|
||||
<div className="reporting-workspace">
|
||||
<PageScrollViewport className="reporting-catalogue">
|
||||
{loading && <LoadingIndicator label="Loading reports" />}
|
||||
{!loading && reports.length === 0 && <div className="reporting-empty">No active reports are available.</div>}
|
||||
<div className="reporting-report-list" role="list">
|
||||
{reports.map((item) =>
|
||||
<button
|
||||
type="button"
|
||||
role="listitem"
|
||||
key={item.definition_id}
|
||||
className={`reporting-report-row${selectedId === item.definition_id ? " is-selected" : ""}`}
|
||||
onClick={() => setSelectedId(item.definition_id)}>
|
||||
<BarChart3 size={17} aria-hidden="true" />
|
||||
<span><strong>{item.name}</strong><small>{item.definition_key} · r{item.revision}</small></span>
|
||||
<StatusBadge status="active" label="Active" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</PageScrollViewport>
|
||||
<section className="reporting-result-region">
|
||||
{selected && report ?
|
||||
<>
|
||||
<header className="reporting-result-header">
|
||||
<div className="reporting-title">
|
||||
<span>{selected.definition_key} · revision {selected.revision}</span>
|
||||
<h1>{selected.name}</h1>
|
||||
</div>
|
||||
<div className="reporting-run-actions">
|
||||
{canSchedule &&
|
||||
<IconButton label="Schedule report" icon={<CalendarClock size={17} />} variant="ghost" onClick={() => setScheduleDialogOpen(true)} />
|
||||
}
|
||||
<IconButton label="Save current view" icon={<Save size={17} />} variant="ghost" onClick={() => setSaveDialogOpen(true)} />
|
||||
<Button variant="primary" onClick={() => void execute()} disabled={!canRun || running}>
|
||||
<Play size={16} aria-hidden="true" /> {running ? "Running" : "Run"}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<QueryControls
|
||||
query={query}
|
||||
semantic={semantic}
|
||||
parameters={report.parameters}
|
||||
parameterValues={parameters}
|
||||
onQueryChange={setQuery}
|
||||
onParametersChange={setParameters}
|
||||
/>
|
||||
<div className="reporting-output-toolbar">
|
||||
<SegmentedControl
|
||||
ariaLabel="Report output"
|
||||
value={outputMode}
|
||||
onChange={setOutputMode}
|
||||
options={[
|
||||
{ id: "visual", label: <><BarChart3 size={15} /> Visual</> },
|
||||
{ id: "table", label: <><Table2 size={15} /> Table</> }
|
||||
]}
|
||||
/>
|
||||
{execution &&
|
||||
<>
|
||||
<span>{execution.total_rows} rows{execution.truncated ? " (truncated)" : ""}</span>
|
||||
<IconButton label="Download CSV" icon={<Download size={17} />} variant="ghost" onClick={() => void downloadExecution(settings, execution.execution_id, "csv").catch((reason) => setError(message(reason)))} />
|
||||
<IconButton label="Download JSON" icon={<FileJson size={17} />} variant="ghost" onClick={() => void downloadExecution(settings, execution.execution_id, "json").catch((reason) => setError(message(reason)))} />
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
<div className="reporting-output">
|
||||
{!execution && <div className="reporting-empty">Run the report or select a previous execution.</div>}
|
||||
{execution && outputMode === "visual" && <ReportVisual execution={execution} />}
|
||||
{execution && outputMode === "table" && <ReportTable execution={execution} />}
|
||||
</div>
|
||||
</> :
|
||||
<div className="reporting-empty">Select a report.</div>
|
||||
}
|
||||
</section>
|
||||
<PageScrollViewport className="reporting-inspector">
|
||||
<Inspector
|
||||
selected={selected}
|
||||
execution={execution}
|
||||
history={history}
|
||||
savedViews={savedViews}
|
||||
onSelectExecution={setExecution}
|
||||
onApplySavedView={applySavedView}
|
||||
/>
|
||||
</PageScrollViewport>
|
||||
</div>
|
||||
</div>
|
||||
<SaveViewDialog
|
||||
open={saveDialogOpen}
|
||||
onClose={() => setSaveDialogOpen(false)}
|
||||
onSave={async (name) => {
|
||||
if (!selected) return;
|
||||
const saved = await saveView(settings, selected, name, query);
|
||||
setSavedViews((current) => [...current, saved].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
setSaveDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
<ScheduleDialog
|
||||
open={scheduleDialogOpen}
|
||||
onClose={() => setScheduleDialogOpen(false)}
|
||||
onSave={async (name, seconds) => {
|
||||
if (!selected) return;
|
||||
await createIntervalSchedule(settings, selected, name, seconds, query, parameters);
|
||||
setScheduleDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function QueryControls({ query, semantic, parameters, parameterValues, onQueryChange, onParametersChange }: {
|
||||
query: ReportingQuery;
|
||||
semantic: SemanticModelPayload | null;
|
||||
parameters: Array<{ key: string; label: string; type: string; required: boolean; default?: unknown; allowed_values: unknown[] }>;
|
||||
parameterValues: Record<string, unknown>;
|
||||
onQueryChange: (query: ReportingQuery) => void;
|
||||
onParametersChange: (parameters: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const dimensions = semantic?.dimensions ?? [];
|
||||
const measures = semantic?.measures ?? [];
|
||||
|
||||
function setMode(mode: ReportingQueryMode) {
|
||||
const row = query.dimensions[0] ?? dimensions[0]?.key;
|
||||
const column = query.dimensions[1] ?? dimensions[1]?.key;
|
||||
onQueryChange({
|
||||
...query,
|
||||
mode,
|
||||
pivot: mode === "pivot" ? {
|
||||
rows: row ? [row] : [],
|
||||
columns: column ? [column] : [],
|
||||
measures: query.measures.length ? query.measures : measures.slice(0, 1).map((item) => item.key),
|
||||
include_totals: true
|
||||
} : null
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="reporting-query-controls">
|
||||
<div className="reporting-query-heading">
|
||||
<SlidersHorizontal size={16} aria-hidden="true" />
|
||||
<SegmentedControl
|
||||
ariaLabel="Query shape"
|
||||
value={query.mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ id: "summary", label: "Summary" },
|
||||
{ id: "detail", label: "Detail" },
|
||||
{ id: "pivot", label: "Pivot" }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<fieldset>
|
||||
<legend>Dimensions</legend>
|
||||
<div className="reporting-option-list">
|
||||
{dimensions.map((item) =>
|
||||
<label key={item.key}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={query.dimensions.includes(item.key)}
|
||||
onChange={() => onQueryChange({ ...query, dimensions: toggle(query.dimensions, item.key) })}
|
||||
/>
|
||||
{item.label}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
{query.mode !== "detail" &&
|
||||
<fieldset>
|
||||
<legend>Measures</legend>
|
||||
<div className="reporting-option-list">
|
||||
{measures.map((item) =>
|
||||
<label key={item.key}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={query.measures.includes(item.key)}
|
||||
onChange={() => onQueryChange({ ...query, measures: toggle(query.measures, item.key) })}
|
||||
/>
|
||||
{item.label}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
}
|
||||
{parameters.map((parameter) =>
|
||||
<label className="reporting-parameter" key={parameter.key}>
|
||||
<span>{parameter.label}</span>
|
||||
{parameter.allowed_values.length > 0 ?
|
||||
<select
|
||||
value={String(parameterValues[parameter.key] ?? "")}
|
||||
onChange={(event) => onParametersChange({ ...parameterValues, [parameter.key]: event.target.value })}>
|
||||
{!parameter.required && <option value="">Any</option>}
|
||||
{parameter.allowed_values.map((value) => <option key={String(value)} value={String(value)}>{String(value)}</option>)}
|
||||
</select> :
|
||||
<input
|
||||
type={parameter.type === "number" || parameter.type === "integer" ? "number" : "text"}
|
||||
value={String(parameterValues[parameter.key] ?? "")}
|
||||
onChange={(event) => onParametersChange({ ...parameterValues, [parameter.key]: typedValue(event.target.value, parameter.type) })}
|
||||
/>
|
||||
}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportTable({ execution }: { execution: ReportExecution }) {
|
||||
const [page, setPage] = useState(0);
|
||||
useEffect(() => setPage(0), [execution.execution_id]);
|
||||
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() =>
|
||||
execution.schema.map((field) => ({
|
||||
id: field.name,
|
||||
header: humanize(field.name),
|
||||
width: "1fr",
|
||||
minWidth: 120,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
filterType: field.type === "integer" || field.type === "number" ? field.type : "text",
|
||||
value: (row) => row[field.name],
|
||||
render: (row) => formatValue(row[field.name])
|
||||
})), [execution]);
|
||||
return (
|
||||
<DataGrid
|
||||
id={`reporting-execution-${execution.execution_id}`}
|
||||
rows={execution.rows}
|
||||
columns={columns}
|
||||
getRowKey={(_row, index) => `${execution.execution_id}:${index}`}
|
||||
initialFit="container"
|
||||
resizeBehavior="cover"
|
||||
emptyText="The report returned no rows."
|
||||
pagination={{ page, pageSize: 50, onPageChange: setPage }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportVisual({ execution }: { execution: ReportExecution }) {
|
||||
const visual = execution.visualization;
|
||||
if (!visual || visual.kind === "table" || !visual.category || !visual.measures?.length) {
|
||||
return (
|
||||
<div className="reporting-visual-fallback">
|
||||
{visual?.fallback_reason && <DismissibleAlert tone="info" dismissible={false}>{visual.fallback_reason}</DismissibleAlert>}
|
||||
<ReportTable execution={execution} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const measure = visual.measures[0];
|
||||
const values = execution.rows.map((row) => Number(row[measure] ?? 0));
|
||||
const maximum = Math.max(...values.map((value) => Math.abs(value)), 1);
|
||||
return (
|
||||
<div className="reporting-bar-chart" role="img" aria-label={`${humanize(measure)} by ${humanize(visual.category)}`}>
|
||||
{execution.rows.map((row, index) =>
|
||||
<div className="reporting-bar-row" key={`${String(row[visual.category ?? ""])}:${index}`}>
|
||||
<span>{formatValue(row[visual.category ?? ""])}</span>
|
||||
<div><i style={{ width: `${Math.max(1, Math.abs(values[index]) / maximum * 100)}%` }} /></div>
|
||||
<strong>{formatValue(row[measure])}</strong>
|
||||
</div>
|
||||
)}
|
||||
<div className="reporting-chart-table"><ReportTable execution={execution} /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Inspector({ selected, execution, history, savedViews, onSelectExecution, onApplySavedView }: {
|
||||
selected: ReportingDefinition | null;
|
||||
execution: ReportExecution | null;
|
||||
history: ReportExecution[];
|
||||
savedViews: ReportingSavedView[];
|
||||
onSelectExecution: (execution: ReportExecution) => void;
|
||||
onApplySavedView: (view: ReportingSavedView) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="reporting-inspector-content">
|
||||
<section>
|
||||
<h2><History size={16} /> Runs</h2>
|
||||
{history.length === 0 && <p>No recorded runs.</p>}
|
||||
{history.map((item) =>
|
||||
<button type="button" key={item.execution_id} className={execution?.execution_id === item.execution_id ? "is-selected" : ""} onClick={() => onSelectExecution(item)}>
|
||||
<span>{formatDateTime(item.started_at)}</span>
|
||||
<StatusBadge status={item.status} label={humanize(item.status)} />
|
||||
<small>{item.total_rows} rows</small>
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
<section>
|
||||
<h2><Save size={16} /> Saved views</h2>
|
||||
{savedViews.length === 0 && <p>No saved views.</p>}
|
||||
{savedViews.map((view) =>
|
||||
<button type="button" key={view.view_id} onClick={() => onApplySavedView(view)}>
|
||||
<span>{view.name}</span><small>revision {view.revision}</small>
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
{selected &&
|
||||
<section className="reporting-provenance">
|
||||
<h2>Definition</h2>
|
||||
<dl>
|
||||
<dt>Report revision</dt><dd>{selected.revision}</dd>
|
||||
<dt>Content hash</dt><dd title={selected.content_hash}>{shortHash(selected.content_hash)}</dd>
|
||||
{execution && <><dt>Dataset</dt><dd>{execution.dataset_id} r{execution.dataset_revision}</dd></>}
|
||||
{execution?.output_hash && <><dt>Output hash</dt><dd title={execution.output_hash}>{shortHash(execution.output_hash)}</dd></>}
|
||||
{execution?.executor_version && <><dt>Executor</dt><dd>{execution.executor_version}</dd></>}
|
||||
</dl>
|
||||
{execution?.diagnostics.map((item, index) =>
|
||||
<DismissibleAlert key={`${item.code}:${index}`} tone={item.severity === "error" ? "danger" : item.severity === "warning" ? "warning" : "info"} dismissible={false} compact>
|
||||
{item.message ?? item.code ?? "Execution diagnostic"}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveViewDialog({ open, onClose, onSave }: { open: boolean; onClose: () => void; onSave: (name: string) => Promise<void> }) {
|
||||
const [name, setName] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
return (
|
||||
<Dialog open={open} title="Save report view" onClose={onClose} footer={<>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" disabled={!name.trim() || saving} onClick={() => { setSaving(true); void onSave(name.trim()).finally(() => setSaving(false)); }}>Save</Button>
|
||||
</>}>
|
||||
<label className="reporting-dialog-field"><span>Name</span><input value={name} onChange={(event) => setName(event.target.value)} autoFocus /></label>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleDialog({ open, onClose, onSave }: { open: boolean; onClose: () => void; onSave: (name: string, seconds: number) => Promise<void> }) {
|
||||
const [name, setName] = useState("");
|
||||
const [interval, setIntervalValue] = useState("86400");
|
||||
const [saving, setSaving] = useState(false);
|
||||
return (
|
||||
<Dialog open={open} title="Schedule report" onClose={onClose} footer={<>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" disabled={!name.trim() || saving} onClick={() => { setSaving(true); void onSave(name.trim(), Number(interval)).finally(() => setSaving(false)); }}>Schedule</Button>
|
||||
</>}>
|
||||
<div className="reporting-dialog-grid">
|
||||
<label className="reporting-dialog-field"><span>Name</span><input value={name} onChange={(event) => setName(event.target.value)} autoFocus /></label>
|
||||
<label className="reporting-dialog-field"><span>Interval</span><select value={interval} onChange={(event) => setIntervalValue(event.target.value)}><option value="3600">Hourly</option><option value="86400">Daily</option><option value="604800">Weekly</option><option value="2592000">Every 30 days</option></select></label>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function emptyQuery(): ReportingQuery {
|
||||
return { mode: "summary", dimensions: [], measures: [], filters: [], sort: [], pivot: null, offset: 0, limit: 200 };
|
||||
}
|
||||
|
||||
function normalizeQuery(value: ReportingQuery): ReportingQuery {
|
||||
return { ...emptyQuery(), ...value, dimensions: [...(value.dimensions ?? [])], measures: [...(value.measures ?? [])], filters: [...(value.filters ?? [])], sort: [...(value.sort ?? [])] };
|
||||
}
|
||||
|
||||
function defaultParameters(parameters: Array<{ key: string; default?: unknown }>): Record<string, unknown> {
|
||||
return Object.fromEntries(parameters.filter((item) => item.default !== null && item.default !== undefined).map((item) => [item.key, item.default]));
|
||||
}
|
||||
|
||||
function toggle(values: string[], value: string): string[] {
|
||||
return values.includes(value) ? values.filter((item) => item !== value) : [...values, value];
|
||||
}
|
||||
|
||||
function typedValue(value: string, type: string): unknown {
|
||||
if (value === "") return null;
|
||||
if (type === "integer") return Number.parseInt(value, 10);
|
||||
if (type === "number") return Number(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "—";
|
||||
if (typeof value === "number") return new Intl.NumberFormat().format(value);
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.valueOf()) ? value : new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(parsed);
|
||||
}
|
||||
|
||||
function humanize(value: string): string {
|
||||
return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function shortHash(value: string): string {
|
||||
return `${value.slice(0, 10)}…${value.slice(-6)}`;
|
||||
}
|
||||
|
||||
function message(reason: unknown): string {
|
||||
return reason instanceof Error ? reason.message : "Reporting could not complete the request.";
|
||||
}
|
||||
Reference in New Issue
Block a user