diff --git a/apps/web/playwright/insights-charts.e2e.ts b/apps/web/playwright/insights-charts.e2e.ts new file mode 100644 index 0000000000..8860355d34 --- /dev/null +++ b/apps/web/playwright/insights-charts.e2e.ts @@ -0,0 +1,288 @@ +import { expect } from "@playwright/test"; +import { v4 as uuidv4 } from "uuid"; + +import { BookingStatus } from "@calcom/prisma/enums"; + +import { expectAllChartsToLoad, getAllChartIds } from "./lib/chart-helpers"; +import { test } from "./lib/fixtures"; + +test.afterEach(({ users }) => users.deleteAll()); + +test.describe("Insights > Charts Loading", () => { + test.describe("Routing Insights", () => { + test("all charts should load successfully with data", async ({ page, users, routingForms, prisma }) => { + // Setup: Create routing form with complete data + const owner = await users.create( + { name: "Chart Test Owner" }, + { + hasTeam: true, + isUnpublished: true, + isOrg: true, + hasSubteam: true, + } + ); + await owner.apiLogin(); + + const membership = await owner.getFirstTeamMembership(); + + // Create a routing form with fields + const formName = "Chart Loading Test Form"; + const form = await routingForms.create({ + name: formName, + userId: owner.id, + teamId: membership.teamId, + fields: [ + { + type: "text", + label: "Name", + identifier: "name", + required: true, + }, + { + type: "email", + label: "Email", + identifier: "email", + required: true, + }, + { + type: "select", + label: "Department", + identifier: "department", + required: true, + options: [ + { + id: uuidv4(), + label: "Sales", + }, + { + id: uuidv4(), + label: "Support", + }, + ], + }, + ], + }); + + const fieldIds = (form.fields as Array<{ id: string }>).map((f) => f.id); + + // Create users for routing + const user1 = await users.create({ name: "Sales User" }); + const user2 = await users.create({ name: "Support User" }); + + // Add users to the team + await prisma.membership.createMany({ + data: [ + { + teamId: membership.teamId, + userId: user1.id, + role: "MEMBER", + accepted: true, + }, + { + teamId: membership.teamId, + userId: user2.id, + role: "MEMBER", + accepted: true, + }, + ], + }); + + // Create bookings for both users + const booking1 = await prisma.booking.create({ + data: { + uid: `booking-${uuidv4()}`, + title: "Sales Booking", + startTime: new Date(), + endTime: new Date(Date.now() + 60 * 60 * 1000), + userId: user1.id, + status: BookingStatus.ACCEPTED, + }, + }); + + const booking2 = await prisma.booking.create({ + data: { + uid: `booking-${uuidv4()}`, + title: "Support Booking", + startTime: new Date(), + endTime: new Date(Date.now() + 60 * 60 * 1000), + userId: user2.id, + status: BookingStatus.ACCEPTED, + }, + }); + + // Create form responses with bookings + await prisma.app_RoutingForms_FormResponse.create({ + data: { + formFillerId: "filler-1", + formId: form.id, + response: { + [fieldIds[0]]: { label: "Name", value: "John Doe" }, + [fieldIds[1]]: { label: "Email", value: "john@example.com" }, + [fieldIds[2]]: { label: "Department", value: "Sales" }, + }, + routedToBookingUid: booking1.uid, + }, + }); + + await prisma.app_RoutingForms_FormResponse.create({ + data: { + formFillerId: "filler-2", + formId: form.id, + response: { + [fieldIds[0]]: { label: "Name", value: "Jane Smith" }, + [fieldIds[1]]: { label: "Email", value: "jane@example.com" }, + [fieldIds[2]]: { label: "Department", value: "Support" }, + }, + routedToBookingUid: booking2.uid, + }, + }); + + // Create a response without booking (for "Failed Bookings" chart) + await prisma.app_RoutingForms_FormResponse.create({ + data: { + formFillerId: "filler-3", + formId: form.id, + response: { + [fieldIds[0]]: { label: "Name", value: "Bob Wilson" }, + [fieldIds[1]]: { label: "Email", value: "bob@example.com" }, + [fieldIds[2]]: { label: "Department", value: "Sales" }, + }, + routedToBookingUid: null, + }, + }); + + // Navigate to insights routing page + await page.goto("/insights/routing"); + await expect(page).toHaveURL(/\/insights\/routing/); + + // Wait for the page to be fully loaded + await page.waitForLoadState("networkidle"); + + // Get all chart IDs before checking + const chartIds = await getAllChartIds(page); + console.log(`[Routing] Found ${chartIds.length} charts: ${chartIds.join(", ")}`); + + // Assert all charts load successfully + await expectAllChartsToLoad(page, 20000); // 20 second timeout for all charts + + // Verify specific expected charts are present and loaded + const expectedCharts = ["stats", "routing-funnel", "routed-to-per-period", "failed-bookings-by-field"]; + + for (const chartId of expectedCharts) { + const chart = page.locator(`[data-testid="chart-card"][data-chart-id="${chartId}"]`); + await expect(chart).toBeVisible(); + await expect(chart).toHaveAttribute("data-loading-state", "loaded"); + } + + // Cleanup + await prisma.booking.delete({ where: { id: booking1.id } }); + await prisma.booking.delete({ where: { id: booking2.id } }); + }); + }); + + test.describe("Bookings Insights", () => { + test("all charts should load successfully with booking data", async ({ + page, + users, + bookings, + prisma, + }) => { + // Setup: Create users with team membership + const owner = await users.create({ name: "Chart Test Owner" }); + const member = await users.create({ name: "Chart Test Member" }); + + // Create team and add both users + const team = await prisma.team.create({ + data: { + name: "test-insights-bookings", + slug: `test-insights-bookings-${Date.now()}`, + }, + }); + + await prisma.membership.createMany({ + data: [ + { + userId: owner.id, + teamId: team.id, + accepted: true, + role: "ADMIN", + }, + { + userId: member.id, + teamId: team.id, + accepted: true, + role: "MEMBER", + }, + ], + }); + + await owner.apiLogin(); + + // Create some bookings for the user + const now = new Date(); + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + + const dayAfterTomorrow = new Date(tomorrow); + dayAfterTomorrow.setDate(dayAfterTomorrow.getDate() + 1); + + const threeDaysFromNow = new Date(dayAfterTomorrow); + threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 1); + + // Create bookings at different times to ensure unique idempotency keys + await bookings.create(owner.id, owner.username, owner.eventTypes[0].id, { + status: BookingStatus.ACCEPTED, + startTime: now, + endTime: tomorrow, + }); + + await bookings.create(owner.id, owner.username, owner.eventTypes[0].id, { + status: BookingStatus.ACCEPTED, + startTime: tomorrow, + endTime: dayAfterTomorrow, + }); + + await bookings.create(owner.id, owner.username, owner.eventTypes[0].id, { + status: BookingStatus.CANCELLED, + startTime: dayAfterTomorrow, + endTime: threeDaysFromNow, + }); + + // Navigate to insights bookings page + await page.goto("/insights"); + await expect(page).toHaveURL(/\/insights/); + + // Wait for the page to be fully loaded + await page.waitForLoadState("networkidle"); + + // Get all chart IDs before checking + const chartIds = await getAllChartIds(page); + console.log(`[Bookings] Found ${chartIds.length} charts: ${chartIds.join(", ")}`); + + // Assert all charts load successfully + await expectAllChartsToLoad(page, 20000); // 20 second timeout for all charts + + // Verify specific expected charts are present and loaded + const expectedCharts = [ + "events", + "performance", + "event-trends", + "no-show-hosts-over-time", + "csat-over-time", + "bookings-by-hour", + "average-event-duration", + ]; + + for (const chartId of expectedCharts) { + const chart = page.locator(`[data-testid="chart-card"][data-chart-id="${chartId}"]`); + // Chart should exist and be loaded + await expect(chart).toHaveCount(1); + await expect(chart).toHaveAttribute("data-loading-state", "loaded"); + } + + // Verify no charts are in error state + const errorCharts = page.locator('[data-testid="chart-card"][data-loading-state="error"]'); + await expect(errorCharts).toHaveCount(0); + }); + }); +}); diff --git a/apps/web/playwright/insights.e2e.ts b/apps/web/playwright/insights.e2e.ts index 49ca82fd55..fa358eef9f 100644 --- a/apps/web/playwright/insights.e2e.ts +++ b/apps/web/playwright/insights.e2e.ts @@ -272,7 +272,7 @@ test.describe("Insights", async () => { for (const title of expectedChartTitles) { const chartCard = page - .locator("[data-testid='panel-card'] h2") + .locator("[data-testid='chart-card'] h2") .filter({ hasText: new RegExp(`^${title}$`) }); await expect(chartCard).toBeVisible(); } diff --git a/apps/web/playwright/lib/chart-helpers.ts b/apps/web/playwright/lib/chart-helpers.ts new file mode 100644 index 0000000000..9f63f4ab0c --- /dev/null +++ b/apps/web/playwright/lib/chart-helpers.ts @@ -0,0 +1,134 @@ +import { expect, type Page } from "@playwright/test"; + +export type ChartLoadingState = "initial" | "loading" | "loaded" | "error"; + +export interface ChartLoadingResult { + chartId: string | null; + loadingState: ChartLoadingState; + success: boolean; + error?: string; +} + +/** + * Wait for all chart cards on the page to reach "loaded" state + * @param page Playwright page object + * @param timeout Maximum time to wait for all charts (default: 15000ms) + * @returns Array of chart loading results + */ +export async function waitForAllChartsToLoad(page: Page, timeout = 15000): Promise { + const startTime = Date.now(); + const results: ChartLoadingResult[] = []; + + // Wait for at least one chart card to be visible + await page.locator('[data-testid="chart-card"]').first().waitFor({ state: "visible", timeout }); + + // Get all chart cards + const chartCards = await page.locator('[data-testid="chart-card"]').all(); + + if (chartCards.length === 0) { + throw new Error("No chart cards found on the page"); + } + + // Wait for each chart to reach loaded state + for (let i = 0; i < chartCards.length; i++) { + const chart = chartCards[i]; + const remainingTime = timeout - (Date.now() - startTime); + + if (remainingTime <= 0) { + throw new Error(`Timeout waiting for charts to load after ${timeout}ms`); + } + + const chartId = await chart.getAttribute("data-chart-id"); + const result: ChartLoadingResult = { + chartId, + loadingState: "initial", + success: false, + }; + + try { + // Wait for this specific chart to reach loaded state + await expect(chart).toHaveAttribute("data-loading-state", "loaded", { + timeout: remainingTime, + }); + + result.loadingState = "loaded"; + result.success = true; + } catch { + // Get the actual state if it failed + const actualState = await chart.getAttribute("data-loading-state"); + result.loadingState = (actualState as ChartLoadingState) || "initial"; + result.error = `Chart "${chartId}" failed to load. Current state: ${actualState}`; + result.success = false; + } + + results.push(result); + } + + return results; +} + +/** + * Assert that all charts on the page have loaded successfully + * @param page Playwright page object + * @param timeout Maximum time to wait for all charts (default: 15000ms) + */ +export async function expectAllChartsToLoad(page: Page, timeout = 15000): Promise { + const results = await waitForAllChartsToLoad(page, timeout); + + const failedCharts = results.filter((r) => !r.success); + + if (failedCharts.length > 0) { + const errorMessages = failedCharts.map((r) => r.error).join("\n"); + throw new Error(`${failedCharts.length} chart(s) failed to load:\n${errorMessages}`); + } + + // Additional check: ensure no charts are in error state + const errorCharts = page.locator('[data-testid="chart-card"][data-loading-state="error"]'); + const errorCount = await errorCharts.count(); + + if (errorCount > 0) { + const errorChartIds = await Promise.all( + (await errorCharts.all()).map((chart) => chart.getAttribute("data-chart-id")) + ); + throw new Error(`${errorCount} chart(s) are in error state: ${errorChartIds.join(", ")}`); + } +} + +/** + * Get the loading state of a specific chart by its ID + * @param page Playwright page object + * @param chartId The chart ID (derived from title) + * @returns The loading state of the chart + */ +export async function getChartLoadingState(page: Page, chartId: string): Promise { + const chart = page.locator(`[data-testid="chart-card"][data-chart-id="${chartId}"]`); + const state = await chart.getAttribute("data-loading-state"); + return state as ChartLoadingState | null; +} + +/** + * Wait for a specific chart to reach loaded state + * @param page Playwright page object + * @param chartId The chart ID (derived from title) + * @param timeout Maximum time to wait (default: 10000ms) + */ +export async function waitForChartToLoad(page: Page, chartId: string, timeout = 10000): Promise { + const chart = page.locator(`[data-testid="chart-card"][data-chart-id="${chartId}"]`); + await expect(chart).toHaveAttribute("data-loading-state", "loaded", { timeout }); +} + +/** + * Get all chart IDs on the page + * @param page Playwright page object + * @returns Array of chart IDs + */ +export async function getAllChartIds(page: Page): Promise { + const chartCards = await page.locator('[data-testid="chart-card"]').all(); + const chartIds = await Promise.all( + chartCards.map(async (chart) => { + const id = await chart.getAttribute("data-chart-id"); + return id || "unknown"; + }) + ); + return chartIds; +} diff --git a/packages/features/insights/HOW_TO_ADD_BOOKING_CHARTS.md b/packages/features/insights/HOW_TO_ADD_BOOKING_CHARTS.md index 8760c12b67..13e32276a9 100644 --- a/packages/features/insights/HOW_TO_ADD_BOOKING_CHARTS.md +++ b/packages/features/insights/HOW_TO_ADD_BOOKING_CHARTS.md @@ -29,16 +29,19 @@ export const MyNewChart = () => { const { t } = useLocale(); const insightsBookingParams = useInsightsBookingParameters(); - const { data, isSuccess, isPending } = trpc.viewer.insights.myNewChartData.useQuery(insightsBookingParams, { - staleTime: 180000, // 3 minutes - refetchOnWindowFocus: false, - trpc: { context: { skipBatch: true } }, - }); + const { data, isSuccess, isPending, isError } = trpc.viewer.insights.myNewChartData.useQuery( + insightsBookingParams, + { + staleTime: 180000, // 3 minutes + refetchOnWindowFocus: false, + trpc: { context: { skipBatch: true } }, + } + ); if (isPending) return ; return ( - + {isSuccess && data?.length > 0 ? ( @@ -177,7 +180,28 @@ export class InsightsBookingBaseService { 2. **Raw SQL for Performance**: Use `$queryRaw` for complex aggregations and better performance 3. **Base Conditions**: Always use `await this.getBaseConditions()` for proper filtering and permissions 4. **Error Handling**: Wrap service calls in try-catch blocks with `TRPCError` -5. **Loading States**: Always show loading indicators with `LoadingInsight` -6. **Consistent Styling**: Use `recharts` for new charts -7. **Date Handling**: Use `getDateRanges()` and `getTimeView()` for time-based charts -8. **Prisma v6 Compatibility**: Use `Prisma.sql` for the entire query instead of mixing template literals with SQL fragments +5. **Loading States**: Always destructure `isPending` and `isError` from the query and pass them to `ChartCard` +6. **ChartCard Props**: Pass `isPending` and `isError` to `ChartCard` - it will automatically calculate the loading state +7. **Consistent Styling**: Use `recharts` for new charts +8. **Date Handling**: Use `getDateRanges()` and `getTimeView()` for time-based charts +9. **Prisma v6 Compatibility**: Use `Prisma.sql` for the entire query instead of mixing template literals with SQL fragments + +## Loading State Management + +`ChartCard` automatically handles loading states. Simply pass `isPending` and `isError`: + +```typescript +const { data, isPending, isError } = trpc.viewer.insights.myData.useQuery(...); + +// ChartCard will automatically show: +// - "loading" state when isPending is true +// - "error" state when isError is true +// - "loaded" state when both are false +return ( + + {/* Your chart content */} + +); +``` + +This enables E2E tests to verify that all charts load successfully by checking the `data-loading-state` attribute. diff --git a/packages/features/insights/components/ChartCard.tsx b/packages/features/insights/components/ChartCard.tsx index f39be19c08..6dce15a072 100644 --- a/packages/features/insights/components/ChartCard.tsx +++ b/packages/features/insights/components/ChartCard.tsx @@ -4,6 +4,8 @@ import { Fragment, useMemo, type ReactNode } from "react"; import classNames from "@calcom/ui/classNames"; import { PanelCard } from "@calcom/ui/components/card"; +import { Spinner } from "@calcom/ui/components/icon"; +import { SkeletonText } from "@calcom/ui/components/skeleton"; import { Tooltip } from "@calcom/ui/components/tooltip"; type PanelCardProps = React.ComponentProps; @@ -20,21 +22,49 @@ export function ChartCard({ legendSize, enabledLegend, onSeriesToggle, + isPending, + isError, ...panelCardProps -}: PanelCardProps & { +}: Omit & { legend?: Array; legendSize?: LegendSize; enabledLegend?: Array; onSeriesToggle?: (label: string) => void; + isPending?: boolean; + isError?: boolean; + children?: ReactNode; }) { const legendComponent = legend && legend.length > 0 ? ( ) : null; + // Generate a chart ID from the title for testing purposes + const chartId = useMemo(() => { + if (typeof panelCardProps.title === "string") { + return panelCardProps.title.toLowerCase().replace(/\s+/g, "-"); + } + return undefined; + }, [panelCardProps.title]); + + // Calculate loading state from isPending/isError + const computedLoadingState = useMemo(() => { + if (isPending) return "loading"; + if (isError) return "error"; + return "loaded"; + }, [isPending, isError]); + + const shouldShowDefaultLoading = isPending && !panelCardProps.children; + + const displayTitle = shouldShowDefaultLoading ? : panelCardProps.title; + return ( @@ -45,7 +75,13 @@ export function ChartCard({ legendComponent ) }> - {panelCardProps.children} + {shouldShowDefaultLoading ? ( +
+ +
+ ) : ( + panelCardProps.children + )}
); } diff --git a/packages/features/insights/components/LoadingInsights.tsx b/packages/features/insights/components/LoadingInsights.tsx deleted file mode 100644 index a2b549c0a7..0000000000 --- a/packages/features/insights/components/LoadingInsights.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { SkeletonText } from "@calcom/ui/components/skeleton"; - -import { ChartCard } from "./ChartCard"; - -export const LoadingInsight = () => { - return ( - }> -
- - - -
-
- ); -}; diff --git a/packages/features/insights/components/booking/AverageEventDurationChart.tsx b/packages/features/insights/components/booking/AverageEventDurationChart.tsx index 4634b7eaf7..8ccabe406b 100644 --- a/packages/features/insights/components/booking/AverageEventDurationChart.tsx +++ b/packages/features/insights/components/booking/AverageEventDurationChart.tsx @@ -8,7 +8,6 @@ import type { RouterOutputs } from "@calcom/trpc/react"; import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; import { ChartCard } from "../ChartCard"; -import { LoadingInsight } from "../LoadingInsights"; const COLOR = { AVERAGE: "#3b82f6", @@ -69,7 +68,7 @@ export const AverageEventDurationChart = () => { const { t } = useLocale(); const insightsBookingParams = useInsightsBookingParameters(); - const { data, isSuccess, isPending } = trpc.viewer.insights.averageEventDuration.useQuery( + const { data, isSuccess, isPending, isError } = trpc.viewer.insights.averageEventDuration.useQuery( insightsBookingParams, { staleTime: 180000, @@ -80,45 +79,42 @@ export const AverageEventDurationChart = () => { } ); - if (isPending) return ; - - if (!isSuccess || !data) return null; - const isNoData = data.every((item) => item["Average"] === 0); return ( - - {isNoData && ( -
-

{t("insights_no_data_found_for_filter")}

-
- )} - {data && data.length > 0 && !isNoData && ( -
- - - - - - } /> - - - -
- )} + + {isSuccess && data ? ( + data.every((item) => item["Average"] === 0) ? ( +
+

{t("insights_no_data_found_for_filter")}

+
+ ) : ( +
+ + + + + + } /> + + + +
+ ) + ) : null}
); }; diff --git a/packages/features/insights/components/booking/BookingKPICards.tsx b/packages/features/insights/components/booking/BookingKPICards.tsx index 0fb7b45009..df9430c75c 100644 --- a/packages/features/insights/components/booking/BookingKPICards.tsx +++ b/packages/features/insights/components/booking/BookingKPICards.tsx @@ -13,7 +13,7 @@ export const BookingKPICards = () => { const { t } = useLocale(); const insightsBookingParams = useInsightsBookingParameters(); - const { data, isSuccess, isPending } = trpc.viewer.insights.bookingKPIStats.useQuery( + const { data, isSuccess, isPending, isError } = trpc.viewer.insights.bookingKPIStats.useQuery( insightsBookingParams, { staleTime: 180000, @@ -70,7 +70,12 @@ export const BookingKPICards = () => { if (isPending) { return ( - + ); } @@ -78,7 +83,7 @@ export const BookingKPICards = () => { return (
- + {eventCategories.map((item, index) => ( @@ -92,7 +97,7 @@ export const BookingKPICards = () => { - + {performanceCategories.map((item, index) => ( @@ -112,13 +117,15 @@ export const BookingKPICards = () => { const LoadingKPICards = (props: { eventCategories: { title: string; index: string }[]; performanceCategories: { title: string; index: string }[]; + isPending: boolean; + isError: boolean; }) => { - const { eventCategories, performanceCategories } = props; + const { eventCategories, performanceCategories, isPending, isError } = props; const { t } = useLocale(); return (
- + {eventCategories.map((item, index) => ( @@ -136,7 +143,7 @@ const LoadingKPICards = (props: { - + {performanceCategories.map((item, index) => ( diff --git a/packages/features/insights/components/booking/BookingsByHourChart.tsx b/packages/features/insights/components/booking/BookingsByHourChart.tsx index d839afbc67..c66609f745 100644 --- a/packages/features/insights/components/booking/BookingsByHourChart.tsx +++ b/packages/features/insights/components/booking/BookingsByHourChart.tsx @@ -17,7 +17,6 @@ import type { RouterOutputs } from "@calcom/trpc/react"; import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; import { ChartCard } from "../ChartCard"; -import { LoadingInsight } from "../LoadingInsights"; type BookingsByHourData = RouterOutputs["viewer"]["insights"]["bookingsByHourStats"][number]; @@ -97,7 +96,7 @@ export const BookingsByHourChart = () => { const { t } = useLocale(); const insightsBookingParams = useInsightsBookingParameters(); - const { data, isSuccess, isPending } = trpc.viewer.insights.bookingsByHourStats.useQuery( + const { data, isSuccess, isPending, isError } = trpc.viewer.insights.bookingsByHourStats.useQuery( insightsBookingParams, { staleTime: 180000, @@ -108,13 +107,9 @@ export const BookingsByHourChart = () => { } ); - if (isPending) return ; - - if (!isSuccess || !data) return null; - return ( - - + + {isSuccess && data ? : null} ); }; diff --git a/packages/features/insights/components/booking/CSATOverTimeChart.tsx b/packages/features/insights/components/booking/CSATOverTimeChart.tsx index 15dc63851e..f8482e3142 100644 --- a/packages/features/insights/components/booking/CSATOverTimeChart.tsx +++ b/packages/features/insights/components/booking/CSATOverTimeChart.tsx @@ -8,7 +8,6 @@ import type { RouterOutputs } from "@calcom/trpc/react"; import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; import { ChartCard } from "../ChartCard"; -import { LoadingInsight } from "../LoadingInsights"; const COLOR = { CSAT: "#22c55e", @@ -57,6 +56,7 @@ export const CSATOverTimeChart = () => { data: csatData, isSuccess, isPending, + isError, } = trpc.viewer.insights.csatOverTime.useQuery(insightsBookingParams, { staleTime: 180000, refetchOnWindowFocus: false, @@ -65,39 +65,38 @@ export const CSATOverTimeChart = () => { }, }); - if (isPending) return ; - - if (!isSuccess) return null; return ( - -
- - - - - `${value}%`} - domain={[0, 100]} - /> - } /> - - - -
+ + {isSuccess ? ( +
+ + + + + `${value}%`} + domain={[0, 100]} + /> + } /> + + + +
+ ) : null}
); }; diff --git a/packages/features/insights/components/booking/EventTrendsChart.tsx b/packages/features/insights/components/booking/EventTrendsChart.tsx index e08d5280f3..0767e4c59e 100644 --- a/packages/features/insights/components/booking/EventTrendsChart.tsx +++ b/packages/features/insights/components/booking/EventTrendsChart.tsx @@ -10,7 +10,6 @@ import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingPara import { useToggleableLegend } from "../../hooks/useToggleableLegend"; import { valueFormatter } from "../../lib/valueFormatter"; import { ChartCard } from "../ChartCard"; -import { LoadingInsight } from "../LoadingInsights"; const COLOR = { CREATED: "#a855f7", @@ -73,6 +72,7 @@ export const EventTrendsChart = () => { data: eventTrends, isSuccess, isPending, + isError, } = trpc.viewer.insights.eventTrends.useQuery(insightsBookingParams, { staleTime: 180000, refetchOnWindowFocus: false, @@ -81,45 +81,45 @@ export const EventTrendsChart = () => { }, }); - if (isPending) return ; - - if (!isSuccess) return null; - return ( -
- - - - - - } /> - {enabledLegend.map((item) => ( - + {isSuccess ? ( +
+ + + + + - ))} - - -
+ } /> + {enabledLegend.map((item) => ( + + ))} +
+
+
+ ) : null}
); }; diff --git a/packages/features/insights/components/booking/HighestNoShowHostTable.tsx b/packages/features/insights/components/booking/HighestNoShowHostTable.tsx index 96c16b52cc..afbef1811a 100644 --- a/packages/features/insights/components/booking/HighestNoShowHostTable.tsx +++ b/packages/features/insights/components/booking/HighestNoShowHostTable.tsx @@ -5,14 +5,13 @@ import { trpc } from "@calcom/trpc"; import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; import { ChartCard } from "../ChartCard"; -import { LoadingInsight } from "../LoadingInsights"; import { UserStatsTable } from "../UserStatsTable"; export const HighestNoShowHostTable = () => { const { t } = useLocale(); const insightsBookingParams = useInsightsBookingParameters(); - const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostNoShow.useQuery( + const { data, isSuccess, isPending, isError } = trpc.viewer.insights.membersWithMostNoShow.useQuery( insightsBookingParams, { staleTime: 180000, @@ -23,13 +22,9 @@ export const HighestNoShowHostTable = () => { } ); - if (isPending) return ; - - if (!isSuccess || !data) return null; - return ( - - + + {isSuccess && data ? : null} ); }; diff --git a/packages/features/insights/components/booking/HighestRatedMembersTable.tsx b/packages/features/insights/components/booking/HighestRatedMembersTable.tsx index d1bc201fb4..cbf79954b4 100644 --- a/packages/features/insights/components/booking/HighestRatedMembersTable.tsx +++ b/packages/features/insights/components/booking/HighestRatedMembersTable.tsx @@ -5,14 +5,13 @@ import { trpc } from "@calcom/trpc"; import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; import { ChartCard } from "../ChartCard"; -import { LoadingInsight } from "../LoadingInsights"; import { UserStatsTable } from "../UserStatsTable"; export const HighestRatedMembersTable = () => { const { t } = useLocale(); const insightsBookingParams = useInsightsBookingParameters(); - const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithHighestRatings.useQuery( + const { data, isSuccess, isPending, isError } = trpc.viewer.insights.membersWithHighestRatings.useQuery( insightsBookingParams, { staleTime: 180000, @@ -23,13 +22,10 @@ export const HighestRatedMembersTable = () => { } ); - if (isPending) return ; - - if (!isSuccess || !data) return null; return ( - - + + {isSuccess && data ? : null} ); }; diff --git a/packages/features/insights/components/booking/LeastBookedTeamMembersTable.tsx b/packages/features/insights/components/booking/LeastBookedTeamMembersTable.tsx index 8fd16a782c..24a80954b6 100644 --- a/packages/features/insights/components/booking/LeastBookedTeamMembersTable.tsx +++ b/packages/features/insights/components/booking/LeastBookedTeamMembersTable.tsx @@ -5,14 +5,13 @@ import { trpc } from "@calcom/trpc"; import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; import { ChartCard } from "../ChartCard"; -import { LoadingInsight } from "../LoadingInsights"; import { UserStatsTable } from "../UserStatsTable"; export const LeastBookedTeamMembersTable = () => { const { t } = useLocale(); const insightsBookingParams = useInsightsBookingParameters(); - const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithLeastBookings.useQuery( + const { data, isSuccess, isPending, isError } = trpc.viewer.insights.membersWithLeastBookings.useQuery( insightsBookingParams, { staleTime: 180000, @@ -23,13 +22,9 @@ export const LeastBookedTeamMembersTable = () => { } ); - if (isPending) return ; - - if (!isSuccess || !data) return null; - return ( - - + + {isSuccess && data ? : null} ); }; diff --git a/packages/features/insights/components/booking/LeastCompletedBookings.tsx b/packages/features/insights/components/booking/LeastCompletedBookings.tsx index 14026dfe6f..39bbf71281 100644 --- a/packages/features/insights/components/booking/LeastCompletedBookings.tsx +++ b/packages/features/insights/components/booking/LeastCompletedBookings.tsx @@ -1,38 +1,28 @@ "use client"; -import { useMemo } from "react"; - -import dayjs from "@calcom/dayjs"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; import { ChartCard } from "../ChartCard"; -import { LoadingInsight } from "../LoadingInsights"; import { UserStatsTable } from "../UserStatsTable"; export const LeastCompletedTeamMembersTable = () => { const { t } = useLocale(); - let insightsBookingParams = useInsightsBookingParameters(); + const insightsBookingParams = useInsightsBookingParameters(); - const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithLeastCompletedBookings.useQuery( - insightsBookingParams, - { + const { data, isSuccess, isPending, isError } = + trpc.viewer.insights.membersWithLeastCompletedBookings.useQuery(insightsBookingParams, { staleTime: 180000, refetchOnWindowFocus: false, trpc: { context: { skipBatch: true }, }, - } - ); - - if (isPending) return ; - - if (!isSuccess || !data) return null; + }); return ( - - + + {isSuccess && data ? : null} ); }; diff --git a/packages/features/insights/components/booking/LowestRatedMembersTable.tsx b/packages/features/insights/components/booking/LowestRatedMembersTable.tsx index 0aa7c57b36..ef5faa0a2a 100644 --- a/packages/features/insights/components/booking/LowestRatedMembersTable.tsx +++ b/packages/features/insights/components/booking/LowestRatedMembersTable.tsx @@ -5,14 +5,13 @@ import { trpc } from "@calcom/trpc"; import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; import { ChartCard } from "../ChartCard"; -import { LoadingInsight } from "../LoadingInsights"; import { UserStatsTable } from "../UserStatsTable"; export const LowestRatedMembersTable = () => { const { t } = useLocale(); const insightsBookingParams = useInsightsBookingParameters(); - const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithLowestRatings.useQuery( + const { data, isSuccess, isPending, isError } = trpc.viewer.insights.membersWithLowestRatings.useQuery( insightsBookingParams, { staleTime: 180000, @@ -23,13 +22,10 @@ export const LowestRatedMembersTable = () => { } ); - if (isPending) return ; - - if (!isSuccess || !data) return null; return ( - - + + {isSuccess && data ? : null} ); }; diff --git a/packages/features/insights/components/booking/MostBookedTeamMembersTable.tsx b/packages/features/insights/components/booking/MostBookedTeamMembersTable.tsx index 9e71e0452f..fdc16557f2 100644 --- a/packages/features/insights/components/booking/MostBookedTeamMembersTable.tsx +++ b/packages/features/insights/components/booking/MostBookedTeamMembersTable.tsx @@ -5,14 +5,13 @@ import { trpc } from "@calcom/trpc"; import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; import { ChartCard } from "../ChartCard"; -import { LoadingInsight } from "../LoadingInsights"; import { UserStatsTable } from "../UserStatsTable"; export const MostBookedTeamMembersTable = () => { const { t } = useLocale(); const insightsBookingParams = useInsightsBookingParameters(); - const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostBookings.useQuery( + const { data, isSuccess, isPending, isError } = trpc.viewer.insights.membersWithMostBookings.useQuery( insightsBookingParams, { staleTime: 180000, @@ -23,13 +22,10 @@ export const MostBookedTeamMembersTable = () => { } ); - if (isPending) return ; - - if (!isSuccess || !data) return null; return ( - - + + {isSuccess && data ? : null} ); }; diff --git a/packages/features/insights/components/booking/MostCancelledBookingsTables.tsx b/packages/features/insights/components/booking/MostCancelledBookingsTables.tsx index 022802e2e6..fd87f922b6 100644 --- a/packages/features/insights/components/booking/MostCancelledBookingsTables.tsx +++ b/packages/features/insights/components/booking/MostCancelledBookingsTables.tsx @@ -5,14 +5,13 @@ import { trpc } from "@calcom/trpc"; import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; import { ChartCard } from "../ChartCard"; -import { LoadingInsight } from "../LoadingInsights"; import { UserStatsTable } from "../UserStatsTable"; export const MostCancelledBookingsTables = () => { const { t } = useLocale(); const insightsBookingParams = useInsightsBookingParameters(); - const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostCancelledBookings.useQuery( + const { data, isSuccess, isPending, isError } = trpc.viewer.insights.membersWithMostCancelledBookings.useQuery( insightsBookingParams, { staleTime: 180000, @@ -23,13 +22,9 @@ export const MostCancelledBookingsTables = () => { } ); - if (isPending) return ; - - if (!isSuccess || !data) return null; - return ( - - + + {isSuccess && data ? : null} ); }; diff --git a/packages/features/insights/components/booking/MostCompletedBookings.tsx b/packages/features/insights/components/booking/MostCompletedBookings.tsx index d29f23caf6..e220dd91da 100644 --- a/packages/features/insights/components/booking/MostCompletedBookings.tsx +++ b/packages/features/insights/components/booking/MostCompletedBookings.tsx @@ -1,38 +1,28 @@ "use client"; -import { useMemo } from "react"; - -import dayjs from "@calcom/dayjs"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; import { ChartCard } from "../ChartCard"; -import { LoadingInsight } from "../LoadingInsights"; import { UserStatsTable } from "../UserStatsTable"; export const MostCompletedTeamMembersTable = () => { const { t } = useLocale(); - let insightsBookingParams = useInsightsBookingParameters(); + const insightsBookingParams = useInsightsBookingParameters(); - const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostCompletedBookings.useQuery( - insightsBookingParams, - { + const { data, isSuccess, isPending, isError } = + trpc.viewer.insights.membersWithMostCompletedBookings.useQuery(insightsBookingParams, { staleTime: 180000, refetchOnWindowFocus: false, trpc: { context: { skipBatch: true }, }, - } - ); - - if (isPending) return ; - - if (!isSuccess || !data) return null; + }); return ( - - + + {isSuccess && data ? : null} ); }; diff --git a/packages/features/insights/components/booking/NoShowHostsOverTimeChart.tsx b/packages/features/insights/components/booking/NoShowHostsOverTimeChart.tsx index a62ca30e09..f9b67782bb 100644 --- a/packages/features/insights/components/booking/NoShowHostsOverTimeChart.tsx +++ b/packages/features/insights/components/booking/NoShowHostsOverTimeChart.tsx @@ -9,7 +9,6 @@ import type { RouterOutputs } from "@calcom/trpc/react"; import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; import { valueFormatter } from "../../lib/valueFormatter"; import { ChartCard } from "../ChartCard"; -import { LoadingInsight } from "../LoadingInsights"; const COLOR = { NO_SHOW_HOST: "#64748b", @@ -58,6 +57,7 @@ export const NoShowHostsOverTimeChart = () => { data: noShowHostsData, isSuccess, isPending, + isError, } = trpc.viewer.insights.noShowHostsOverTime.useQuery(insightsBookingParams, { staleTime: 180000, refetchOnWindowFocus: false, @@ -66,38 +66,36 @@ export const NoShowHostsOverTimeChart = () => { }, }); - if (isPending) return ; - - if (!isSuccess) return null; - return ( - -
- - - - - - } /> - - - -
+ + {isSuccess ? ( +
+ + + + + + } /> + + + +
+ ) : null}
); }; diff --git a/packages/features/insights/components/booking/PopularEventsTable.tsx b/packages/features/insights/components/booking/PopularEventsTable.tsx index 584513ff4e..d4cd8aabc5 100644 --- a/packages/features/insights/components/booking/PopularEventsTable.tsx +++ b/packages/features/insights/components/booking/PopularEventsTable.tsx @@ -5,13 +5,12 @@ import { trpc } from "@calcom/trpc"; import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; import { ChartCard, ChartCardItem } from "../ChartCard"; -import { LoadingInsight } from "../LoadingInsights"; export const PopularEventsTable = () => { const { t } = useLocale(); const insightsBookingParams = useInsightsBookingParameters(); - const { data, isSuccess, isPending } = trpc.viewer.insights.popularEvents.useQuery(insightsBookingParams, { + const { data, isSuccess, isPending, isError } = trpc.viewer.insights.popularEvents.useQuery(insightsBookingParams, { staleTime: 180000, refetchOnWindowFocus: false, trpc: { @@ -19,29 +18,29 @@ export const PopularEventsTable = () => { }, }); - if (isPending) return ; - - if (!isSuccess || !data) return null; - return ( - -
- {data - .filter((item) => item.eventTypeId) - .map((item) => ( - -
-
-

{item.eventTypeName}

-
- - ))} -
- {data.length === 0 && ( -
-

{t("insights_no_data_found_for_filter")}

-
- )} + + {isSuccess && data ? ( + <> +
+ {data + .filter((item) => item.eventTypeId) + .map((item) => ( + +
+
+

{item.eventTypeName}

+
+ + ))} +
+ {data.length === 0 && ( +
+

{t("insights_no_data_found_for_filter")}

+
+ )} + + ) : null} ); }; diff --git a/packages/features/insights/components/booking/RecentFeedbackTable.tsx b/packages/features/insights/components/booking/RecentFeedbackTable.tsx index cc7a324646..499aac0012 100644 --- a/packages/features/insights/components/booking/RecentFeedbackTable.tsx +++ b/packages/features/insights/components/booking/RecentFeedbackTable.tsx @@ -5,14 +5,13 @@ import { trpc } from "@calcom/trpc"; import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; import { ChartCard } from "../ChartCard"; -import { LoadingInsight } from "../LoadingInsights"; import { RecentFeedbackTableContent } from "./RecentFeedbackTableContent"; export const RecentFeedbackTable = () => { const { t } = useLocale(); const insightsBookingParams = useInsightsBookingParameters(); - const { data, isSuccess, isPending } = trpc.viewer.insights.recentRatings.useQuery(insightsBookingParams, { + const { data, isSuccess, isPending, isError } = trpc.viewer.insights.recentRatings.useQuery(insightsBookingParams, { staleTime: 180000, refetchOnWindowFocus: false, trpc: { @@ -20,13 +19,10 @@ export const RecentFeedbackTable = () => { }, }); - if (isPending) return ; - - if (!isSuccess || !data) return null; return ( - - + + {isSuccess && data ? : null} ); }; diff --git a/packages/features/insights/components/booking/RecentNoShowGuestsChart.tsx b/packages/features/insights/components/booking/RecentNoShowGuestsChart.tsx index 7a9fcc3ac6..dc31d2ab6f 100644 --- a/packages/features/insights/components/booking/RecentNoShowGuestsChart.tsx +++ b/packages/features/insights/components/booking/RecentNoShowGuestsChart.tsx @@ -8,7 +8,6 @@ import { showToast } from "@calcom/ui/components/toast"; import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; import { ChartCard, ChartCardItem } from "../ChartCard"; -import { LoadingInsight } from "../LoadingInsights"; export const RecentNoShowGuestsChart = () => { const { t } = useLocale(); @@ -16,7 +15,7 @@ export const RecentNoShowGuestsChart = () => { const insightsBookingParams = useInsightsBookingParameters(); const timeZone = insightsBookingParams.timeZone; - const { data, isSuccess, isPending } = trpc.viewer.insights.recentNoShowGuests.useQuery( + const { data, isSuccess, isPending, isError } = trpc.viewer.insights.recentNoShowGuests.useQuery( insightsBookingParams, { staleTime: 180000, @@ -27,10 +26,6 @@ export const RecentNoShowGuestsChart = () => { } ); - if (isPending) return ; - - if (!isSuccess || !data) return null; - const handleCopyEmail = (email: string) => { copyToClipboard(email); showToast(t("email_copied"), "success"); @@ -40,45 +35,49 @@ export const RecentNoShowGuestsChart = () => { -
-
- {data.map((item) => ( - -
-
-
-
-

{item.guestName}

-
-

{item.eventTypeName}

-

- {Intl.DateTimeFormat(undefined, { - timeZone, - dateStyle: "medium", - timeStyle: "short", - }).format(new Date(item.startTime))} -

+ className="h-full" + isPending={isPending} + isError={isError}> + {isSuccess && data ? ( +
+
+ {data.map((item) => ( + +
+
+
+
+

{item.guestName}

+
+

{item.eventTypeName}

+

+ {Intl.DateTimeFormat(undefined, { + timeZone, + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(item.startTime))} +

+
+
- -
-
- ))} -
- {data.length === 0 && ( -
-

{t("insights_no_data_found_for_filter")}

+ + ))}
- )} -
+ {data.length === 0 && ( +
+

{t("insights_no_data_found_for_filter")}

+
+ )} +
+ ) : null} ); }; diff --git a/packages/features/insights/components/index.ts b/packages/features/insights/components/index.ts index c94b682f22..6da90aee71 100644 --- a/packages/features/insights/components/index.ts +++ b/packages/features/insights/components/index.ts @@ -5,6 +5,5 @@ export { CellWithOverflowX } from "./CellWithOverflowX"; export { ChartCard } from "./ChartCard"; export { KPICard } from "./KPICard"; export { LineChart } from "./LineChart"; -export { LoadingInsight } from "./LoadingInsights"; export { ResponseValueCell } from "./ResponseValueCell"; export { UserStatsTable } from "./UserStatsTable"; diff --git a/packages/features/insights/components/routing/FailedBookingsByField.tsx b/packages/features/insights/components/routing/FailedBookingsByField.tsx index 9ed58e9958..04c730d185 100644 --- a/packages/features/insights/components/routing/FailedBookingsByField.tsx +++ b/packages/features/insights/components/routing/FailedBookingsByField.tsx @@ -57,7 +57,7 @@ interface FormCardProps { fields: Record; } -function FormCard({ formName, fields }: FormCardProps) { +function FormCard({ fields }: FormCardProps) { const { t } = useLocale(); const fieldNames = Object.keys(fields); const [selectedField, setSelectedField] = useState(fieldNames[0]); @@ -132,16 +132,17 @@ function FormCard({ formName, fields }: FormCardProps) { export function FailedBookingsByField() { const { t } = useLocale(); const insightsRoutingParams = useInsightsRoutingParameters(); - const { data } = trpc.viewer.insights.failedBookingsByField.useQuery(insightsRoutingParams); + const { data, isLoading, isError } = + trpc.viewer.insights.failedBookingsByField.useQuery(insightsRoutingParams); - if (!data || Object.entries(data).length === 0) return null; + if (!isLoading && !isError && (!data || Object.entries(data).length === 0)) return null; // routingFormId is always set, meaning data has only one entry. - const [formName, fields] = Object.entries(data)[0]; + const [formName, fields] = data ? Object.entries(data)[0] : ["", {}]; return ( - - + + {data && } ); } diff --git a/packages/features/insights/components/routing/RoutedToPerPeriod.tsx b/packages/features/insights/components/routing/RoutedToPerPeriod.tsx index 83d3436fa0..ff55d571f6 100644 --- a/packages/features/insights/components/routing/RoutedToPerPeriod.tsx +++ b/packages/features/insights/components/routing/RoutedToPerPeriod.tsx @@ -5,7 +5,6 @@ import { useQueryState } from "nuqs"; import { type ReactNode, useMemo, useRef, useState } from "react"; import posthog from "posthog-js"; -import { DataTableSkeleton } from "@calcom/features/data-table"; import { downloadAsCsv } from "@calcom/lib/csvUtils"; import { useDebounce } from "@calcom/lib/hooks/useDebounce"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -179,7 +178,7 @@ export function RoutedToPerPeriod() { const tableContainerRef = useRef(null); const debouncedSearchQuery = useDebounce(searchQuery, 500); - const { data, isLoading } = trpc.viewer.insights.routedToPerPeriod.useQuery( + const { data, isLoading, isError } = trpc.viewer.insights.routedToPerPeriod.useQuery( { ...routingParams, period: selectedPeriod, @@ -240,26 +239,6 @@ export function RoutedToPerPeriod() { }); }, [data?.periodStats.data, flattenedUsers, uniquePeriods]); - if (isLoading) { - return ( -
-
-

{t("routed_to_per_period")}

-
- - -
- -
-
-
- ); - } - const isCurrentPeriod = (date: Date, today: Date, selectedPeriod: string): boolean => { if (selectedPeriod === "perDay") { return ( @@ -280,7 +259,7 @@ export function RoutedToPerPeriod() { }; return ( - +
+ onSeriesToggle={toggleSeries} + isPending={isLoading} + isError={isError}> ); @@ -40,7 +42,9 @@ export function RoutingFunnel() { title={t("routing_funnel")} legend={legend} enabledLegend={enabledLegend} - onSeriesToggle={toggleSeries}> + onSeriesToggle={toggleSeries} + isPending={isLoading} + isError={isError}> ); diff --git a/packages/features/insights/components/routing/RoutingKPICards.tsx b/packages/features/insights/components/routing/RoutingKPICards.tsx index aca0c85477..7d1232076f 100644 --- a/packages/features/insights/components/routing/RoutingKPICards.tsx +++ b/packages/features/insights/components/routing/RoutingKPICards.tsx @@ -13,12 +13,15 @@ export const RoutingKPICards = () => { const { t } = useLocale(); const insightsRoutingParameters = useInsightsRoutingParameters(); - const { data, isPending } = trpc.viewer.insights.routingFormsByStatus.useQuery(insightsRoutingParameters, { - staleTime: 30000, - trpc: { - context: { skipBatch: true }, - }, - }); + const { data, isSuccess, isPending, isError } = trpc.viewer.insights.routingFormsByStatus.useQuery( + insightsRoutingParameters, + { + staleTime: 30000, + trpc: { + context: { skipBatch: true }, + }, + } + ); const categories: { title: string; @@ -39,38 +42,41 @@ export const RoutingKPICards = () => { ]; if (isPending) { - return ; - } - - if (!data) { - return null; + return ; } return ( - -
- {categories.map((item) => ( -
-
{item.title}
-
-
{valueFormatter(data[item.index])}
+ + {isSuccess && data ? ( +
+ {categories.map((item) => ( +
+
{item.title}
+
+
{valueFormatter(data[item.index])}
+
-
- ))} -
+ ))} +
+ ) : null} ); }; -const LoadingKPICards = (props: { categories: { title: string; index: string }[] }) => { +const LoadingKPICards = (props: { + categories: { title: string; index: string }[]; + isPending: boolean; + isError: boolean; +}) => { const { t } = useLocale(); - const { categories } = props; + const { categories, isPending, isError } = props; + return ( - +
{categories.map((item) => (
u.id))}]) as user_id + SELECT unnest(ARRAY[${Prisma.join(users.map((u) => u.id))}]::integer[]) as user_id ), paginated_periods AS ( SELECT date as period_start diff --git a/packages/ui/components/card/PanelCard.tsx b/packages/ui/components/card/PanelCard.tsx index 756c215b1e..313e228ea8 100644 --- a/packages/ui/components/card/PanelCard.tsx +++ b/packages/ui/components/card/PanelCard.tsx @@ -20,6 +20,7 @@ export function PanelCard({ children, collapsible = false, defaultCollapsed = false, + ...dataAttributes }: { title: string | ReactNode; subtitle?: string; @@ -30,7 +31,7 @@ export function PanelCard({ children: ReactNode; collapsible?: boolean; defaultCollapsed?: boolean; -}) { +} & Record<`data-${string}`, string | undefined>) { const { t } = useLocale(); const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed); const contentId = useId(); @@ -51,7 +52,8 @@ export function PanelCard({ "bg-cal-muted group relative flex w-full flex-col items-center rounded-2xl px-1", !isCollapsed && "pb-1", className - )}> + )} + {...dataAttributes}>
{collapsible && (