lytx 0.3.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/.env.example +37 -0
- package/README.md +486 -0
- package/alchemy.run.ts +155 -0
- package/cli/bootstrap-admin.ts +284 -0
- package/cli/deploy-staging.ts +692 -0
- package/cli/import-events.ts +628 -0
- package/cli/import-sites.ts +518 -0
- package/cli/index.ts +609 -0
- package/cli/init-db.ts +269 -0
- package/cli/migrate-to-durable-objects.ts +564 -0
- package/cli/migration-worker.ts +300 -0
- package/cli/performance-test.ts +588 -0
- package/cli/pg/client.ts +4 -0
- package/cli/pg/new-site.ts +153 -0
- package/cli/rollback-durable-objects.ts +622 -0
- package/cli/seed-data.ts +459 -0
- package/cli/setup.js +18 -0
- package/cli/setup.ts +463 -0
- package/cli/validate-migration.ts +200 -0
- package/cli/wrangler-migration.jsonc +28 -0
- package/db/adapter.ts +166 -0
- package/db/analytics_engine/client.ts +0 -0
- package/db/analytics_engine/sites.ts +0 -0
- package/db/client.ts +16 -0
- package/db/d1/client.ts +8 -0
- package/db/d1/drizzle.config.ts +35 -0
- package/db/d1/migrations/0000_true_maelstrom.sql +165 -0
- package/db/d1/migrations/0001_wonderful_bloodaxe.sql +12 -0
- package/db/d1/migrations/0002_late_frightful_four.sql +1 -0
- package/db/d1/migrations/0003_cuddly_obadiah_stane.sql +16 -0
- package/db/d1/migrations/0004_mute_stardust.sql +1 -0
- package/db/d1/migrations/0005_awesome_silvermane.sql +3 -0
- package/db/d1/migrations/0006_volatile_shriek.sql +2 -0
- package/db/d1/migrations/0007_superb_lila_cheney.sql +1 -0
- package/db/d1/migrations/0008_bitter_longshot.sql +17 -0
- package/db/d1/migrations/0009_wonderful_madame_masque.sql +28 -0
- package/db/d1/migrations/meta/0000_snapshot.json +1112 -0
- package/db/d1/migrations/meta/0001_snapshot.json +1187 -0
- package/db/d1/migrations/meta/0002_snapshot.json +1194 -0
- package/db/d1/migrations/meta/0003_snapshot.json +1296 -0
- package/db/d1/migrations/meta/0004_snapshot.json +1303 -0
- package/db/d1/migrations/meta/0005_snapshot.json +1325 -0
- package/db/d1/migrations/meta/0006_snapshot.json +1339 -0
- package/db/d1/migrations/meta/0007_snapshot.json +1347 -0
- package/db/d1/migrations/meta/0008_snapshot.json +1464 -0
- package/db/d1/migrations/meta/0009_snapshot.json +1648 -0
- package/db/d1/migrations/meta/_journal.json +76 -0
- package/db/d1/schema.ts +407 -0
- package/db/d1/sites.ts +374 -0
- package/db/d1/teamAiUsage.ts +101 -0
- package/db/d1/teams.ts +127 -0
- package/db/durable/drizzle.config.ts +8 -0
- package/db/durable/durableObjectClient.ts +480 -0
- package/db/durable/events.ts +100 -0
- package/db/durable/migrations/0000_fair_bucky.sql +38 -0
- package/db/durable/migrations/meta/0000_snapshot.json +278 -0
- package/db/durable/migrations/meta/_journal.json +13 -0
- package/db/durable/migrations/migrations.js +10 -0
- package/db/durable/schema.ts +5 -0
- package/db/durable/siteDurableObject.ts +1352 -0
- package/db/durable/types.ts +53 -0
- package/db/postgres/client.ts +13 -0
- package/db/postgres/drizzle.config.ts +12 -0
- package/db/postgres/migrations/0000_brainy_sprite.sql +116 -0
- package/db/postgres/migrations/meta/0000_snapshot.json +681 -0
- package/db/postgres/migrations/meta/_journal.json +13 -0
- package/db/postgres/schema.ts +145 -0
- package/db/postgres/sites.ts +118 -0
- package/db/tranformReports.ts +595 -0
- package/db/types.ts +55 -0
- package/endpoints/api_worker.tsx +1854 -0
- package/endpoints/site_do_worker.ts +11 -0
- package/index.d.ts +63 -0
- package/index.ts +83 -0
- package/lib/auth.ts +279 -0
- package/lib/geojson/world_countries.json +45307 -0
- package/lib/random_name.ts +41 -0
- package/lib/sendMail.ts +252 -0
- package/package.json +142 -0
- package/public/favicon.ico +0 -0
- package/public/images/android-chrome-192x192.png +0 -0
- package/public/images/android-chrome-512x512.png +0 -0
- package/public/images/apple-touch-icon.png +0 -0
- package/public/images/favicon-16x16.png +0 -0
- package/public/images/favicon-32x32.png +0 -0
- package/public/images/lytx_dark_dashboard.png +0 -0
- package/public/images/lytx_light_dashboard.png +0 -0
- package/public/images/safari-pinned-tab.svg +4 -0
- package/public/logo.png +0 -0
- package/public/site.webmanifest +26 -0
- package/public/sw.js +107 -0
- package/src/Document.tsx +86 -0
- package/src/api/ai_api.ts +1156 -0
- package/src/api/authMiddleware.ts +45 -0
- package/src/api/auth_api.ts +465 -0
- package/src/api/event_labels_api.ts +193 -0
- package/src/api/events_api.ts +210 -0
- package/src/api/queueWorker.ts +303 -0
- package/src/api/reports_api.ts +278 -0
- package/src/api/seed_api.ts +288 -0
- package/src/api/sites_api.ts +904 -0
- package/src/api/tag_api.ts +458 -0
- package/src/api/tag_api_v2.ts +289 -0
- package/src/api/team_api.ts +456 -0
- package/src/app/Dashboard.tsx +1339 -0
- package/src/app/Events.tsx +974 -0
- package/src/app/Explore.tsx +312 -0
- package/src/app/Layout.tsx +58 -0
- package/src/app/Settings.tsx +1302 -0
- package/src/app/components/DashboardCard.tsx +118 -0
- package/src/app/components/EditableCell.tsx +123 -0
- package/src/app/components/EventForm.tsx +93 -0
- package/src/app/components/MarketingFooter.tsx +49 -0
- package/src/app/components/MarketingNav.tsx +150 -0
- package/src/app/components/Nav.tsx +755 -0
- package/src/app/components/NewSiteSetup.tsx +298 -0
- package/src/app/components/SQLEditor.tsx +740 -0
- package/src/app/components/SiteSelector.tsx +126 -0
- package/src/app/components/SiteTag.tsx +42 -0
- package/src/app/components/SiteTagInstallCard.tsx +241 -0
- package/src/app/components/WorldMapCard.tsx +337 -0
- package/src/app/components/charts/ChartComponents.tsx +1481 -0
- package/src/app/components/charts/EventFunnel.tsx +45 -0
- package/src/app/components/charts/EventSummary.tsx +194 -0
- package/src/app/components/charts/SankeyFlows.tsx +72 -0
- package/src/app/components/marketing/CheckIcon.tsx +16 -0
- package/src/app/components/marketing/MarketingLayout.tsx +23 -0
- package/src/app/components/marketing/SectionHeading.tsx +35 -0
- package/src/app/components/reports/AskAiWorkspace.tsx +371 -0
- package/src/app/components/reports/CreateReportStarter.tsx +74 -0
- package/src/app/components/reports/DashboardRouteFiltersContext.tsx +14 -0
- package/src/app/components/reports/DashboardToolbar.tsx +154 -0
- package/src/app/components/reports/DashboardWorkspaceLayout.tsx +63 -0
- package/src/app/components/reports/DashboardWorkspaceShell.tsx +118 -0
- package/src/app/components/reports/ReportBuilderWorkspace.tsx +76 -0
- package/src/app/components/reports/custom/CustomReportBuilderPage.tsx +1667 -0
- package/src/app/components/reports/custom/ReportWidgetChart.tsx +297 -0
- package/src/app/components/reports/custom/buildWidgetSql.ts +151 -0
- package/src/app/components/reports/custom/chartPalettes.ts +18 -0
- package/src/app/components/reports/custom/types.ts +50 -0
- package/src/app/components/reports/reportBuilderMenuItems.ts +17 -0
- package/src/app/components/reports/useDashboardToolbarControls.tsx +235 -0
- package/src/app/components/ui/AlertBanner.tsx +101 -0
- package/src/app/components/ui/Button.tsx +55 -0
- package/src/app/components/ui/Card.tsx +80 -0
- package/src/app/components/ui/Input.tsx +72 -0
- package/src/app/components/ui/Link.tsx +23 -0
- package/src/app/components/ui/ReportBuilderMenu.tsx +246 -0
- package/src/app/components/ui/ThemeToggle.tsx +54 -0
- package/src/app/constants.ts +6 -0
- package/src/app/headers.ts +33 -0
- package/src/app/providers/AuthProvider.tsx +189 -0
- package/src/app/providers/ClientProviders.tsx +18 -0
- package/src/app/providers/QueryProvider.tsx +23 -0
- package/src/app/providers/ThemeProvider.tsx +88 -0
- package/src/app/utils/chartThemes.ts +146 -0
- package/src/app/utils/keybinds.ts +96 -0
- package/src/app/utils/media.tsx +24 -0
- package/src/client.tsx +114 -0
- package/src/config/createLytxAppConfig.ts +252 -0
- package/src/config/resourceNames.ts +88 -0
- package/src/db/index.ts +67 -0
- package/src/index.css +285 -0
- package/src/lib/featureFlags.ts +69 -0
- package/src/pages/GetStarted.tsx +290 -0
- package/src/pages/Home.tsx +268 -0
- package/src/pages/Login.tsx +283 -0
- package/src/pages/PrivacyPolicy.tsx +120 -0
- package/src/pages/Signup.tsx +267 -0
- package/src/pages/TermsOfService.tsx +126 -0
- package/src/pages/VerifyEmail.tsx +56 -0
- package/src/session/durableObject.ts +7 -0
- package/src/session/siteSchema.ts +86 -0
- package/src/session/types.ts +36 -0
- package/src/templates/README.md +80 -0
- package/src/templates/cleanFunctions.js +44 -0
- package/src/templates/embedFunctions.js +52 -0
- package/src/templates/lytx-shared.ts +662 -0
- package/src/templates/lytxpixel-core.ts +144 -0
- package/src/templates/lytxpixel.ts +267 -0
- package/src/templates/lytxpixelBrowser.js +634 -0
- package/src/templates/lytxpixelBrowser.mjs +634 -0
- package/src/templates/parseData.js +12 -0
- package/src/templates/script.ts +31 -0
- package/src/templates/template.tsx +50 -0
- package/src/templates/test.js +3 -0
- package/src/templates/trackWebEvents.ts +177 -0
- package/src/templates/vendors/clickcease.ts +8 -0
- package/src/templates/vendors/google.ts +174 -0
- package/src/templates/vendors/linkedin.ts +23 -0
- package/src/templates/vendors/meta.ts +56 -0
- package/src/templates/vendors/quantcast.ts +22 -0
- package/src/templates/vendors/simplfi.ts +7 -0
- package/src/types/app-context.ts +16 -0
- package/src/utilities/dashboardParams.ts +188 -0
- package/src/utilities/dashboardQueries.ts +537 -0
- package/src/utilities/dashboardTransforms.ts +167 -0
- package/src/utilities/dataValidation.ts +414 -0
- package/src/utilities/detector.ts +73 -0
- package/src/utilities/encrypt.ts +103 -0
- package/src/utilities/index.ts +13 -0
- package/src/utilities/parser.ts +117 -0
- package/src/utilities/performanceMonitoring.ts +570 -0
- package/src/utilities/route_interuptors.ts +24 -0
- package/src/worker.tsx +675 -0
- package/tsconfig.json +78 -0
- package/types/env.d.ts +16 -0
- package/types/rw.d.ts +7 -0
- package/types/shims.d.ts +53 -0
- package/types/vite.d.ts +19 -0
- package/vite/vite-plugin-pixel-bundle.ts +126 -0
- package/vite.config.ts +53 -0
- package/worker-configuration.d.ts +8401 -0
|
@@ -0,0 +1,1667 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
12
|
+
import { AuthContext } from "@/app/providers/AuthProvider";
|
|
13
|
+
import { AlertBanner } from "@/app/components/ui/AlertBanner";
|
|
14
|
+
import { DashboardCard } from "@/app/components/DashboardCard";
|
|
15
|
+
import { HelpTooltip } from "@/app/components/charts/ChartComponents";
|
|
16
|
+
import { ReportWidgetChart } from "@/app/components/reports/custom/ReportWidgetChart";
|
|
17
|
+
import { buildSqlForWidget } from "@/app/components/reports/custom/buildWidgetSql";
|
|
18
|
+
import {
|
|
19
|
+
reportColorPalettes,
|
|
20
|
+
reportPaletteOptions,
|
|
21
|
+
} from "@/app/components/reports/custom/chartPalettes";
|
|
22
|
+
import { useDashboardRouteFilters } from "@/app/components/reports/DashboardRouteFiltersContext";
|
|
23
|
+
import type {
|
|
24
|
+
CustomReportConfig,
|
|
25
|
+
CustomReportRecord,
|
|
26
|
+
CustomReportWidgetConfig,
|
|
27
|
+
ReportAggregation,
|
|
28
|
+
ReportChartType,
|
|
29
|
+
ReportColorPalette,
|
|
30
|
+
SiteEventsSchemaColumn,
|
|
31
|
+
} from "@/app/components/reports/custom/types";
|
|
32
|
+
import type { EventLabelSelect } from "@db/d1/schema";
|
|
33
|
+
|
|
34
|
+
type RowMode = "split" | "full";
|
|
35
|
+
|
|
36
|
+
type CanvasRow = {
|
|
37
|
+
mode: RowMode;
|
|
38
|
+
full: CustomReportWidgetConfig | null;
|
|
39
|
+
left: CustomReportWidgetConfig | null;
|
|
40
|
+
right: CustomReportWidgetConfig | null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const ROW_HEIGHT = 4;
|
|
44
|
+
const HALF_SLOT_WIDTH = 6;
|
|
45
|
+
const FULL_SLOT_WIDTH = 12;
|
|
46
|
+
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
47
|
+
|
|
48
|
+
const normalizeHexColor = (value: string): string | null => {
|
|
49
|
+
const trimmed = value.trim();
|
|
50
|
+
if (!trimmed) return null;
|
|
51
|
+
|
|
52
|
+
const withHash = trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
|
|
53
|
+
if (!HEX_COLOR_PATTERN.test(withHash)) return null;
|
|
54
|
+
return withHash.toUpperCase();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const getHalfSlotLayout = (rowIndex: number, slotIndex: 0 | 1): CustomReportWidgetConfig["layout"] => ({
|
|
58
|
+
x: slotIndex === 0 ? 0 : HALF_SLOT_WIDTH,
|
|
59
|
+
y: rowIndex * ROW_HEIGHT,
|
|
60
|
+
w: HALF_SLOT_WIDTH,
|
|
61
|
+
h: ROW_HEIGHT,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const getFullRowLayout = (rowIndex: number): CustomReportWidgetConfig["layout"] => ({
|
|
65
|
+
x: 0,
|
|
66
|
+
y: rowIndex * ROW_HEIGHT,
|
|
67
|
+
w: FULL_SLOT_WIDTH,
|
|
68
|
+
h: ROW_HEIGHT,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const getRowIndexFromLayout = (layout: CustomReportWidgetConfig["layout"]) =>
|
|
72
|
+
Math.max(0, Math.floor(layout.y / ROW_HEIGHT));
|
|
73
|
+
|
|
74
|
+
const getSlotIndexFromLayout = (layout: CustomReportWidgetConfig["layout"]) =>
|
|
75
|
+
layout.x >= HALF_SLOT_WIDTH ? 1 : 0;
|
|
76
|
+
|
|
77
|
+
const isFullRowLayout = (layout: CustomReportWidgetConfig["layout"]) => layout.w >= FULL_SLOT_WIDTH;
|
|
78
|
+
|
|
79
|
+
const buildCanvasRows = (widgetList: CustomReportWidgetConfig[]): CanvasRow[] => {
|
|
80
|
+
const groupedRows = new Map<number, CustomReportWidgetConfig[]>();
|
|
81
|
+
|
|
82
|
+
for (const widget of widgetList) {
|
|
83
|
+
const rowIndex = getRowIndexFromLayout(widget.layout);
|
|
84
|
+
const rowWidgets = groupedRows.get(rowIndex) ?? [];
|
|
85
|
+
rowWidgets.push(widget);
|
|
86
|
+
groupedRows.set(rowIndex, rowWidgets);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const rowIndexes = [...groupedRows.keys()].toSorted((a, b) => a - b);
|
|
90
|
+
const rows: CanvasRow[] = [];
|
|
91
|
+
const overflow: CustomReportWidgetConfig[] = [];
|
|
92
|
+
|
|
93
|
+
for (const rowIndex of rowIndexes) {
|
|
94
|
+
const rowWidgets = groupedRows.get(rowIndex) ?? [];
|
|
95
|
+
const fullWidget = rowWidgets.find((widget) => isFullRowLayout(widget.layout));
|
|
96
|
+
|
|
97
|
+
if (fullWidget) {
|
|
98
|
+
rows.push({ mode: "full", full: fullWidget, left: null, right: null });
|
|
99
|
+
for (const widget of rowWidgets) {
|
|
100
|
+
if (widget.id !== fullWidget.id) overflow.push(widget);
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const ordered = [...rowWidgets].toSorted((a, b) => getSlotIndexFromLayout(a.layout) - getSlotIndexFromLayout(b.layout));
|
|
106
|
+
rows.push({
|
|
107
|
+
mode: "split",
|
|
108
|
+
full: null,
|
|
109
|
+
left: ordered[0] ?? null,
|
|
110
|
+
right: ordered[1] ?? null,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
for (const widget of ordered.slice(2)) {
|
|
114
|
+
overflow.push(widget);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
while (overflow.length > 0) {
|
|
119
|
+
const first = overflow.shift()!;
|
|
120
|
+
if (isFullRowLayout(first.layout)) {
|
|
121
|
+
rows.push({ mode: "full", full: first, left: null, right: null });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const second = overflow[0] && !isFullRowLayout(overflow[0].layout)
|
|
126
|
+
? overflow.shift() ?? null
|
|
127
|
+
: null;
|
|
128
|
+
|
|
129
|
+
rows.push({
|
|
130
|
+
mode: "split",
|
|
131
|
+
full: null,
|
|
132
|
+
left: first,
|
|
133
|
+
right: second,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return rows;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const flattenCanvasRows = (rows: CanvasRow[]): CustomReportWidgetConfig[] => {
|
|
141
|
+
const nextWidgets: CustomReportWidgetConfig[] = [];
|
|
142
|
+
|
|
143
|
+
rows.forEach((row, rowIndex) => {
|
|
144
|
+
if (row.mode === "full") {
|
|
145
|
+
const widget = row.full ?? row.left ?? row.right;
|
|
146
|
+
if (!widget) return;
|
|
147
|
+
nextWidgets.push({
|
|
148
|
+
...widget,
|
|
149
|
+
layout: getFullRowLayout(rowIndex),
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (row.left) {
|
|
155
|
+
nextWidgets.push({
|
|
156
|
+
...row.left,
|
|
157
|
+
layout: getHalfSlotLayout(rowIndex, 0),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if (row.right) {
|
|
161
|
+
nextWidgets.push({
|
|
162
|
+
...row.right,
|
|
163
|
+
layout: getHalfSlotLayout(rowIndex, 1),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return nextWidgets;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const normalizeWidgetsForRows = (widgetList: CustomReportWidgetConfig[]) =>
|
|
172
|
+
flattenCanvasRows(buildCanvasRows(widgetList));
|
|
173
|
+
|
|
174
|
+
const getFocusableElements = (container: HTMLElement | null): HTMLElement[] => {
|
|
175
|
+
if (!container) return [];
|
|
176
|
+
|
|
177
|
+
const selectors = [
|
|
178
|
+
"a[href]",
|
|
179
|
+
"button:not([disabled])",
|
|
180
|
+
"input:not([disabled])",
|
|
181
|
+
"select:not([disabled])",
|
|
182
|
+
"textarea:not([disabled])",
|
|
183
|
+
"[tabindex]:not([tabindex='-1'])",
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
return Array.from(container.querySelectorAll<HTMLElement>(selectors.join(","))).filter(
|
|
187
|
+
(element) =>
|
|
188
|
+
!element.hasAttribute("disabled") &&
|
|
189
|
+
element.getAttribute("aria-hidden") !== "true",
|
|
190
|
+
);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const chartTypeOptions: Array<{ value: ReportChartType; label: string }> = [
|
|
194
|
+
{ value: "bar", label: "Bar" },
|
|
195
|
+
{ value: "line", label: "Line" },
|
|
196
|
+
{ value: "pie", label: "Pie" },
|
|
197
|
+
{ value: "funnel", label: "Funnel" },
|
|
198
|
+
{ value: "sankey", label: "Sankey" },
|
|
199
|
+
{ value: "map", label: "Map" },
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
const aggregationOptions: Array<{ value: ReportAggregation; label: string }> = [
|
|
203
|
+
{ value: "count", label: "Count" },
|
|
204
|
+
{ value: "unique_users", label: "Unique users" },
|
|
205
|
+
{ value: "sum", label: "Sum of y" },
|
|
206
|
+
{ value: "avg", label: "Average of y" },
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
type CustomReportBuilderPageProps = {
|
|
210
|
+
reportUuid?: string;
|
|
211
|
+
initialTemplate?: string | null;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
type SchemaResponse = {
|
|
215
|
+
tables?: Array<{ columns?: SiteEventsSchemaColumn[] }>;
|
|
216
|
+
error?: string;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
type ReportApiResponse = {
|
|
220
|
+
report?: CustomReportRecord;
|
|
221
|
+
error?: string;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
type WidgetColorField = "customPrimaryColor" | "customSecondaryColor";
|
|
225
|
+
|
|
226
|
+
const makeWidgetId = () =>
|
|
227
|
+
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
228
|
+
? crypto.randomUUID()
|
|
229
|
+
: `widget_${Math.random().toString(36).slice(2)}`;
|
|
230
|
+
|
|
231
|
+
const pickFirstColumn = (columns: string[], preferred: string[]) => {
|
|
232
|
+
for (const value of preferred) {
|
|
233
|
+
if (columns.includes(value)) return value;
|
|
234
|
+
}
|
|
235
|
+
return columns[0] || "event";
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const createWidget = (
|
|
239
|
+
chartType: ReportChartType,
|
|
240
|
+
availableColumns: string[],
|
|
241
|
+
): CustomReportWidgetConfig => {
|
|
242
|
+
const xField = chartType === "map"
|
|
243
|
+
? pickFirstColumn(availableColumns, ["country", "region", "city", "event"])
|
|
244
|
+
: pickFirstColumn(availableColumns, [
|
|
245
|
+
"event",
|
|
246
|
+
"client_page_url",
|
|
247
|
+
"referer",
|
|
248
|
+
"country",
|
|
249
|
+
"city",
|
|
250
|
+
"device_type",
|
|
251
|
+
"created_at",
|
|
252
|
+
]);
|
|
253
|
+
const yField = pickFirstColumn(availableColumns, [
|
|
254
|
+
"screen_width",
|
|
255
|
+
"screen_height",
|
|
256
|
+
"id",
|
|
257
|
+
]);
|
|
258
|
+
const sourceField = pickFirstColumn(availableColumns, ["referer", "country", "city", "event"]);
|
|
259
|
+
const targetField = pickFirstColumn(availableColumns, ["event", "client_page_url", "device_type", "country"]);
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
id: makeWidgetId(),
|
|
263
|
+
title: `New ${chartType} chart`,
|
|
264
|
+
chartType,
|
|
265
|
+
xField,
|
|
266
|
+
yField,
|
|
267
|
+
aggregation: "count",
|
|
268
|
+
sourceField,
|
|
269
|
+
targetField,
|
|
270
|
+
colorPalette: "primary",
|
|
271
|
+
customPrimaryColor: null,
|
|
272
|
+
customSecondaryColor: null,
|
|
273
|
+
limit: 20,
|
|
274
|
+
layout: getHalfSlotLayout(0, 0),
|
|
275
|
+
};
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const createTemplateWidgets = (
|
|
279
|
+
template: string | null | undefined,
|
|
280
|
+
availableColumns: string[],
|
|
281
|
+
): CustomReportWidgetConfig[] => {
|
|
282
|
+
if (template === "ecomm-tracker") {
|
|
283
|
+
return [
|
|
284
|
+
{
|
|
285
|
+
...createWidget("line", availableColumns),
|
|
286
|
+
title: "Daily page views",
|
|
287
|
+
xField: "created_at",
|
|
288
|
+
colorPalette: "line",
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
...createWidget("bar", availableColumns),
|
|
292
|
+
title: "Top product pages",
|
|
293
|
+
xField: pickFirstColumn(availableColumns, ["client_page_url", "page_url", "event"]),
|
|
294
|
+
colorPalette: "primary",
|
|
295
|
+
},
|
|
296
|
+
];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (template === "marketing-leads") {
|
|
300
|
+
return [
|
|
301
|
+
{
|
|
302
|
+
...createWidget("bar", availableColumns),
|
|
303
|
+
title: "Leads by source",
|
|
304
|
+
xField: pickFirstColumn(availableColumns, ["referer", "country", "event"]),
|
|
305
|
+
colorPalette: "mixed",
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
...createWidget("pie", availableColumns),
|
|
309
|
+
title: "Leads by region",
|
|
310
|
+
xField: pickFirstColumn(availableColumns, ["region", "country", "city"]),
|
|
311
|
+
colorPalette: "secondary",
|
|
312
|
+
},
|
|
313
|
+
];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return [];
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
export function CustomReportBuilderPage({ reportUuid, initialTemplate }: CustomReportBuilderPageProps) {
|
|
320
|
+
const queryClient = useQueryClient();
|
|
321
|
+
const isExistingReport = Boolean(reportUuid);
|
|
322
|
+
const { current_site, data: session, isPending: isSessionLoading } = useContext(AuthContext) || {
|
|
323
|
+
current_site: null,
|
|
324
|
+
data: null,
|
|
325
|
+
isPending: true,
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const fallbackSiteId = session?.userSites?.[0]?.site_id ?? null;
|
|
329
|
+
const preferredSiteId = current_site?.id ?? fallbackSiteId;
|
|
330
|
+
const routeFilterContext = useDashboardRouteFilters();
|
|
331
|
+
const activeFilters = routeFilterContext?.filters;
|
|
332
|
+
|
|
333
|
+
const [reportName, setReportName] = useState("Untitled custom report");
|
|
334
|
+
const [reportSiteId, setReportSiteId] = useState<number | null>(preferredSiteId);
|
|
335
|
+
const [widgets, setWidgets] = useState<CustomReportWidgetConfig[]>([]);
|
|
336
|
+
const [selectedChartType, setSelectedChartType] = useState<ReportChartType>("bar");
|
|
337
|
+
const [selectedWidgetId, setSelectedWidgetId] = useState<string | null>(null);
|
|
338
|
+
const [isEditing, setIsEditing] = useState(!isExistingReport);
|
|
339
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
340
|
+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
341
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
342
|
+
const [isDeleting, setIsDeleting] = useState(false);
|
|
343
|
+
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
|
344
|
+
const [extraRows, setExtraRows] = useState(0);
|
|
345
|
+
const [rowModeOverrides, setRowModeOverrides] = useState<Record<number, RowMode>>({});
|
|
346
|
+
const [editingWidgetTitleId, setEditingWidgetTitleId] = useState<string | null>(null);
|
|
347
|
+
const [widgetTitleDraft, setWidgetTitleDraft] = useState("");
|
|
348
|
+
const [primaryColorDraft, setPrimaryColorDraft] = useState("");
|
|
349
|
+
const [secondaryColorDraft, setSecondaryColorDraft] = useState("");
|
|
350
|
+
|
|
351
|
+
const hasHydratedReportRef = useRef(false);
|
|
352
|
+
const hasInitializedTemplateRef = useRef(false);
|
|
353
|
+
const deleteModalRef = useRef<HTMLDivElement | null>(null);
|
|
354
|
+
const previousFocusedElementRef = useRef<HTMLElement | null>(null);
|
|
355
|
+
const deleteCancelButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
356
|
+
const widgetTitleInputRef = useRef<HTMLInputElement | null>(null);
|
|
357
|
+
const deleteModalTitleId = useId();
|
|
358
|
+
const deleteModalDescriptionId = useId();
|
|
359
|
+
|
|
360
|
+
useEffect(() => {
|
|
361
|
+
if (!notice) return;
|
|
362
|
+
|
|
363
|
+
const timeoutId = window.setTimeout(() => {
|
|
364
|
+
setNotice(null);
|
|
365
|
+
}, 5000);
|
|
366
|
+
|
|
367
|
+
return () => {
|
|
368
|
+
window.clearTimeout(timeoutId);
|
|
369
|
+
};
|
|
370
|
+
}, [notice]);
|
|
371
|
+
|
|
372
|
+
useEffect(() => {
|
|
373
|
+
if (!isDeleteModalOpen) return;
|
|
374
|
+
|
|
375
|
+
previousFocusedElementRef.current = document.activeElement as HTMLElement | null;
|
|
376
|
+
const frame = requestAnimationFrame(() => {
|
|
377
|
+
deleteCancelButtonRef.current?.focus();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
return () => {
|
|
381
|
+
cancelAnimationFrame(frame);
|
|
382
|
+
previousFocusedElementRef.current?.focus();
|
|
383
|
+
};
|
|
384
|
+
}, [isDeleteModalOpen]);
|
|
385
|
+
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
if (!isExistingReport && preferredSiteId) {
|
|
388
|
+
setReportSiteId(preferredSiteId);
|
|
389
|
+
}
|
|
390
|
+
}, [isExistingReport, preferredSiteId]);
|
|
391
|
+
|
|
392
|
+
useEffect(() => {
|
|
393
|
+
setIsEditing(!isExistingReport);
|
|
394
|
+
}, [isExistingReport, reportUuid]);
|
|
395
|
+
|
|
396
|
+
useEffect(() => {
|
|
397
|
+
hasHydratedReportRef.current = false;
|
|
398
|
+
}, [reportUuid]);
|
|
399
|
+
|
|
400
|
+
const schemaQuery = useQuery({
|
|
401
|
+
queryKey: ["custom-report-schema", reportSiteId],
|
|
402
|
+
enabled: Boolean(reportSiteId),
|
|
403
|
+
queryFn: async () => {
|
|
404
|
+
const response = await fetch(`/api/site-events/schema?site_id=${reportSiteId}`);
|
|
405
|
+
const data = (await response.json().catch(() => null)) as SchemaResponse | null;
|
|
406
|
+
if (!response.ok) {
|
|
407
|
+
throw new Error(data?.error || "Failed to fetch site_events schema");
|
|
408
|
+
}
|
|
409
|
+
return data;
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
const reportQuery = useQuery({
|
|
414
|
+
queryKey: ["custom-report-config", reportUuid],
|
|
415
|
+
enabled: isExistingReport,
|
|
416
|
+
queryFn: async () => {
|
|
417
|
+
const response = await fetch(`/api/reports/custom/${reportUuid}`);
|
|
418
|
+
const data = (await response.json().catch(() => null)) as ReportApiResponse | null;
|
|
419
|
+
if (!response.ok) {
|
|
420
|
+
throw new Error(data?.error || "Failed to fetch custom report");
|
|
421
|
+
}
|
|
422
|
+
return data?.report ?? null;
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const labelsQuery = useQuery<EventLabelSelect[], Error>({
|
|
427
|
+
queryKey: ["event-labels", reportSiteId],
|
|
428
|
+
queryFn: async () => {
|
|
429
|
+
if (!reportSiteId) return [];
|
|
430
|
+
const response = await fetch(`/api/event-labels?site_id=${reportSiteId}`);
|
|
431
|
+
if (!response.ok) throw new Error("Failed to fetch event labels");
|
|
432
|
+
return response.json();
|
|
433
|
+
},
|
|
434
|
+
enabled: Boolean(reportSiteId),
|
|
435
|
+
staleTime: 5 * 60 * 1000,
|
|
436
|
+
gcTime: 10 * 60 * 1000,
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
const eventLabelsMap = useMemo(() => {
|
|
440
|
+
const map = new Map<string, string>();
|
|
441
|
+
if (labelsQuery.data) {
|
|
442
|
+
for (const label of labelsQuery.data) {
|
|
443
|
+
map.set(label.event_name, label.label);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return map;
|
|
447
|
+
}, [labelsQuery.data]);
|
|
448
|
+
|
|
449
|
+
useEffect(() => {
|
|
450
|
+
if (!reportQuery.data || hasHydratedReportRef.current) return;
|
|
451
|
+
|
|
452
|
+
const report = reportQuery.data;
|
|
453
|
+
setReportName(report.name || "Untitled custom report");
|
|
454
|
+
setReportSiteId(report.site_id);
|
|
455
|
+
const normalizedWidgets = normalizeWidgetsForRows(report.config?.widgets || []);
|
|
456
|
+
setWidgets(normalizedWidgets);
|
|
457
|
+
setSelectedWidgetId(normalizedWidgets[0]?.id || null);
|
|
458
|
+
setExtraRows(0);
|
|
459
|
+
setRowModeOverrides({});
|
|
460
|
+
hasHydratedReportRef.current = true;
|
|
461
|
+
}, [reportQuery.data]);
|
|
462
|
+
|
|
463
|
+
const availableColumns = useMemo(() => {
|
|
464
|
+
const columns = schemaQuery.data?.tables?.[0]?.columns || [];
|
|
465
|
+
return columns.map((column) => column.name);
|
|
466
|
+
}, [schemaQuery.data]);
|
|
467
|
+
|
|
468
|
+
useEffect(() => {
|
|
469
|
+
if (isExistingReport) return;
|
|
470
|
+
if (hasInitializedTemplateRef.current) return;
|
|
471
|
+
if (availableColumns.length === 0) return;
|
|
472
|
+
|
|
473
|
+
const templateWidgets = normalizeWidgetsForRows(
|
|
474
|
+
createTemplateWidgets(initialTemplate, availableColumns),
|
|
475
|
+
);
|
|
476
|
+
if (templateWidgets.length > 0) {
|
|
477
|
+
setWidgets(templateWidgets);
|
|
478
|
+
setSelectedWidgetId(templateWidgets[0].id);
|
|
479
|
+
}
|
|
480
|
+
setExtraRows(0);
|
|
481
|
+
setRowModeOverrides({});
|
|
482
|
+
hasInitializedTemplateRef.current = true;
|
|
483
|
+
}, [isExistingReport, initialTemplate, availableColumns]);
|
|
484
|
+
|
|
485
|
+
const selectedWidget = widgets.find((widget) => widget.id === selectedWidgetId) ?? null;
|
|
486
|
+
|
|
487
|
+
useEffect(() => {
|
|
488
|
+
if (!editingWidgetTitleId) return;
|
|
489
|
+
if (widgets.some((widget) => widget.id === editingWidgetTitleId)) return;
|
|
490
|
+
|
|
491
|
+
setEditingWidgetTitleId(null);
|
|
492
|
+
setWidgetTitleDraft("");
|
|
493
|
+
}, [editingWidgetTitleId, widgets]);
|
|
494
|
+
|
|
495
|
+
useEffect(() => {
|
|
496
|
+
if (!editingWidgetTitleId) return;
|
|
497
|
+
widgetTitleInputRef.current?.focus();
|
|
498
|
+
widgetTitleInputRef.current?.select();
|
|
499
|
+
}, [editingWidgetTitleId]);
|
|
500
|
+
|
|
501
|
+
const startWidgetTitleEdit = (widget: CustomReportWidgetConfig) => {
|
|
502
|
+
setSelectedWidgetId(widget.id);
|
|
503
|
+
setEditingWidgetTitleId(widget.id);
|
|
504
|
+
setWidgetTitleDraft(widget.title);
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const cancelWidgetTitleEdit = () => {
|
|
508
|
+
setEditingWidgetTitleId(null);
|
|
509
|
+
setWidgetTitleDraft("");
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const commitWidgetTitleEdit = (widgetId: string) => {
|
|
513
|
+
updateWidget(widgetId, (widget) => ({
|
|
514
|
+
...widget,
|
|
515
|
+
title: widgetTitleDraft,
|
|
516
|
+
}));
|
|
517
|
+
setEditingWidgetTitleId(null);
|
|
518
|
+
setWidgetTitleDraft("");
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
const renderWidgetTitle = (widget: CustomReportWidgetConfig) => {
|
|
522
|
+
if (!isEditing) return widget.title;
|
|
523
|
+
|
|
524
|
+
const isTitleEditing = editingWidgetTitleId === widget.id;
|
|
525
|
+
if (isTitleEditing) {
|
|
526
|
+
return (
|
|
527
|
+
<input
|
|
528
|
+
ref={widgetTitleInputRef}
|
|
529
|
+
value={widgetTitleDraft}
|
|
530
|
+
onChange={(event) => setWidgetTitleDraft(event.target.value)}
|
|
531
|
+
onBlur={() => commitWidgetTitleEdit(widget.id)}
|
|
532
|
+
onClick={(event) => event.stopPropagation()}
|
|
533
|
+
onKeyDown={(event) => {
|
|
534
|
+
event.stopPropagation();
|
|
535
|
+
|
|
536
|
+
if (event.key === "Enter") {
|
|
537
|
+
event.preventDefault();
|
|
538
|
+
commitWidgetTitleEdit(widget.id);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (event.key === "Escape") {
|
|
543
|
+
event.preventDefault();
|
|
544
|
+
cancelWidgetTitleEdit();
|
|
545
|
+
}
|
|
546
|
+
}}
|
|
547
|
+
className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1 text-sm font-semibold text-(--theme-text-primary)"
|
|
548
|
+
aria-label="Chart title"
|
|
549
|
+
/>
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return (
|
|
554
|
+
<button
|
|
555
|
+
type="button"
|
|
556
|
+
onClick={(event) => {
|
|
557
|
+
event.stopPropagation();
|
|
558
|
+
startWidgetTitleEdit(widget);
|
|
559
|
+
}}
|
|
560
|
+
className="-mx-1 rounded px-1 text-left text-lg sm:text-xl font-semibold text-(--theme-text-primary) hover:bg-(--theme-bg-secondary)"
|
|
561
|
+
aria-label={`Edit title for ${widget.title}`}
|
|
562
|
+
>
|
|
563
|
+
{widget.title || "Untitled chart"}
|
|
564
|
+
</button>
|
|
565
|
+
);
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const canvasRows = useMemo(() => buildCanvasRows(widgets), [widgets]);
|
|
569
|
+
|
|
570
|
+
const totalRows = useMemo(
|
|
571
|
+
() => Math.max(1, canvasRows.length) + extraRows,
|
|
572
|
+
[canvasRows.length, extraRows],
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
const rowsForRender = useMemo(
|
|
576
|
+
() =>
|
|
577
|
+
Array.from({ length: totalRows }, (_, rowIndex) => {
|
|
578
|
+
const existingRow = canvasRows[rowIndex];
|
|
579
|
+
const mode = existingRow?.mode ?? rowModeOverrides[rowIndex] ?? "full";
|
|
580
|
+
return {
|
|
581
|
+
rowIndex,
|
|
582
|
+
mode,
|
|
583
|
+
full: existingRow?.full ?? null,
|
|
584
|
+
left: existingRow?.left ?? null,
|
|
585
|
+
right: existingRow?.right ?? null,
|
|
586
|
+
};
|
|
587
|
+
}),
|
|
588
|
+
[canvasRows, rowModeOverrides, totalRows],
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
const selectedWidgetRowIndex = selectedWidget ? getRowIndexFromLayout(selectedWidget.layout) : null;
|
|
592
|
+
const selectedRow = selectedWidgetRowIndex !== null ? rowsForRender[selectedWidgetRowIndex] ?? null : null;
|
|
593
|
+
const selectedWidgetPlacement = useMemo(() => {
|
|
594
|
+
if (!selectedWidgetId) return null;
|
|
595
|
+
|
|
596
|
+
for (const row of rowsForRender) {
|
|
597
|
+
if (row.full?.id === selectedWidgetId) {
|
|
598
|
+
return `Row ${row.rowIndex + 1} - Full width`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (row.left?.id === selectedWidgetId) {
|
|
602
|
+
return `Row ${row.rowIndex + 1} - Slot 1`;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (row.right?.id === selectedWidgetId) {
|
|
606
|
+
return `Row ${row.rowIndex + 1} - Slot 2`;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return null;
|
|
611
|
+
}, [rowsForRender, selectedWidgetId]);
|
|
612
|
+
const yFieldUsedByAggregation = selectedWidget
|
|
613
|
+
? selectedWidget.aggregation === "sum" || selectedWidget.aggregation === "avg"
|
|
614
|
+
: false;
|
|
615
|
+
const selectedPalette = selectedWidget
|
|
616
|
+
? reportColorPalettes[selectedWidget.colorPalette]
|
|
617
|
+
: reportColorPalettes.primary;
|
|
618
|
+
const primaryFallbackColor = selectedPalette[0] ?? "#3B82F6";
|
|
619
|
+
const secondaryFallbackColor = selectedPalette[1] ?? primaryFallbackColor;
|
|
620
|
+
|
|
621
|
+
useEffect(() => {
|
|
622
|
+
if (!selectedWidget) {
|
|
623
|
+
setPrimaryColorDraft("");
|
|
624
|
+
setSecondaryColorDraft("");
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
setPrimaryColorDraft(selectedWidget.customPrimaryColor ?? "");
|
|
629
|
+
setSecondaryColorDraft(selectedWidget.customSecondaryColor ?? "");
|
|
630
|
+
}, [
|
|
631
|
+
selectedWidget?.id,
|
|
632
|
+
selectedWidget?.customPrimaryColor,
|
|
633
|
+
selectedWidget?.customSecondaryColor,
|
|
634
|
+
]);
|
|
635
|
+
|
|
636
|
+
const applyCustomColor = (widgetId: string, field: WidgetColorField, rawValue: string) => {
|
|
637
|
+
const normalized = normalizeHexColor(rawValue);
|
|
638
|
+
updateWidget(widgetId, (widget) => ({
|
|
639
|
+
...widget,
|
|
640
|
+
[field]: normalized,
|
|
641
|
+
}));
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
const addWidgetAtSlot = (rowIndex: number, slotIndex: 0 | 1) => {
|
|
645
|
+
setErrorMessage(null);
|
|
646
|
+
|
|
647
|
+
const row = rowsForRender[rowIndex];
|
|
648
|
+
if (!row) return;
|
|
649
|
+
|
|
650
|
+
if (row.mode === "full") {
|
|
651
|
+
if (row.full) {
|
|
652
|
+
setSelectedWidgetId(row.full.id);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const fullWidget: CustomReportWidgetConfig = {
|
|
657
|
+
...createWidget(selectedChartType, availableColumns),
|
|
658
|
+
layout: getFullRowLayout(rowIndex),
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
const nextRows = [...canvasRows];
|
|
662
|
+
while (nextRows.length <= rowIndex) {
|
|
663
|
+
nextRows.push({ mode: "split", full: null, left: null, right: null });
|
|
664
|
+
}
|
|
665
|
+
nextRows[rowIndex] = { mode: "full", full: fullWidget, left: null, right: null };
|
|
666
|
+
|
|
667
|
+
setWidgets(normalizeWidgetsForRows(flattenCanvasRows(nextRows)));
|
|
668
|
+
setSelectedWidgetId(fullWidget.id);
|
|
669
|
+
setExtraRows((prev) => (rowIndex >= canvasRows.length ? Math.max(0, prev - 1) : prev));
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const existing = slotIndex === 0 ? row.left : row.right;
|
|
674
|
+
if (existing) {
|
|
675
|
+
setSelectedWidgetId(existing.id);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const nextWidget: CustomReportWidgetConfig = {
|
|
680
|
+
...createWidget(selectedChartType, availableColumns),
|
|
681
|
+
layout: getHalfSlotLayout(rowIndex, slotIndex),
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
const nextRows = [...canvasRows];
|
|
685
|
+
while (nextRows.length <= rowIndex) {
|
|
686
|
+
nextRows.push({ mode: "split", full: null, left: null, right: null });
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const targetRow = nextRows[rowIndex] ?? { mode: "split", full: null, left: null, right: null };
|
|
690
|
+
nextRows[rowIndex] = {
|
|
691
|
+
mode: "split",
|
|
692
|
+
full: null,
|
|
693
|
+
left: slotIndex === 0 ? nextWidget : targetRow.left,
|
|
694
|
+
right: slotIndex === 1 ? nextWidget : targetRow.right,
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
setWidgets(normalizeWidgetsForRows(flattenCanvasRows(nextRows)));
|
|
698
|
+
setSelectedWidgetId(nextWidget.id);
|
|
699
|
+
setExtraRows((prev) => (rowIndex >= canvasRows.length ? Math.max(0, prev - 1) : prev));
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
const updateRowMode = (rowIndex: number, nextMode: RowMode) => {
|
|
703
|
+
const row = rowsForRender[rowIndex];
|
|
704
|
+
if (!row) return;
|
|
705
|
+
|
|
706
|
+
const hasWidgets = Boolean(row.full || row.left || row.right);
|
|
707
|
+
if (!hasWidgets) {
|
|
708
|
+
setRowModeOverrides((prev) => ({ ...prev, [rowIndex]: nextMode }));
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const nextRows = [...canvasRows];
|
|
713
|
+
while (nextRows.length <= rowIndex) {
|
|
714
|
+
nextRows.push({ mode: "split", full: null, left: null, right: null });
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const currentRow = nextRows[rowIndex] ?? { mode: "split", full: null, left: null, right: null };
|
|
718
|
+
|
|
719
|
+
if (nextMode === "full") {
|
|
720
|
+
const candidates = [currentRow.full, currentRow.left, currentRow.right].filter(
|
|
721
|
+
(widget): widget is CustomReportWidgetConfig => Boolean(widget),
|
|
722
|
+
);
|
|
723
|
+
if (candidates.length === 0) {
|
|
724
|
+
setRowModeOverrides((prev) => ({ ...prev, [rowIndex]: "full" }));
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const primary =
|
|
729
|
+
candidates.find((widget) => widget.id === selectedWidgetId) ?? candidates[0];
|
|
730
|
+
const overflow = candidates.filter((widget) => widget.id !== primary.id);
|
|
731
|
+
|
|
732
|
+
nextRows[rowIndex] = { mode: "full", full: primary, left: null, right: null };
|
|
733
|
+
if (overflow.length > 0) {
|
|
734
|
+
nextRows.splice(rowIndex + 1, 0, {
|
|
735
|
+
mode: "split",
|
|
736
|
+
full: null,
|
|
737
|
+
left: overflow[0] ?? null,
|
|
738
|
+
right: overflow[1] ?? null,
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
setSelectedWidgetId(primary.id);
|
|
742
|
+
} else {
|
|
743
|
+
if (currentRow.mode === "full") {
|
|
744
|
+
let pulledWidget: CustomReportWidgetConfig | null = null;
|
|
745
|
+
const nextRow = nextRows[rowIndex + 1];
|
|
746
|
+
if (nextRow?.mode === "split") {
|
|
747
|
+
pulledWidget = nextRow.left ?? nextRow.right ?? null;
|
|
748
|
+
|
|
749
|
+
if (pulledWidget) {
|
|
750
|
+
const pulledWidgetId = pulledWidget.id;
|
|
751
|
+
const remaining = [nextRow.left, nextRow.right].filter(
|
|
752
|
+
(widget): widget is CustomReportWidgetConfig =>
|
|
753
|
+
widget != null && widget.id !== pulledWidgetId,
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
if (remaining.length === 0) {
|
|
757
|
+
nextRows.splice(rowIndex + 1, 1);
|
|
758
|
+
} else {
|
|
759
|
+
nextRows[rowIndex + 1] = {
|
|
760
|
+
mode: "split",
|
|
761
|
+
full: null,
|
|
762
|
+
left: remaining[0] ?? null,
|
|
763
|
+
right: remaining[1] ?? null,
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
nextRows[rowIndex] = {
|
|
770
|
+
mode: "split",
|
|
771
|
+
full: null,
|
|
772
|
+
left: currentRow.full,
|
|
773
|
+
right: pulledWidget,
|
|
774
|
+
};
|
|
775
|
+
if (currentRow.full) {
|
|
776
|
+
setSelectedWidgetId(currentRow.full.id);
|
|
777
|
+
}
|
|
778
|
+
} else {
|
|
779
|
+
nextRows[rowIndex] = {
|
|
780
|
+
mode: "split",
|
|
781
|
+
full: null,
|
|
782
|
+
left: currentRow.left,
|
|
783
|
+
right: currentRow.right,
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
setRowModeOverrides((prev) => {
|
|
789
|
+
const next = { ...prev };
|
|
790
|
+
delete next[rowIndex];
|
|
791
|
+
return next;
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
setWidgets(normalizeWidgetsForRows(flattenCanvasRows(nextRows)));
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
const updateWidget = (widgetId: string, updater: (widget: CustomReportWidgetConfig) => CustomReportWidgetConfig) => {
|
|
798
|
+
setWidgets((prev) =>
|
|
799
|
+
normalizeWidgetsForRows(
|
|
800
|
+
prev.map((widget) => {
|
|
801
|
+
if (widget.id !== widgetId) return widget;
|
|
802
|
+
return updater(widget);
|
|
803
|
+
}),
|
|
804
|
+
),
|
|
805
|
+
);
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
const removeWidget = (widgetId: string) => {
|
|
809
|
+
setWidgets((prev) => normalizeWidgetsForRows(prev.filter((widget) => widget.id !== widgetId)));
|
|
810
|
+
if (selectedWidgetId === widgetId) {
|
|
811
|
+
setSelectedWidgetId(null);
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
const widgetDataQueries = useQueries({
|
|
816
|
+
queries: widgets.map((widget) => ({
|
|
817
|
+
queryKey: [
|
|
818
|
+
"custom-report-widget-data",
|
|
819
|
+
reportSiteId,
|
|
820
|
+
widget.id,
|
|
821
|
+
JSON.stringify(widget),
|
|
822
|
+
activeFilters?.dateRange.start,
|
|
823
|
+
activeFilters?.dateRange.end,
|
|
824
|
+
activeFilters?.deviceType,
|
|
825
|
+
activeFilters?.country,
|
|
826
|
+
activeFilters?.city,
|
|
827
|
+
activeFilters?.region,
|
|
828
|
+
activeFilters?.source,
|
|
829
|
+
activeFilters?.pageUrl,
|
|
830
|
+
activeFilters?.eventName,
|
|
831
|
+
],
|
|
832
|
+
enabled: Boolean(reportSiteId) && availableColumns.length > 0,
|
|
833
|
+
queryFn: async () => {
|
|
834
|
+
const query = buildSqlForWidget(widget, availableColumns, {
|
|
835
|
+
dateRange: activeFilters?.dateRange,
|
|
836
|
+
deviceType: activeFilters?.deviceType,
|
|
837
|
+
country: activeFilters?.country,
|
|
838
|
+
city: activeFilters?.city,
|
|
839
|
+
region: activeFilters?.region,
|
|
840
|
+
source: activeFilters?.source,
|
|
841
|
+
pageUrl: activeFilters?.pageUrl,
|
|
842
|
+
eventName: activeFilters?.eventName,
|
|
843
|
+
});
|
|
844
|
+
const response = await fetch("/api/site-events/query", {
|
|
845
|
+
method: "POST",
|
|
846
|
+
headers: { "Content-Type": "application/json" },
|
|
847
|
+
body: JSON.stringify({
|
|
848
|
+
site_id: reportSiteId,
|
|
849
|
+
query,
|
|
850
|
+
}),
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
const payload = (await response.json().catch(() => null)) as
|
|
854
|
+
| { rows?: Array<Record<string, unknown>>; error?: string }
|
|
855
|
+
| null;
|
|
856
|
+
|
|
857
|
+
if (!response.ok) {
|
|
858
|
+
throw new Error(payload?.error || "Failed to load widget data");
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
return payload?.rows || [];
|
|
862
|
+
},
|
|
863
|
+
})),
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
const widgetQueryById = useMemo(() => {
|
|
867
|
+
const map = new Map<string, (typeof widgetDataQueries)[number]>();
|
|
868
|
+
widgets.forEach((widget, index) => {
|
|
869
|
+
map.set(widget.id, widgetDataQueries[index]);
|
|
870
|
+
});
|
|
871
|
+
return map;
|
|
872
|
+
}, [widgets, widgetDataQueries]);
|
|
873
|
+
|
|
874
|
+
const saveReport = async () => {
|
|
875
|
+
if (!reportSiteId) {
|
|
876
|
+
setErrorMessage("Select a site before saving a custom report.");
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
if (!reportName.trim()) {
|
|
880
|
+
setErrorMessage("Report name is required.");
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
if (widgets.length === 0) {
|
|
884
|
+
setErrorMessage("Add at least one chart widget before saving.");
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const config: CustomReportConfig = {
|
|
889
|
+
version: 1,
|
|
890
|
+
widgets,
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
setErrorMessage(null);
|
|
894
|
+
setIsSaving(true);
|
|
895
|
+
try {
|
|
896
|
+
const endpoint = isExistingReport
|
|
897
|
+
? `/api/reports/custom/${reportUuid}`
|
|
898
|
+
: "/api/reports/custom";
|
|
899
|
+
|
|
900
|
+
const response = await fetch(endpoint, {
|
|
901
|
+
method: "POST",
|
|
902
|
+
headers: { "Content-Type": "application/json" },
|
|
903
|
+
body: JSON.stringify({
|
|
904
|
+
site_id: reportSiteId,
|
|
905
|
+
name: reportName.trim(),
|
|
906
|
+
description: null,
|
|
907
|
+
config,
|
|
908
|
+
}),
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
const payload = (await response.json().catch(() => null)) as
|
|
912
|
+
| { uuid?: string; error?: string }
|
|
913
|
+
| null;
|
|
914
|
+
|
|
915
|
+
if (!response.ok) {
|
|
916
|
+
throw new Error(payload?.error || "Failed to save report");
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const nextUuid = payload?.uuid || reportUuid;
|
|
920
|
+
if (!nextUuid) {
|
|
921
|
+
throw new Error("Save succeeded but report id was missing");
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const normalizedName = reportName.trim();
|
|
925
|
+
const normalizedSiteId = reportSiteId;
|
|
926
|
+
const normalizedTeamId = session?.team?.id ?? reportQuery.data?.team_id ?? 0;
|
|
927
|
+
const nextReportRecord: CustomReportRecord = {
|
|
928
|
+
uuid: nextUuid,
|
|
929
|
+
site_id: normalizedSiteId,
|
|
930
|
+
team_id: normalizedTeamId,
|
|
931
|
+
name: normalizedName,
|
|
932
|
+
description: null,
|
|
933
|
+
config,
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
queryClient.setQueryData(
|
|
937
|
+
["dashboard-toolbar-active-custom-report", nextUuid],
|
|
938
|
+
{ report: nextReportRecord },
|
|
939
|
+
);
|
|
940
|
+
|
|
941
|
+
queryClient.setQueryData<{ reports?: CustomReportRecord[] }>(
|
|
942
|
+
["dashboard-toolbar-custom-reports", normalizedSiteId],
|
|
943
|
+
(current) => {
|
|
944
|
+
const reports = current?.reports ?? [];
|
|
945
|
+
const reportIndex = reports.findIndex((report) => report.uuid === nextUuid);
|
|
946
|
+
if (reportIndex >= 0) {
|
|
947
|
+
const nextReports = [...reports];
|
|
948
|
+
nextReports[reportIndex] = {
|
|
949
|
+
...nextReports[reportIndex],
|
|
950
|
+
...nextReportRecord,
|
|
951
|
+
};
|
|
952
|
+
return { reports: nextReports };
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
return { reports: [nextReportRecord, ...reports] };
|
|
956
|
+
},
|
|
957
|
+
);
|
|
958
|
+
|
|
959
|
+
void queryClient.invalidateQueries({
|
|
960
|
+
queryKey: ["dashboard-toolbar-custom-reports", normalizedSiteId],
|
|
961
|
+
});
|
|
962
|
+
void queryClient.invalidateQueries({
|
|
963
|
+
queryKey: ["dashboard-toolbar-active-custom-report", nextUuid],
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
setNotice("Report saved.");
|
|
967
|
+
if (!isExistingReport) {
|
|
968
|
+
window.location.assign(`/dashboard/reports/custom/${nextUuid}`);
|
|
969
|
+
} else {
|
|
970
|
+
setIsEditing(false);
|
|
971
|
+
}
|
|
972
|
+
} catch (error) {
|
|
973
|
+
setErrorMessage(error instanceof Error ? error.message : "Failed to save report");
|
|
974
|
+
} finally {
|
|
975
|
+
setIsSaving(false);
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
const deleteReport = async () => {
|
|
980
|
+
if (!isExistingReport || !reportUuid) return;
|
|
981
|
+
|
|
982
|
+
setErrorMessage(null);
|
|
983
|
+
setNotice(null);
|
|
984
|
+
setIsDeleteModalOpen(false);
|
|
985
|
+
setIsDeleting(true);
|
|
986
|
+
|
|
987
|
+
try {
|
|
988
|
+
const response = await fetch(`/api/reports/custom/${reportUuid}`, {
|
|
989
|
+
method: "DELETE",
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
const payload = (await response.json().catch(() => null)) as
|
|
993
|
+
| { error?: string }
|
|
994
|
+
| null;
|
|
995
|
+
|
|
996
|
+
if (!response.ok) {
|
|
997
|
+
throw new Error(payload?.error || "Failed to delete report");
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
window.location.assign("/dashboard/reports/create-report");
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
setErrorMessage(error instanceof Error ? error.message : "Failed to delete report");
|
|
1003
|
+
} finally {
|
|
1004
|
+
setIsDeleting(false);
|
|
1005
|
+
}
|
|
1006
|
+
};
|
|
1007
|
+
|
|
1008
|
+
if (!isExistingReport && !reportSiteId && !isSessionLoading) {
|
|
1009
|
+
return (
|
|
1010
|
+
<div className="max-w-3xl mx-auto">
|
|
1011
|
+
<div className="rounded-xl border border-(--theme-border-primary) bg-(--theme-card-bg) p-6">
|
|
1012
|
+
<h2 className="text-xl font-semibold text-(--theme-text-primary)">Select a site first</h2>
|
|
1013
|
+
<p className="mt-2 text-(--theme-text-secondary)">
|
|
1014
|
+
Pick a site from the dashboard selector, then open custom report builder.
|
|
1015
|
+
</p>
|
|
1016
|
+
</div>
|
|
1017
|
+
</div>
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
return (
|
|
1022
|
+
<div className="space-y-6">
|
|
1023
|
+
{(notice || errorMessage) && (
|
|
1024
|
+
<div className="fixed bottom-4 right-4 z-[70] w-[min(24rem,calc(100vw-2rem))]">
|
|
1025
|
+
<AlertBanner
|
|
1026
|
+
tone={errorMessage ? "error" : "success"}
|
|
1027
|
+
message={errorMessage || notice || ""}
|
|
1028
|
+
onDismiss={() => {
|
|
1029
|
+
setNotice(null);
|
|
1030
|
+
setErrorMessage(null);
|
|
1031
|
+
}}
|
|
1032
|
+
/>
|
|
1033
|
+
</div>
|
|
1034
|
+
)}
|
|
1035
|
+
|
|
1036
|
+
{isDeleteModalOpen ? (
|
|
1037
|
+
<div
|
|
1038
|
+
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 px-4"
|
|
1039
|
+
role="presentation"
|
|
1040
|
+
onMouseDown={(event) => {
|
|
1041
|
+
if (event.target === event.currentTarget) {
|
|
1042
|
+
setIsDeleteModalOpen(false);
|
|
1043
|
+
}
|
|
1044
|
+
}}
|
|
1045
|
+
>
|
|
1046
|
+
<div
|
|
1047
|
+
ref={deleteModalRef}
|
|
1048
|
+
role="dialog"
|
|
1049
|
+
aria-modal="true"
|
|
1050
|
+
aria-labelledby={deleteModalTitleId}
|
|
1051
|
+
aria-describedby={deleteModalDescriptionId}
|
|
1052
|
+
tabIndex={-1}
|
|
1053
|
+
onKeyDown={(event) => {
|
|
1054
|
+
if (event.key === "Escape") {
|
|
1055
|
+
event.preventDefault();
|
|
1056
|
+
setIsDeleteModalOpen(false);
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
if (event.key !== "Tab") return;
|
|
1061
|
+
|
|
1062
|
+
const focusable = getFocusableElements(deleteModalRef.current);
|
|
1063
|
+
if (focusable.length === 0) return;
|
|
1064
|
+
|
|
1065
|
+
const first = focusable[0];
|
|
1066
|
+
const last = focusable[focusable.length - 1];
|
|
1067
|
+
const active = document.activeElement as HTMLElement | null;
|
|
1068
|
+
|
|
1069
|
+
if (event.shiftKey) {
|
|
1070
|
+
if (!active || active === first) {
|
|
1071
|
+
event.preventDefault();
|
|
1072
|
+
last.focus();
|
|
1073
|
+
}
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
if (!active || active === last) {
|
|
1078
|
+
event.preventDefault();
|
|
1079
|
+
first.focus();
|
|
1080
|
+
}
|
|
1081
|
+
}}
|
|
1082
|
+
className="w-[min(34rem,calc(100vw-2rem))] rounded-xl border border-(--theme-border-primary) bg-(--theme-card-bg) p-6 shadow-2xl"
|
|
1083
|
+
>
|
|
1084
|
+
<h3 id={deleteModalTitleId} className="text-lg font-semibold text-(--theme-text-primary)">
|
|
1085
|
+
Delete report?
|
|
1086
|
+
</h3>
|
|
1087
|
+
<p id={deleteModalDescriptionId} className="mt-2 text-sm text-(--theme-text-secondary)">
|
|
1088
|
+
Delete "{reportName}" permanently. This action cannot be undone.
|
|
1089
|
+
</p>
|
|
1090
|
+
<div className="mt-6 flex items-center justify-end gap-2">
|
|
1091
|
+
<button
|
|
1092
|
+
ref={deleteCancelButtonRef}
|
|
1093
|
+
type="button"
|
|
1094
|
+
onClick={() => setIsDeleteModalOpen(false)}
|
|
1095
|
+
className="rounded-md border border-(--theme-border-primary) px-3 py-1.5 text-sm font-medium text-(--theme-text-primary) hover:bg-(--theme-bg-secondary)"
|
|
1096
|
+
>
|
|
1097
|
+
Cancel
|
|
1098
|
+
</button>
|
|
1099
|
+
<button
|
|
1100
|
+
type="button"
|
|
1101
|
+
onClick={() => {
|
|
1102
|
+
void deleteReport();
|
|
1103
|
+
}}
|
|
1104
|
+
disabled={isDeleting}
|
|
1105
|
+
className="rounded-md border border-red-500/60 bg-red-500/10 px-3 py-1.5 text-sm font-semibold text-red-300 hover:bg-red-500/20 disabled:opacity-60"
|
|
1106
|
+
>
|
|
1107
|
+
{isDeleting ? "Deleting..." : "Delete report"}
|
|
1108
|
+
</button>
|
|
1109
|
+
</div>
|
|
1110
|
+
</div>
|
|
1111
|
+
</div>
|
|
1112
|
+
) : null}
|
|
1113
|
+
|
|
1114
|
+
<header className="grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
|
|
1115
|
+
<div className="min-w-0 w-full">
|
|
1116
|
+
{isEditing ? (
|
|
1117
|
+
<input
|
|
1118
|
+
value={reportName}
|
|
1119
|
+
onChange={(event) => setReportName(event.target.value)}
|
|
1120
|
+
className="w-full min-w-[220px] sm:min-w-[360px] max-w-2xl rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-3 py-1.5 text-sm text-(--theme-text-primary)"
|
|
1121
|
+
placeholder="Untitled custom report"
|
|
1122
|
+
aria-label="Report name"
|
|
1123
|
+
/>
|
|
1124
|
+
) : (
|
|
1125
|
+
<h2 className="text-2xl font-bold text-(--theme-text-primary) truncate">{reportName}</h2>
|
|
1126
|
+
)}
|
|
1127
|
+
</div>
|
|
1128
|
+
|
|
1129
|
+
<div className="flex items-center gap-2">
|
|
1130
|
+
{isExistingReport && !isEditing ? (
|
|
1131
|
+
<button
|
|
1132
|
+
type="button"
|
|
1133
|
+
onClick={() => setIsEditing(true)}
|
|
1134
|
+
className="rounded-md border border-(--theme-border-primary) px-2.5 py-1 text-xs font-semibold text-(--theme-text-secondary) hover:bg-(--theme-bg-secondary)"
|
|
1135
|
+
>
|
|
1136
|
+
Edit
|
|
1137
|
+
</button>
|
|
1138
|
+
) : null}
|
|
1139
|
+
|
|
1140
|
+
{isEditing ? (
|
|
1141
|
+
<>
|
|
1142
|
+
{isExistingReport ? (
|
|
1143
|
+
<button
|
|
1144
|
+
type="button"
|
|
1145
|
+
onClick={() => setIsEditing(false)}
|
|
1146
|
+
disabled={isSaving || isDeleting}
|
|
1147
|
+
className="rounded-md border border-(--theme-border-primary) px-2.5 py-1 text-xs font-semibold text-(--theme-text-secondary) hover:bg-(--theme-bg-secondary)"
|
|
1148
|
+
>
|
|
1149
|
+
Cancel
|
|
1150
|
+
</button>
|
|
1151
|
+
) : null}
|
|
1152
|
+
{isExistingReport ? (
|
|
1153
|
+
<button
|
|
1154
|
+
type="button"
|
|
1155
|
+
onClick={() => {
|
|
1156
|
+
setIsDeleteModalOpen(true);
|
|
1157
|
+
}}
|
|
1158
|
+
disabled={isSaving || isDeleting}
|
|
1159
|
+
className="rounded-md border border-red-500/60 px-2.5 py-1 text-xs font-semibold text-red-300 hover:bg-red-500/10 disabled:opacity-60"
|
|
1160
|
+
>
|
|
1161
|
+
Delete
|
|
1162
|
+
</button>
|
|
1163
|
+
) : null}
|
|
1164
|
+
<button
|
|
1165
|
+
type="button"
|
|
1166
|
+
onClick={() => {
|
|
1167
|
+
void saveReport();
|
|
1168
|
+
}}
|
|
1169
|
+
disabled={isSaving || isDeleting}
|
|
1170
|
+
className="rounded-md border border-(--theme-button-bg) px-2.5 py-1 text-xs font-semibold text-(--theme-text-primary) hover:bg-(--theme-bg-secondary) disabled:opacity-60"
|
|
1171
|
+
>
|
|
1172
|
+
{isSaving ? "Saving..." : "Save"}
|
|
1173
|
+
</button>
|
|
1174
|
+
</>
|
|
1175
|
+
) : null}
|
|
1176
|
+
</div>
|
|
1177
|
+
</header>
|
|
1178
|
+
|
|
1179
|
+
<section className={isEditing ? "grid grid-cols-1 items-start gap-4 xl:grid-cols-[minmax(0,4fr)_minmax(300px,1fr)]" : "space-y-4"}>
|
|
1180
|
+
<DashboardCard
|
|
1181
|
+
title={isEditing ? "Layout editor" : undefined}
|
|
1182
|
+
titleAs="h3"
|
|
1183
|
+
subtitle={isEditing ? "Build as many rows as you need. Each row can be split (two charts) or full-width (one chart)." : undefined}
|
|
1184
|
+
actions={isEditing ? (
|
|
1185
|
+
<div className="flex items-center gap-2">
|
|
1186
|
+
<label className="text-xs text-(--theme-text-secondary)">Chart type</label>
|
|
1187
|
+
<select
|
|
1188
|
+
value={selectedChartType}
|
|
1189
|
+
onChange={(event) => setSelectedChartType(event.target.value as ReportChartType)}
|
|
1190
|
+
className="rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1 text-sm text-(--theme-text-primary)"
|
|
1191
|
+
>
|
|
1192
|
+
{chartTypeOptions.map((option) => (
|
|
1193
|
+
<option key={option.value} value={option.value}>{option.label}</option>
|
|
1194
|
+
))}
|
|
1195
|
+
</select>
|
|
1196
|
+
</div>
|
|
1197
|
+
) : undefined}
|
|
1198
|
+
className={isEditing ? "space-y-4" : "!border-0 !bg-transparent !p-0 space-y-4"}
|
|
1199
|
+
>
|
|
1200
|
+
<div className="space-y-4">
|
|
1201
|
+
{rowsForRender.map((row) => {
|
|
1202
|
+
const hasWidgets = Boolean(row.full || row.left || row.right);
|
|
1203
|
+
if (!isEditing && !hasWidgets) {
|
|
1204
|
+
return null;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const rowHeightClass = row.mode === "full" ? "h-[420px]" : "h-[340px]";
|
|
1208
|
+
|
|
1209
|
+
return (
|
|
1210
|
+
<div key={`row-${row.rowIndex}`} className="space-y-2">
|
|
1211
|
+
{isEditing ? (
|
|
1212
|
+
<div className="flex items-center justify-between">
|
|
1213
|
+
<p className="text-xs font-semibold uppercase tracking-wide text-(--theme-text-secondary)">
|
|
1214
|
+
Row {row.rowIndex + 1}
|
|
1215
|
+
</p>
|
|
1216
|
+
<select
|
|
1217
|
+
value={row.mode}
|
|
1218
|
+
onChange={(event) => updateRowMode(row.rowIndex, event.target.value as RowMode)}
|
|
1219
|
+
className="rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1 text-xs text-(--theme-text-primary)"
|
|
1220
|
+
>
|
|
1221
|
+
<option value="split">Two charts</option>
|
|
1222
|
+
<option value="full">Full-width chart</option>
|
|
1223
|
+
</select>
|
|
1224
|
+
</div>
|
|
1225
|
+
) : null}
|
|
1226
|
+
|
|
1227
|
+
{row.mode === "full" ? (
|
|
1228
|
+
row.full ? (
|
|
1229
|
+
(() => {
|
|
1230
|
+
const fullWidget = row.full;
|
|
1231
|
+
const query = widgetQueryById.get(fullWidget.id);
|
|
1232
|
+
const rowsData =
|
|
1233
|
+
(query?.data as Array<Record<string, unknown>> | undefined) || [];
|
|
1234
|
+
const isSelected = isEditing && fullWidget.id === selectedWidgetId;
|
|
1235
|
+
const selectionClass = isEditing
|
|
1236
|
+
? (isSelected
|
|
1237
|
+
? "border-amber-500 ring-2 ring-amber-500 shadow-[0_0_0_1px_rgba(245,158,11,0.45)] dark:border-[var(--theme-border-secondary)] dark:ring-[var(--theme-border-secondary)] dark:shadow-none"
|
|
1238
|
+
: "hover:border-amber-400 dark:hover:border-[var(--theme-border-secondary)]")
|
|
1239
|
+
: "";
|
|
1240
|
+
|
|
1241
|
+
return (
|
|
1242
|
+
<div
|
|
1243
|
+
role={isEditing ? "button" : undefined}
|
|
1244
|
+
tabIndex={isEditing ? 0 : undefined}
|
|
1245
|
+
aria-pressed={isEditing ? isSelected : undefined}
|
|
1246
|
+
onClick={() => {
|
|
1247
|
+
if (!isEditing) return;
|
|
1248
|
+
setSelectedWidgetId(fullWidget.id);
|
|
1249
|
+
}}
|
|
1250
|
+
onKeyDown={(event) => {
|
|
1251
|
+
if (!isEditing) return;
|
|
1252
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
1253
|
+
event.preventDefault();
|
|
1254
|
+
setSelectedWidgetId(fullWidget.id);
|
|
1255
|
+
}}
|
|
1256
|
+
className={isEditing ? "rounded-lg focus:outline-none" : undefined}
|
|
1257
|
+
>
|
|
1258
|
+
<DashboardCard
|
|
1259
|
+
title={renderWidgetTitle(fullWidget)}
|
|
1260
|
+
titleAs="h3"
|
|
1261
|
+
subtitle={isEditing ? `${fullWidget.chartType} - Full row` : undefined}
|
|
1262
|
+
actions={isEditing ? (
|
|
1263
|
+
<button
|
|
1264
|
+
type="button"
|
|
1265
|
+
onClick={(event) => {
|
|
1266
|
+
event.stopPropagation();
|
|
1267
|
+
removeWidget(fullWidget.id);
|
|
1268
|
+
}}
|
|
1269
|
+
className="text-[10px] text-red-400 hover:text-red-300"
|
|
1270
|
+
>
|
|
1271
|
+
Remove
|
|
1272
|
+
</button>
|
|
1273
|
+
) : undefined}
|
|
1274
|
+
className={`${rowHeightClass} flex flex-col transition-colors ${isSelected ? "bg-amber-500/15 dark:bg-(--theme-bg-tertiary)" : ""} ${selectionClass}`}
|
|
1275
|
+
>
|
|
1276
|
+
<div className="mt-1 flex-1 min-h-0">
|
|
1277
|
+
{schemaQuery.isLoading || query?.isLoading || !query ? (
|
|
1278
|
+
<div className="h-full flex items-center justify-center text-sm text-(--theme-text-secondary)">
|
|
1279
|
+
Loading chart data...
|
|
1280
|
+
</div>
|
|
1281
|
+
) : query.error ? (
|
|
1282
|
+
<div className="h-full flex items-center justify-center text-sm text-red-400">
|
|
1283
|
+
{(query.error as Error).message}
|
|
1284
|
+
</div>
|
|
1285
|
+
) : (
|
|
1286
|
+
<div className="h-full flex flex-col">
|
|
1287
|
+
<ReportWidgetChart widget={fullWidget} rows={rowsData} labelsMap={eventLabelsMap} />
|
|
1288
|
+
{rowsData.length === 0 ? (
|
|
1289
|
+
<p className="mt-2 text-xs text-(--theme-text-secondary)">
|
|
1290
|
+
No data for this date range.
|
|
1291
|
+
</p>
|
|
1292
|
+
) : null}
|
|
1293
|
+
</div>
|
|
1294
|
+
)}
|
|
1295
|
+
</div>
|
|
1296
|
+
</DashboardCard>
|
|
1297
|
+
</div>
|
|
1298
|
+
);
|
|
1299
|
+
})()
|
|
1300
|
+
) : (
|
|
1301
|
+
isEditing ? (
|
|
1302
|
+
<button
|
|
1303
|
+
type="button"
|
|
1304
|
+
onClick={() => addWidgetAtSlot(row.rowIndex, 0)}
|
|
1305
|
+
className={`${rowHeightClass} w-full rounded-lg border border-dashed border-(--theme-border-primary) bg-(--theme-bg-secondary) text-(--theme-text-secondary) hover:bg-(--theme-bg-tertiary) transition-colors`}
|
|
1306
|
+
>
|
|
1307
|
+
<span className="text-sm font-medium">+ Add full-width chart</span>
|
|
1308
|
+
</button>
|
|
1309
|
+
) : null
|
|
1310
|
+
)
|
|
1311
|
+
) : (
|
|
1312
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
|
1313
|
+
{([0, 1] as const).map((slotIndex) => {
|
|
1314
|
+
const slotWidget = slotIndex === 0 ? row.left : row.right;
|
|
1315
|
+
if (!slotWidget) {
|
|
1316
|
+
if (!isEditing) {
|
|
1317
|
+
return null;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
return (
|
|
1321
|
+
<button
|
|
1322
|
+
key={`row-${row.rowIndex}-slot-${slotIndex}`}
|
|
1323
|
+
type="button"
|
|
1324
|
+
onClick={() => addWidgetAtSlot(row.rowIndex, slotIndex)}
|
|
1325
|
+
className={`${rowHeightClass} rounded-lg border border-dashed border-(--theme-border-primary) bg-(--theme-bg-secondary) text-(--theme-text-secondary) hover:bg-(--theme-bg-tertiary) transition-colors`}
|
|
1326
|
+
>
|
|
1327
|
+
<span className="text-sm font-medium">+ Add chart to slot {slotIndex + 1}</span>
|
|
1328
|
+
</button>
|
|
1329
|
+
);
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
const query = widgetQueryById.get(slotWidget.id);
|
|
1333
|
+
const rowsData =
|
|
1334
|
+
(query?.data as Array<Record<string, unknown>> | undefined) || [];
|
|
1335
|
+
const isSelected = isEditing && slotWidget.id === selectedWidgetId;
|
|
1336
|
+
const selectionClass = isEditing
|
|
1337
|
+
? (isSelected
|
|
1338
|
+
? "border-amber-500 ring-2 ring-amber-500 shadow-[0_0_0_1px_rgba(245,158,11,0.45)] dark:border-[var(--theme-border-secondary)] dark:ring-[var(--theme-border-secondary)] dark:shadow-none"
|
|
1339
|
+
: "hover:border-amber-400 dark:hover:border-[var(--theme-border-secondary)]")
|
|
1340
|
+
: "";
|
|
1341
|
+
|
|
1342
|
+
return (
|
|
1343
|
+
<div
|
|
1344
|
+
key={slotWidget.id}
|
|
1345
|
+
role={isEditing ? "button" : undefined}
|
|
1346
|
+
tabIndex={isEditing ? 0 : undefined}
|
|
1347
|
+
aria-pressed={isEditing ? isSelected : undefined}
|
|
1348
|
+
onClick={() => {
|
|
1349
|
+
if (!isEditing) return;
|
|
1350
|
+
setSelectedWidgetId(slotWidget.id);
|
|
1351
|
+
}}
|
|
1352
|
+
onKeyDown={(event) => {
|
|
1353
|
+
if (!isEditing) return;
|
|
1354
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
1355
|
+
event.preventDefault();
|
|
1356
|
+
setSelectedWidgetId(slotWidget.id);
|
|
1357
|
+
}}
|
|
1358
|
+
className={isEditing ? "rounded-lg focus:outline-none" : undefined}
|
|
1359
|
+
>
|
|
1360
|
+
<DashboardCard
|
|
1361
|
+
title={renderWidgetTitle(slotWidget)}
|
|
1362
|
+
titleAs="h3"
|
|
1363
|
+
subtitle={isEditing ? `${slotWidget.chartType} - Slot ${slotIndex + 1}` : undefined}
|
|
1364
|
+
actions={isEditing ? (
|
|
1365
|
+
<button
|
|
1366
|
+
type="button"
|
|
1367
|
+
onClick={(event) => {
|
|
1368
|
+
event.stopPropagation();
|
|
1369
|
+
removeWidget(slotWidget.id);
|
|
1370
|
+
}}
|
|
1371
|
+
className="text-[10px] text-red-400 hover:text-red-300"
|
|
1372
|
+
>
|
|
1373
|
+
Remove
|
|
1374
|
+
</button>
|
|
1375
|
+
) : undefined}
|
|
1376
|
+
className={`${rowHeightClass} flex flex-col transition-colors ${isSelected ? "bg-amber-500/15 dark:bg-(--theme-bg-tertiary)" : ""} ${selectionClass}`}
|
|
1377
|
+
>
|
|
1378
|
+
<div className="mt-1 flex-1 min-h-0">
|
|
1379
|
+
{schemaQuery.isLoading || query?.isLoading || !query ? (
|
|
1380
|
+
<div className="h-full flex items-center justify-center text-sm text-(--theme-text-secondary)">
|
|
1381
|
+
Loading chart data...
|
|
1382
|
+
</div>
|
|
1383
|
+
) : query.error ? (
|
|
1384
|
+
<div className="h-full flex items-center justify-center text-sm text-red-400">
|
|
1385
|
+
{(query.error as Error).message}
|
|
1386
|
+
</div>
|
|
1387
|
+
) : (
|
|
1388
|
+
<div className="h-full flex flex-col">
|
|
1389
|
+
<ReportWidgetChart widget={slotWidget} rows={rowsData} labelsMap={eventLabelsMap} />
|
|
1390
|
+
{rowsData.length === 0 ? (
|
|
1391
|
+
<p className="mt-2 text-xs text-(--theme-text-secondary)">
|
|
1392
|
+
No data for this date range.
|
|
1393
|
+
</p>
|
|
1394
|
+
) : null}
|
|
1395
|
+
</div>
|
|
1396
|
+
)}
|
|
1397
|
+
</div>
|
|
1398
|
+
</DashboardCard>
|
|
1399
|
+
</div>
|
|
1400
|
+
);
|
|
1401
|
+
})}
|
|
1402
|
+
</div>
|
|
1403
|
+
)}
|
|
1404
|
+
</div>
|
|
1405
|
+
);
|
|
1406
|
+
})}
|
|
1407
|
+
|
|
1408
|
+
{isEditing ? (
|
|
1409
|
+
<button
|
|
1410
|
+
type="button"
|
|
1411
|
+
onClick={() => {
|
|
1412
|
+
const nextRowIndex = canvasRows.length + extraRows;
|
|
1413
|
+
setRowModeOverrides((prev) => ({
|
|
1414
|
+
...prev,
|
|
1415
|
+
[nextRowIndex]: "full",
|
|
1416
|
+
}));
|
|
1417
|
+
setExtraRows((prev) => prev + 1);
|
|
1418
|
+
}}
|
|
1419
|
+
className="rounded-md border border-dashed border-(--theme-border-primary) px-3 py-2 text-xs text-(--theme-text-secondary) hover:bg-(--theme-bg-secondary)"
|
|
1420
|
+
>
|
|
1421
|
+
+ Add row
|
|
1422
|
+
</button>
|
|
1423
|
+
) : null}
|
|
1424
|
+
</div>
|
|
1425
|
+
</DashboardCard>
|
|
1426
|
+
|
|
1427
|
+
{isEditing ? (
|
|
1428
|
+
<DashboardCard
|
|
1429
|
+
title="Widget settings"
|
|
1430
|
+
titleAs="h3"
|
|
1431
|
+
subtitle={selectedWidget
|
|
1432
|
+
? `${selectedWidget.title} ${selectedWidgetPlacement ? `(${selectedWidgetPlacement})` : ""}`
|
|
1433
|
+
: undefined}
|
|
1434
|
+
className="space-y-3 xl:sticky xl:top-4"
|
|
1435
|
+
>
|
|
1436
|
+
{!selectedWidget ? (
|
|
1437
|
+
<p className="text-sm text-(--theme-text-secondary)">Select a widget from the grid to edit it.</p>
|
|
1438
|
+
) : (
|
|
1439
|
+
<>
|
|
1440
|
+
<label className="block text-xs text-(--theme-text-secondary)">Title</label>
|
|
1441
|
+
<input
|
|
1442
|
+
value={selectedWidget.title}
|
|
1443
|
+
onChange={(event) => updateWidget(selectedWidget.id, (widget) => ({ ...widget, title: event.target.value }))}
|
|
1444
|
+
className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
|
|
1445
|
+
/>
|
|
1446
|
+
|
|
1447
|
+
<label className="block text-xs text-(--theme-text-secondary)">Chart type</label>
|
|
1448
|
+
<select
|
|
1449
|
+
value={selectedWidget.chartType}
|
|
1450
|
+
onChange={(event) => updateWidget(selectedWidget.id, (widget) => ({
|
|
1451
|
+
...widget,
|
|
1452
|
+
chartType: event.target.value as ReportChartType,
|
|
1453
|
+
}))}
|
|
1454
|
+
className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
|
|
1455
|
+
>
|
|
1456
|
+
{chartTypeOptions.map((option) => (
|
|
1457
|
+
<option key={option.value} value={option.value}>{option.label}</option>
|
|
1458
|
+
))}
|
|
1459
|
+
</select>
|
|
1460
|
+
|
|
1461
|
+
{selectedWidget.chartType === "sankey" ? (
|
|
1462
|
+
<>
|
|
1463
|
+
<label className="block text-xs text-(--theme-text-secondary)">Source field</label>
|
|
1464
|
+
<select
|
|
1465
|
+
value={selectedWidget.sourceField}
|
|
1466
|
+
onChange={(event) => updateWidget(selectedWidget.id, (widget) => ({ ...widget, sourceField: event.target.value }))}
|
|
1467
|
+
className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
|
|
1468
|
+
>
|
|
1469
|
+
{availableColumns.map((column) => (
|
|
1470
|
+
<option key={`source-${column}`} value={column}>{column}</option>
|
|
1471
|
+
))}
|
|
1472
|
+
</select>
|
|
1473
|
+
|
|
1474
|
+
<label className="block text-xs text-(--theme-text-secondary)">Target field</label>
|
|
1475
|
+
<select
|
|
1476
|
+
value={selectedWidget.targetField}
|
|
1477
|
+
onChange={(event) => updateWidget(selectedWidget.id, (widget) => ({ ...widget, targetField: event.target.value }))}
|
|
1478
|
+
className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
|
|
1479
|
+
>
|
|
1480
|
+
{availableColumns.map((column) => (
|
|
1481
|
+
<option key={`target-${column}`} value={column}>{column}</option>
|
|
1482
|
+
))}
|
|
1483
|
+
</select>
|
|
1484
|
+
</>
|
|
1485
|
+
) : (
|
|
1486
|
+
<>
|
|
1487
|
+
<label className="block text-xs text-(--theme-text-secondary)">X field</label>
|
|
1488
|
+
<select
|
|
1489
|
+
value={selectedWidget.xField}
|
|
1490
|
+
onChange={(event) => updateWidget(selectedWidget.id, (widget) => ({ ...widget, xField: event.target.value }))}
|
|
1491
|
+
className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
|
|
1492
|
+
>
|
|
1493
|
+
{availableColumns.map((column) => (
|
|
1494
|
+
<option key={`x-${column}`} value={column}>{column}</option>
|
|
1495
|
+
))}
|
|
1496
|
+
</select>
|
|
1497
|
+
</>
|
|
1498
|
+
)}
|
|
1499
|
+
|
|
1500
|
+
<label className="block text-xs text-(--theme-text-secondary)">Y aggregation</label>
|
|
1501
|
+
<select
|
|
1502
|
+
value={selectedWidget.aggregation}
|
|
1503
|
+
onChange={(event) => updateWidget(selectedWidget.id, (widget) => ({ ...widget, aggregation: event.target.value as ReportAggregation }))}
|
|
1504
|
+
className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
|
|
1505
|
+
>
|
|
1506
|
+
{aggregationOptions.map((option) => (
|
|
1507
|
+
<option key={option.value} value={option.value}>{option.label}</option>
|
|
1508
|
+
))}
|
|
1509
|
+
</select>
|
|
1510
|
+
|
|
1511
|
+
<label className="block text-xs text-(--theme-text-secondary)">Y field</label>
|
|
1512
|
+
<select
|
|
1513
|
+
value={selectedWidget.yField}
|
|
1514
|
+
onChange={(event) => updateWidget(selectedWidget.id, (widget) => ({ ...widget, yField: event.target.value }))}
|
|
1515
|
+
className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
|
|
1516
|
+
>
|
|
1517
|
+
{availableColumns.map((column) => (
|
|
1518
|
+
<option key={`y-${column}`} value={column}>{column}</option>
|
|
1519
|
+
))}
|
|
1520
|
+
</select>
|
|
1521
|
+
{!yFieldUsedByAggregation ? (
|
|
1522
|
+
<p className="text-[11px] text-(--theme-text-secondary)">
|
|
1523
|
+
Y field is used when Y aggregation is Sum of y or Average of y.
|
|
1524
|
+
</p>
|
|
1525
|
+
) : null}
|
|
1526
|
+
|
|
1527
|
+
<label className="block text-xs text-(--theme-text-secondary)">Color theme</label>
|
|
1528
|
+
<select
|
|
1529
|
+
value={selectedWidget.colorPalette}
|
|
1530
|
+
onChange={(event) => updateWidget(selectedWidget.id, (widget) => ({ ...widget, colorPalette: event.target.value as ReportColorPalette }))}
|
|
1531
|
+
className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
|
|
1532
|
+
>
|
|
1533
|
+
{reportPaletteOptions.map((palette) => (
|
|
1534
|
+
<option key={palette.value} value={palette.value}>{palette.label}</option>
|
|
1535
|
+
))}
|
|
1536
|
+
</select>
|
|
1537
|
+
|
|
1538
|
+
<label className="block text-xs text-(--theme-text-secondary)">Custom colors (optional)</label>
|
|
1539
|
+
<div className="space-y-2">
|
|
1540
|
+
<div className="grid grid-cols-[40px_minmax(0,1fr)_auto] items-center gap-2">
|
|
1541
|
+
<input
|
|
1542
|
+
type="color"
|
|
1543
|
+
value={normalizeHexColor(selectedWidget.customPrimaryColor ?? "") ?? primaryFallbackColor}
|
|
1544
|
+
onChange={(event) => {
|
|
1545
|
+
const next = event.target.value.toUpperCase();
|
|
1546
|
+
setPrimaryColorDraft(next);
|
|
1547
|
+
applyCustomColor(selectedWidget.id, "customPrimaryColor", next);
|
|
1548
|
+
}}
|
|
1549
|
+
className="h-9 w-10 rounded border border-(--theme-border-primary) bg-(--theme-bg-secondary) p-1"
|
|
1550
|
+
aria-label="Primary chart color picker"
|
|
1551
|
+
/>
|
|
1552
|
+
<input
|
|
1553
|
+
type="text"
|
|
1554
|
+
value={primaryColorDraft}
|
|
1555
|
+
onChange={(event) => setPrimaryColorDraft(event.target.value)}
|
|
1556
|
+
onBlur={() => {
|
|
1557
|
+
const normalized = normalizeHexColor(primaryColorDraft);
|
|
1558
|
+
if (primaryColorDraft.trim().length > 0 && !normalized) {
|
|
1559
|
+
setPrimaryColorDraft(selectedWidget.customPrimaryColor ?? "");
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
applyCustomColor(selectedWidget.id, "customPrimaryColor", primaryColorDraft);
|
|
1564
|
+
}}
|
|
1565
|
+
placeholder="#FF6B35"
|
|
1566
|
+
className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
|
|
1567
|
+
aria-label="Primary chart color hex value"
|
|
1568
|
+
/>
|
|
1569
|
+
<button
|
|
1570
|
+
type="button"
|
|
1571
|
+
onClick={() => {
|
|
1572
|
+
setPrimaryColorDraft("");
|
|
1573
|
+
applyCustomColor(selectedWidget.id, "customPrimaryColor", "");
|
|
1574
|
+
}}
|
|
1575
|
+
className="rounded-md border border-(--theme-border-primary) px-2 py-1 text-xs text-(--theme-text-secondary) hover:bg-(--theme-bg-secondary)"
|
|
1576
|
+
>
|
|
1577
|
+
Reset
|
|
1578
|
+
</button>
|
|
1579
|
+
</div>
|
|
1580
|
+
|
|
1581
|
+
<div className="grid grid-cols-[40px_minmax(0,1fr)_auto] items-center gap-2">
|
|
1582
|
+
<input
|
|
1583
|
+
type="color"
|
|
1584
|
+
value={normalizeHexColor(selectedWidget.customSecondaryColor ?? "") ?? secondaryFallbackColor}
|
|
1585
|
+
onChange={(event) => {
|
|
1586
|
+
const next = event.target.value.toUpperCase();
|
|
1587
|
+
setSecondaryColorDraft(next);
|
|
1588
|
+
applyCustomColor(selectedWidget.id, "customSecondaryColor", next);
|
|
1589
|
+
}}
|
|
1590
|
+
className="h-9 w-10 rounded border border-(--theme-border-primary) bg-(--theme-bg-secondary) p-1"
|
|
1591
|
+
aria-label="Secondary chart color picker"
|
|
1592
|
+
/>
|
|
1593
|
+
<input
|
|
1594
|
+
type="text"
|
|
1595
|
+
value={secondaryColorDraft}
|
|
1596
|
+
onChange={(event) => setSecondaryColorDraft(event.target.value)}
|
|
1597
|
+
onBlur={() => {
|
|
1598
|
+
const normalized = normalizeHexColor(secondaryColorDraft);
|
|
1599
|
+
if (secondaryColorDraft.trim().length > 0 && !normalized) {
|
|
1600
|
+
setSecondaryColorDraft(selectedWidget.customSecondaryColor ?? "");
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
applyCustomColor(selectedWidget.id, "customSecondaryColor", secondaryColorDraft);
|
|
1605
|
+
}}
|
|
1606
|
+
placeholder="#4ECDC4"
|
|
1607
|
+
className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
|
|
1608
|
+
aria-label="Secondary chart color hex value"
|
|
1609
|
+
/>
|
|
1610
|
+
<button
|
|
1611
|
+
type="button"
|
|
1612
|
+
onClick={() => {
|
|
1613
|
+
setSecondaryColorDraft("");
|
|
1614
|
+
applyCustomColor(selectedWidget.id, "customSecondaryColor", "");
|
|
1615
|
+
}}
|
|
1616
|
+
className="rounded-md border border-(--theme-border-primary) px-2 py-1 text-xs text-(--theme-text-secondary) hover:bg-(--theme-bg-secondary)"
|
|
1617
|
+
>
|
|
1618
|
+
Reset
|
|
1619
|
+
</button>
|
|
1620
|
+
</div>
|
|
1621
|
+
</div>
|
|
1622
|
+
|
|
1623
|
+
<div className="flex items-center gap-2">
|
|
1624
|
+
<label className="block text-xs text-(--theme-text-secondary)">Rows limit</label>
|
|
1625
|
+
<HelpTooltip text={`Chart row limit ${selectedWidget.limit}`} />
|
|
1626
|
+
</div>
|
|
1627
|
+
<input
|
|
1628
|
+
type="number"
|
|
1629
|
+
min={1}
|
|
1630
|
+
max={500}
|
|
1631
|
+
value={selectedWidget.limit}
|
|
1632
|
+
onChange={(event) => {
|
|
1633
|
+
const nextValue = Number(event.target.value);
|
|
1634
|
+
updateWidget(selectedWidget.id, (widget) => ({
|
|
1635
|
+
...widget,
|
|
1636
|
+
limit: Number.isFinite(nextValue) ? nextValue : widget.limit,
|
|
1637
|
+
}));
|
|
1638
|
+
}}
|
|
1639
|
+
className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
|
|
1640
|
+
/>
|
|
1641
|
+
|
|
1642
|
+
{selectedWidgetRowIndex !== null && selectedRow ? (
|
|
1643
|
+
<>
|
|
1644
|
+
<label className="block text-xs text-(--theme-text-secondary)">Row layout</label>
|
|
1645
|
+
<select
|
|
1646
|
+
value={selectedRow.mode}
|
|
1647
|
+
onChange={(event) => updateRowMode(selectedWidgetRowIndex, event.target.value as RowMode)}
|
|
1648
|
+
className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
|
|
1649
|
+
>
|
|
1650
|
+
<option value="split">Two charts</option>
|
|
1651
|
+
<option value="full">Full-width chart</option>
|
|
1652
|
+
</select>
|
|
1653
|
+
</>
|
|
1654
|
+
) : null}
|
|
1655
|
+
|
|
1656
|
+
<p className="text-xs text-(--theme-text-secondary)">
|
|
1657
|
+
Layout stays on a row grid: each row can be split or full-width, and you can add more rows as needed.
|
|
1658
|
+
</p>
|
|
1659
|
+
</>
|
|
1660
|
+
)}
|
|
1661
|
+
</DashboardCard>
|
|
1662
|
+
) : null}
|
|
1663
|
+
</section>
|
|
1664
|
+
|
|
1665
|
+
</div>
|
|
1666
|
+
);
|
|
1667
|
+
}
|