From 720136fcf9a13faa81b0edb7b137baf13b67372b Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Mon, 10 Feb 2025 20:28:48 +0100 Subject: [PATCH] fix: replace filter implementations on /insights (#19033) * fix: replace filter implementations on /insights * add authorization for teamIds * replace all the implementations * provide eventTypeId * remove log * fix teamId for isAll * fix Download button * remove legacy implementations * clean up * nullish check * remove unusable filters * revert style * fix type error * fix e2e test * fix type error * fix type error * fix e2e * extract util method * fix type error * fix type error * fix type error --------- Co-authored-by: Benny Joo --- apps/web/modules/insights/insights-view.tsx | 52 +- apps/web/playwright/insights.e2e.ts | 116 ++--- .../components/filters/AddFilterButton.tsx | 3 +- .../components/filters/ClearFiltersButton.tsx | 1 + .../components/filters/DateRangeFilter.tsx | 37 +- .../components/filters/FilterPopover.tsx | 5 +- .../filters/MultiSelectFilterOptions.tsx | 2 +- .../filters/SingleSelectFilterOptions.tsx | 2 +- .../data-table/hooks/useFilterableColumns.ts | 35 +- packages/features/data-table/lib/dateRange.ts | 31 ++ packages/features/data-table/lib/types.ts | 5 + packages/features/data-table/lib/utils.ts | 19 + .../ee/teams/components/MemberList.tsx | 3 +- .../filters/components/TeamsFilter.tsx | 65 +-- .../components/AverageEventDurationChart.tsx | 26 +- .../insights/components/BookedByCell.tsx | 29 ++ .../insights/components/BookingAtCell.tsx | 67 +++ .../insights/components/BookingKPICards.tsx | 24 +- .../components/BookingStatusBadge.tsx | 21 + .../components/BookingStatusLineChart.tsx | 36 +- .../insights/components/CellWithOverflowX.tsx | 31 ++ .../components/HighestNoShowHostTable.tsx | 16 +- .../components/HighestRatedMembersTable.tsx | 16 +- .../LeastBookedTeamMembersTable.tsx | 15 +- .../components/LowestRatedMembersTable.tsx | 16 +- .../components/MostBookedTeamMembersTable.tsx | 16 +- .../components/PopularEventsTable.tsx | 23 +- .../components/RecentFeedbackTable.tsx | 15 +- .../insights/components/ResponseValueCell.tsx | 54 ++ .../components/RoutingFormResponsesTable.tsx | 478 +----------------- .../DateSelect.css => components/tremor.css} | 2 +- .../insights/context/FiltersProvider.tsx | 212 -------- .../features/insights/context/provider.ts | 49 -- .../insights/filters/BookingStatusFilter.tsx | 49 -- .../insights/filters/ClearFilters.tsx | 48 -- .../features/insights/filters/DateSelect.tsx | 109 ---- .../insights/filters/Download/Download.tsx | 17 +- .../insights/filters/EventTypeList.tsx | 47 -- .../features/insights/filters/FilterType.tsx | 91 ---- .../insights/filters/OrgTeamsFilter.tsx | 32 +- .../filters/RoutingFormFieldFilter.tsx | 64 --- .../filters/RoutingFormFilterList.tsx | 51 -- .../insights/filters/TeamAndSelfList.tsx | 182 ------- .../insights/filters/UserListInTeam.tsx | 52 -- packages/features/insights/filters/index.tsx | 91 ---- .../insights/hooks/useInsightsBookings.ts | 77 +++ .../insights/hooks/useInsightsColumns.tsx | 227 +++++++++ .../hooks/useInsightsFacetedUniqueValues.ts | 96 ++++ .../insights/hooks/useInsightsOrgTeams.ts | 5 +- .../insights/hooks/useInsightsParameters.ts | 24 +- .../insights/lib/bookingStatusToText.ts | 9 + packages/features/insights/lib/types.ts | 6 + .../insights/server/routing-events.ts | 19 +- .../features/insights/server/trpc-router.ts | 26 +- .../components/UserTable/UserListTable.tsx | 22 +- 55 files changed, 1037 insertions(+), 1829 deletions(-) create mode 100644 packages/features/data-table/lib/dateRange.ts create mode 100644 packages/features/insights/components/BookedByCell.tsx create mode 100644 packages/features/insights/components/BookingAtCell.tsx create mode 100644 packages/features/insights/components/BookingStatusBadge.tsx create mode 100644 packages/features/insights/components/CellWithOverflowX.tsx create mode 100644 packages/features/insights/components/ResponseValueCell.tsx rename packages/features/insights/{filters/DateSelect.css => components/tremor.css} (99%) delete mode 100644 packages/features/insights/context/FiltersProvider.tsx delete mode 100644 packages/features/insights/context/provider.ts delete mode 100644 packages/features/insights/filters/BookingStatusFilter.tsx delete mode 100644 packages/features/insights/filters/ClearFilters.tsx delete mode 100644 packages/features/insights/filters/DateSelect.tsx delete mode 100644 packages/features/insights/filters/EventTypeList.tsx delete mode 100644 packages/features/insights/filters/FilterType.tsx delete mode 100644 packages/features/insights/filters/RoutingFormFieldFilter.tsx delete mode 100644 packages/features/insights/filters/RoutingFormFilterList.tsx delete mode 100644 packages/features/insights/filters/TeamAndSelfList.tsx delete mode 100644 packages/features/insights/filters/UserListInTeam.tsx create mode 100644 packages/features/insights/hooks/useInsightsBookings.ts create mode 100644 packages/features/insights/hooks/useInsightsColumns.tsx create mode 100644 packages/features/insights/hooks/useInsightsFacetedUniqueValues.ts create mode 100644 packages/features/insights/lib/bookingStatusToText.ts diff --git a/apps/web/modules/insights/insights-view.tsx b/apps/web/modules/insights/insights-view.tsx index b481850347..0e4f7eccb6 100644 --- a/apps/web/modules/insights/insights-view.tsx +++ b/apps/web/modules/insights/insights-view.tsx @@ -1,5 +1,12 @@ "use client"; +import { + DataTableProvider, + DataTableFilters, + DateRangeFilter, + ColumnFilterType, + type FilterableColumn, +} from "@calcom/features/data-table"; import { AverageEventDurationChart, BookingKPICards, @@ -12,25 +19,56 @@ import { HighestRatedMembersTable, LowestRatedMembersTable, } from "@calcom/features/insights/components"; -import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider"; -import { Filters } from "@calcom/features/insights/filters"; +import "@calcom/features/insights/components/tremor.css"; +import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider"; +import { Download } from "@calcom/features/insights/filters/Download"; +import { OrgTeamsFilter } from "@calcom/features/insights/filters/OrgTeamsFilter"; +import { useInsightsBookings } from "@calcom/features/insights/hooks/useInsightsBookings"; +import { useInsightsOrgTeams } from "@calcom/features/insights/hooks/useInsightsOrgTeams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; export default function InsightsPage() { + return ( + + + + + + ); +} + +const createdAtColumn: Extract = { + id: "createdAt", + title: "createdAt", + type: ColumnFilterType.DATE_RANGE, +}; + +function InsightsPageContent() { const { t } = useLocale(); + const { table } = useInsightsBookings(); + const { isAll, teamId, userId } = useInsightsOrgTeams(); return ( - - + <> +
+ + + + +
+ + +
-
+
-
@@ -53,6 +91,6 @@ export default function InsightsPage() {
- + ); } diff --git a/apps/web/playwright/insights.e2e.ts b/apps/web/playwright/insights.e2e.ts index dbdd3b694d..ac51e31c53 100644 --- a/apps/web/playwright/insights.e2e.ts +++ b/apps/web/playwright/insights.e2e.ts @@ -75,10 +75,8 @@ test.describe("Insights", async () => { // go to insights page await page.goto("/insights"); - await page.waitForURL(/.*\/insights\?teamId=[^&]*&isAll=false.*/); - // expect url to have isAll and TeamId in query params - await expect(page).toHaveURL(/.*\/insights\?teamId=[^&]*&isAll=false.*/); + await page.locator('[data-testid^="insights-filters-false-"]').waitFor(); }); test("should be able to go to insights as members", async ({ page, users }) => { @@ -91,10 +89,7 @@ test.describe("Insights", async () => { // go to insights page await page.goto("/insights"); - await page.waitForURL(/.*\/insights\?userId=[^&]*&isAll=false.*/); - - // expect url to have isAll and TeamId in query params - await expect(page).toHaveURL(/.*\/insights\?userId=[^&]*&isAll=false.*/); + await page.locator('[data-testid^="insights-filters-false-"]').waitFor(); }); test("team select filter should have 2 teams and your account option only as member", async ({ @@ -107,19 +102,22 @@ test.describe("Insights", async () => { await user.apiLogin(); await createTeamsAndMembership(user.id, userTwo.id); + // go to insights page await page.goto("/insights"); + await page.locator(`[data-testid=insights-filters-false-undefined-${user.id}]`).waitFor(); - // get div from team select filter with this class flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1 - await page.getByTestId("dashboard-shell").getByText("Team: test-insights").click(); - await page - .locator('div[class="flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1"]') - .click(); - const teamSelectFilter = await page.locator( - 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]' + await page.getByTestId("dashboard-shell").getByText("Your account").click(); + + await expect(await page.locator('[data-testid="org-teams-filter-item"]')).toHaveCount(3); + + await expect(await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "All" })).toHaveCount( + 0 ); - await expect(teamSelectFilter).toHaveCount(3); + await expect( + await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "Your account" }) + ).toHaveCount(1); }); test("Insights Organization should have isAll option true", async ({ users, page }) => { @@ -132,17 +130,20 @@ test.describe("Insights", async () => { await owner.apiLogin(); await page.goto("/insights"); + await page.locator('[data-testid^="insights-filters-true-"]').waitFor(); - await page.getByTestId("dashboard-shell").getByText("All").nth(1).click(); + await page.locator('[data-testid^="insights-filters-true-"]').getByText("All").click(); - const teamSelectFilter = await page.locator( - 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]' - ); + await expect(await page.locator('[data-testid="org-teams-filter-item"]')).toHaveCount(3); - await expect(teamSelectFilter).toHaveCount(4); + await expect( + await page + .locator('[data-testid="org-teams-filter-item"]', { hasText: "All" }) + .locator('input[type="checkbox"]') + ).toBeChecked(); }); - test("should have all option in team-and-self filter as admin", async ({ page, users }) => { + test("should not have all option as admin in non-org", async ({ page, users }) => { const owner = await users.create(); const member = await users.create(); @@ -152,16 +153,13 @@ test.describe("Insights", async () => { await page.goto("/insights"); - // get div from team select filter with this class flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1 - await page.getByTestId("dashboard-shell").getByText("Team: test-insights").click(); - await page - .locator('div[class="flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1"]') - .click(); - const teamSelectFilter = await page.locator( - 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]' - ); + await page.getByTestId("dashboard-shell").getByText("Your account").click(); - await expect(teamSelectFilter).toHaveCount(3); + await expect(await page.locator('[data-testid="org-teams-filter-item"]')).toHaveCount(3); + + await expect(await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "All" })).toHaveCount( + 0 + ); }); test("should be able to switch between teams and self profile for insights", async ({ page, users }) => { @@ -174,25 +172,18 @@ test.describe("Insights", async () => { await page.goto("/insights"); - // get div from team select filter with this class flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1 - await page.getByTestId("dashboard-shell").getByText("Team: test-insights").click(); - await page - .locator('div[class="flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1"]') - .click(); - const teamSelectFilter = await page.locator( - 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]' - ); + await page.getByTestId("dashboard-shell").getByText("Your account").click(); - await expect(teamSelectFilter).toHaveCount(3); + await expect(await page.locator('[data-testid="org-teams-filter-item"]')).toHaveCount(3); // switch to self profile - await page.getByTestId("dashboard-shell").getByText("Your Account").click(); + await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "Your account" }).click(); // switch to team 1 - await page.getByTestId("dashboard-shell").getByText("test-insights").nth(0).click(); + await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "test-insights" }).first().click(); // switch to team 2 - await page.getByTestId("dashboard-shell").getByText("test-insights-2").click(); + await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "test-insights-2" }).click(); }); test("should be able to switch between memberUsers", async ({ page, users }) => { @@ -205,36 +196,39 @@ test.describe("Insights", async () => { await page.goto("/insights"); - await page.getByText("Add filter").click(); + // Choose a team + await page.getByTestId("dashboard-shell").getByText("Your account").click(); + await page.locator('[data-testid="org-teams-filter-item"]').nth(1).click(); + await page.keyboard.press("Escape"); - await page.getByRole("button", { name: "User" }).click(); - //
People
- // await page.locator('div[class="flex select-none truncate font-medium"]').getByText("People").click(); - await page.waitForSelector('[data-testid="people-filter-button"]'); - await page.click('[data-testid="people-filter-button"]'); + // Choose User filter item from dropdown + await page.getByTestId("add-filter-button").click(); + await page.getByTestId("add-filter-item-bookingUserId").click(); + + // Wait for the URL to include bookingUserId + await page.waitForURL((url) => url.toString().includes("bookingUserId")); + + // Click User filter to see a user list + await page.getByTestId("filter-popover-trigger-bookingUserId").click(); await page - // .locator( - // 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]' - // ) - .locator('[data-testid="people-filter-group-option-0"]') - // .nth(0) + .locator('[data-testid="single-select-options-bookingUserId"]') + .getByRole("option") + .nth(0) .click(); await page - // .locator( - // 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]' - // ) - .locator('[data-testid="people-filter-group-option-1"]') - // .nth(1) + .locator('[data-testid="single-select-options-bookingUserId"]') + .getByRole("option") + .nth(1) .click(); + // press escape button to close the filter await page.keyboard.press("Escape"); - await page.getByRole("button", { name: "Clear" }).click(); + await page.getByTestId("clear-filters-button").click(); - // expect for "Team: test-insight" text in page - await expect(page.locator("text=Team: test-insights")).toBeVisible(); + await expect(page.url()).not.toContain("bookingUserId"); }); test("should test download button", async ({ page, users }) => { diff --git a/packages/features/data-table/components/filters/AddFilterButton.tsx b/packages/features/data-table/components/filters/AddFilterButton.tsx index 4caf473842..393a78ff99 100644 --- a/packages/features/data-table/components/filters/AddFilterButton.tsx +++ b/packages/features/data-table/components/filters/AddFilterButton.tsx @@ -63,7 +63,8 @@ function AddFilterButtonComponent( handleAddFilter(column.id)} - className="px-4 py-2"> + className="px-4 py-2" + data-testid={`add-filter-item-${column.id}`}> {startCase(column.title)} ); diff --git a/packages/features/data-table/components/filters/ClearFiltersButton.tsx b/packages/features/data-table/components/filters/ClearFiltersButton.tsx index 341f3a632c..000118c0e6 100644 --- a/packages/features/data-table/components/filters/ClearFiltersButton.tsx +++ b/packages/features/data-table/components/filters/ClearFiltersButton.tsx @@ -13,6 +13,7 @@ export const ClearFiltersButton = ({ exclude }: { exclude?: string[] }) => { return ( +

+
+
+
+ Status: + +
+
+ + + + ); +} diff --git a/packages/features/insights/components/BookingKPICards.tsx b/packages/features/insights/components/BookingKPICards.tsx index 1fb966e33d..dd3cf10ce8 100644 --- a/packages/features/insights/components/BookingKPICards.tsx +++ b/packages/features/insights/components/BookingKPICards.tsx @@ -4,29 +4,22 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; import { SkeletonContainer, SkeletonText } from "@calcom/ui"; -import { useFilterContext } from "../context/provider"; +import { useInsightsParameters } from "../hooks/useInsightsParameters"; import { CardInsights } from "./Card"; import { KPICard } from "./KPICard"; export const BookingKPICards = () => { const { t } = useLocale(); - const { filter } = useFilterContext(); - - const { dateRange, selectedEventTypeId, selectedUserId, selectedMemberUserId, isAll, initialConfig } = - filter; - const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll); - const [startDate, endDate] = dateRange; - - const { selectedTeamId: teamId } = filter; + const { startDate, endDate, teamId, userId, isAll, memberUserId, eventTypeId } = useInsightsParameters(); const { data, isSuccess, isPending } = trpc.viewer.insights.eventsByStatus.useQuery( { - startDate: startDate.toISOString(), - endDate: endDate.toISOString(), + startDate, + endDate, teamId, - eventTypeId: selectedEventTypeId ?? undefined, - memberUserId: selectedMemberUserId ?? undefined, - userId: selectedUserId ?? undefined, + eventTypeId, + memberUserId, + userId, isAll, }, { @@ -34,7 +27,6 @@ export const BookingKPICards = () => { trpc: { context: { skipBatch: true }, }, - enabled: initialConfigIsReady, } ); @@ -88,7 +80,7 @@ export const BookingKPICards = () => { return ; } - if (!isSuccess || !startDate || !endDate || (!teamId && !selectedUserId)) return null; + if (!isSuccess || !data) return null; return ( <> diff --git a/packages/features/insights/components/BookingStatusBadge.tsx b/packages/features/insights/components/BookingStatusBadge.tsx new file mode 100644 index 0000000000..3aab3756ec --- /dev/null +++ b/packages/features/insights/components/BookingStatusBadge.tsx @@ -0,0 +1,21 @@ +import { BookingStatus } from "@calcom/prisma/enums"; +import { Badge, type BadgeProps } from "@calcom/ui"; + +import { bookingStatusToText } from "../lib/bookingStatusToText"; + +export function BookingStatusBadge({ bookingStatus }: { bookingStatus: BookingStatus | null }) { + let badgeVariant: BadgeProps["variant"] = "success"; + + if (!bookingStatus) return null; + + switch (bookingStatus) { + case BookingStatus.REJECTED: + case BookingStatus.AWAITING_HOST: + case BookingStatus.PENDING: + case BookingStatus.CANCELLED: + badgeVariant = "warning"; + break; + } + + return {bookingStatusToText(bookingStatus)}; +} diff --git a/packages/features/insights/components/BookingStatusLineChart.tsx b/packages/features/insights/components/BookingStatusLineChart.tsx index f2e6e386a6..215fdf8b28 100644 --- a/packages/features/insights/components/BookingStatusLineChart.tsx +++ b/packages/features/insights/components/BookingStatusLineChart.tsx @@ -1,10 +1,10 @@ import { Title } from "@tremor/react"; +import { useMemo } from "react"; -import dayjs from "@calcom/dayjs"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { useFilterContext } from "../context/provider"; +import { useInsightsParameters } from "../hooks/useInsightsParameters"; import { valueFormatter } from "../lib/valueFormatter"; import { CardInsights } from "./Card"; import { LineChart } from "./LineChart"; @@ -12,20 +12,15 @@ import { LoadingInsight } from "./LoadingInsights"; export const BookingStatusLineChart = () => { const { t } = useLocale(); - const { filter } = useFilterContext(); - const { - selectedTeamId, - selectedUserId, - selectedTimeView = "week", - dateRange, - selectedEventTypeId, - isAll, - initialConfig, - } = filter; - const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll); - const [startDate, endDate] = dateRange; + const { isAll, teamId, userId, startDate, endDate, dateRangePreset, eventTypeId } = useInsightsParameters(); - if (!startDate || !endDate) return null; + const selectedTimeView = useMemo(() => { + if (dateRangePreset === "tdy") return "day"; + else if (dateRangePreset === "w") return "week"; + else if (dateRangePreset === "m") return "month"; + else if (dateRangePreset === "y") return "year"; + else return "week"; + }, [dateRangePreset]); const { data: eventsTimeLine, @@ -34,11 +29,11 @@ export const BookingStatusLineChart = () => { } = trpc.viewer.insights.eventsTimeline.useQuery( { timeView: selectedTimeView, - startDate: dayjs.utc(startDate).toISOString(), - endDate: dayjs.utc(endDate).toISOString(), - teamId: selectedTeamId ?? undefined, - eventTypeId: selectedEventTypeId ?? undefined, - userId: selectedUserId ?? undefined, + startDate, + endDate, + teamId, + eventTypeId, + userId, isAll, }, { @@ -46,7 +41,6 @@ export const BookingStatusLineChart = () => { trpc: { context: { skipBatch: true }, }, - enabled: initialConfigIsReady, } ); diff --git a/packages/features/insights/components/CellWithOverflowX.tsx b/packages/features/insights/components/CellWithOverflowX.tsx new file mode 100644 index 0000000000..a98b8ee996 --- /dev/null +++ b/packages/features/insights/components/CellWithOverflowX.tsx @@ -0,0 +1,31 @@ +import classNames from "@calcom/lib/classNames"; + +export function CellWithOverflowX({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+
{ + // Only add shadow if the scroll width is greater than 200px + if (!el) return; + const nextElement = el.nextElementSibling; + if (!nextElement) return; + + if (el.scrollWidth > 200) { + nextElement.classList.remove("hidden"); + } else { + nextElement.classList.add("hidden"); + } + }}> + {children} +
+
+
+ ); +} diff --git a/packages/features/insights/components/HighestNoShowHostTable.tsx b/packages/features/insights/components/HighestNoShowHostTable.tsx index 72443de160..23774c52de 100644 --- a/packages/features/insights/components/HighestNoShowHostTable.tsx +++ b/packages/features/insights/components/HighestNoShowHostTable.tsx @@ -3,24 +3,21 @@ import { Title } from "@tremor/react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { useFilterContext } from "../context/provider"; +import { useInsightsParameters } from "../hooks/useInsightsParameters"; import { CardInsights } from "./Card"; import { LoadingInsight } from "./LoadingInsights"; import { TotalUserFeedbackTable } from "./TotalUserFeedbackTable"; export const HighestNoShowHostTable = () => { const { t } = useLocale(); - const { filter } = useFilterContext(); - const { dateRange, selectedEventTypeId, isAll, initialConfig } = filter; - const [startDate, endDate] = dateRange; - const { selectedTeamId: teamId } = filter; + const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters(); const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostNoShow.useQuery( { - startDate: startDate.toISOString(), - endDate: endDate.toISOString(), + startDate, + endDate, teamId, - eventTypeId: selectedEventTypeId ?? undefined, + eventTypeId, isAll, }, { @@ -28,13 +25,12 @@ export const HighestNoShowHostTable = () => { trpc: { context: { skipBatch: true }, }, - enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll), } ); if (isPending) return ; - if (!isSuccess || !startDate || !endDate || !teamId) return null; + if (!isSuccess || !data) return null; return data && data.length > 0 ? ( diff --git a/packages/features/insights/components/HighestRatedMembersTable.tsx b/packages/features/insights/components/HighestRatedMembersTable.tsx index 7a1ce1bae5..926910eb53 100644 --- a/packages/features/insights/components/HighestRatedMembersTable.tsx +++ b/packages/features/insights/components/HighestRatedMembersTable.tsx @@ -3,24 +3,21 @@ import { Title } from "@tremor/react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { useFilterContext } from "../context/provider"; +import { useInsightsParameters } from "../hooks/useInsightsParameters"; import { CardInsights } from "./Card"; import { LoadingInsight } from "./LoadingInsights"; import { TotalUserFeedbackTable } from "./TotalUserFeedbackTable"; export const HighestRatedMembersTable = () => { const { t } = useLocale(); - const { filter } = useFilterContext(); - const { dateRange, selectedEventTypeId, isAll, initialConfig } = filter; - const [startDate, endDate] = dateRange; - const { selectedTeamId: teamId } = filter; + const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters(); const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithHighestRatings.useQuery( { - startDate: startDate.toISOString(), - endDate: endDate.toISOString(), + startDate, + endDate, teamId, - eventTypeId: selectedEventTypeId ?? undefined, + eventTypeId, isAll, }, { @@ -28,13 +25,12 @@ export const HighestRatedMembersTable = () => { trpc: { context: { skipBatch: true }, }, - enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll), } ); if (isPending) return ; - if (!isSuccess || !startDate || !endDate || !teamId) return null; + if (!isSuccess || !data) return null; return data && data.length > 0 ? ( diff --git a/packages/features/insights/components/LeastBookedTeamMembersTable.tsx b/packages/features/insights/components/LeastBookedTeamMembersTable.tsx index 92b9b76f9b..af1a20cee9 100644 --- a/packages/features/insights/components/LeastBookedTeamMembersTable.tsx +++ b/packages/features/insights/components/LeastBookedTeamMembersTable.tsx @@ -3,23 +3,21 @@ import { Title } from "@tremor/react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { useFilterContext } from "../context/provider"; +import { useInsightsParameters } from "../hooks/useInsightsParameters"; import { CardInsights } from "./Card"; import { LoadingInsight } from "./LoadingInsights"; import { TotalBookingUsersTable } from "./TotalBookingUsersTable"; export const LeastBookedTeamMembersTable = () => { const { t } = useLocale(); - const { filter } = useFilterContext(); - const { dateRange, selectedEventTypeId, selectedTeamId: teamId, isAll, initialConfig } = filter; - const [startDate, endDate] = dateRange; + const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters(); const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithLeastBookings.useQuery( { - startDate: startDate.toISOString(), - endDate: endDate.toISOString(), + startDate, + endDate, teamId, - eventTypeId: selectedEventTypeId ?? undefined, + eventTypeId, isAll, }, { @@ -27,13 +25,12 @@ export const LeastBookedTeamMembersTable = () => { trpc: { context: { skipBatch: true }, }, - enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll), } ); if (isPending) return ; - if (!isSuccess || !startDate || !endDate || !teamId) return null; + if (!isSuccess || !data) return null; return ( diff --git a/packages/features/insights/components/LowestRatedMembersTable.tsx b/packages/features/insights/components/LowestRatedMembersTable.tsx index 652b8d603b..615f24523b 100644 --- a/packages/features/insights/components/LowestRatedMembersTable.tsx +++ b/packages/features/insights/components/LowestRatedMembersTable.tsx @@ -3,24 +3,21 @@ import { Title } from "@tremor/react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { useFilterContext } from "../context/provider"; +import { useInsightsParameters } from "../hooks/useInsightsParameters"; import { CardInsights } from "./Card"; import { LoadingInsight } from "./LoadingInsights"; import { TotalUserFeedbackTable } from "./TotalUserFeedbackTable"; export const LowestRatedMembersTable = () => { const { t } = useLocale(); - const { filter } = useFilterContext(); - const { dateRange, selectedEventTypeId, isAll, initialConfig } = filter; - const [startDate, endDate] = dateRange; - const { selectedTeamId: teamId } = filter; + const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters(); const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithLowestRatings.useQuery( { - startDate: startDate.toISOString(), - endDate: endDate.toISOString(), + startDate, + endDate, teamId, - eventTypeId: selectedEventTypeId ?? undefined, + eventTypeId, isAll, }, { @@ -28,13 +25,12 @@ export const LowestRatedMembersTable = () => { trpc: { context: { skipBatch: true }, }, - enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll), } ); if (isPending) return ; - if (!isSuccess || !startDate || !endDate || !teamId) return null; + if (!isSuccess || !data) return null; return data && data.length > 0 ? ( diff --git a/packages/features/insights/components/MostBookedTeamMembersTable.tsx b/packages/features/insights/components/MostBookedTeamMembersTable.tsx index a9e37db58b..5e7d99eb07 100644 --- a/packages/features/insights/components/MostBookedTeamMembersTable.tsx +++ b/packages/features/insights/components/MostBookedTeamMembersTable.tsx @@ -3,24 +3,21 @@ import { Title } from "@tremor/react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { useFilterContext } from "../context/provider"; +import { useInsightsParameters } from "../hooks/useInsightsParameters"; import { CardInsights } from "./Card"; import { LoadingInsight } from "./LoadingInsights"; import { TotalBookingUsersTable } from "./TotalBookingUsersTable"; export const MostBookedTeamMembersTable = () => { const { t } = useLocale(); - const { filter } = useFilterContext(); - const { dateRange, selectedEventTypeId, isAll, initialConfig } = filter; - const [startDate, endDate] = dateRange; - const { selectedTeamId: teamId } = filter; + const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters(); const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostBookings.useQuery( { - startDate: startDate.toISOString(), - endDate: endDate.toISOString(), + startDate, + endDate, teamId, - eventTypeId: selectedEventTypeId ?? undefined, + eventTypeId, isAll, }, { @@ -28,13 +25,12 @@ export const MostBookedTeamMembersTable = () => { trpc: { context: { skipBatch: true }, }, - enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll), } ); if (isPending) return ; - if (!isSuccess || !startDate || !endDate || !teamId) return null; + if (!isSuccess || !data) return null; return ( diff --git a/packages/features/insights/components/PopularEventsTable.tsx b/packages/features/insights/components/PopularEventsTable.tsx index 59f6b23255..1e4e3b3546 100644 --- a/packages/features/insights/components/PopularEventsTable.tsx +++ b/packages/features/insights/components/PopularEventsTable.tsx @@ -3,26 +3,22 @@ import { Table, TableBody, TableCell, TableRow, Text, Title } from "@tremor/reac import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { useFilterContext } from "../context/provider"; +import { useInsightsParameters } from "../hooks/useInsightsParameters"; import { CardInsights } from "./Card"; import { LoadingInsight } from "./LoadingInsights"; export const PopularEventsTable = () => { const { t } = useLocale(); - const { filter } = useFilterContext(); - const { dateRange, selectedMemberUserId, selectedUserId, isAll, initialConfig, selectedEventTypeId } = - filter; - const [startDate, endDate] = dateRange; - const { selectedTeamId: teamId } = filter; + const { isAll, teamId, userId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters(); const { data, isSuccess, isPending } = trpc.viewer.insights.popularEventTypes.useQuery( { - startDate: startDate.toISOString(), - endDate: endDate.toISOString(), - teamId: teamId ?? undefined, - userId: selectedUserId ?? undefined, - eventTypeId: selectedEventTypeId ?? undefined, - memberUserId: selectedMemberUserId ?? undefined, + startDate, + endDate, + teamId, + userId, + eventTypeId, + memberUserId, isAll, }, { @@ -30,13 +26,12 @@ export const PopularEventsTable = () => { trpc: { context: { skipBatch: true }, }, - enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll), } ); if (isPending) return ; - if (!isSuccess || !startDate || !endDate || (!teamId && !selectedUserId)) return null; + if (!isSuccess || !data) return null; return ( diff --git a/packages/features/insights/components/RecentFeedbackTable.tsx b/packages/features/insights/components/RecentFeedbackTable.tsx index 969267ef0e..0c5c0c1282 100644 --- a/packages/features/insights/components/RecentFeedbackTable.tsx +++ b/packages/features/insights/components/RecentFeedbackTable.tsx @@ -3,23 +3,21 @@ import { Title } from "@tremor/react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { useFilterContext } from "../context/provider"; +import { useInsightsParameters } from "../hooks/useInsightsParameters"; import { CardInsights } from "./Card"; import { FeedbackTable } from "./FeedbackTable"; import { LoadingInsight } from "./LoadingInsights"; export const RecentFeedbackTable = () => { const { t } = useLocale(); - const { filter } = useFilterContext(); - const { dateRange, selectedEventTypeId, selectedTeamId: teamId, isAll, initialConfig } = filter; - const [startDate, endDate] = dateRange; + const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters(); const { data, isSuccess, isPending } = trpc.viewer.insights.recentRatings.useQuery( { - startDate: startDate.toISOString(), - endDate: endDate.toISOString(), + startDate, + endDate, teamId, - eventTypeId: selectedEventTypeId ?? undefined, + eventTypeId, isAll, }, { @@ -27,13 +25,12 @@ export const RecentFeedbackTable = () => { trpc: { context: { skipBatch: true }, }, - enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll), } ); if (isPending) return ; - if (!isSuccess || !startDate || !endDate || !teamId) return null; + if (!isSuccess || !data) return null; return ( diff --git a/packages/features/insights/components/ResponseValueCell.tsx b/packages/features/insights/components/ResponseValueCell.tsx new file mode 100644 index 0000000000..d111f814ff --- /dev/null +++ b/packages/features/insights/components/ResponseValueCell.tsx @@ -0,0 +1,54 @@ +import { useId } from "react"; + +import { Badge, HoverCard, HoverCardContent, HoverCardTrigger, HoverCardPortal } from "@calcom/ui"; + +import { CellWithOverflowX } from "./CellWithOverflowX"; + +export function ResponseValueCell({ + optionMap, + values, + rowId, +}: { + optionMap: Record; + values: string[]; + rowId: number; +}) { + const cellId = useId(); + if (values.length === 0) return
; + + return ( + + {values.length > 2 ? ( + <> + {values.slice(0, 2).map((id: string, i: number) => ( + + {optionMap[id] ?? id} + + ))} + + + +{values.length - 2} + + + +
+ {values.slice(2).map((id: string, i: number) => ( + + {optionMap[id] ?? id} + + ))} +
+
+
+
+ + ) : ( + values.map((id: string, i: number) => ( + + {optionMap[id] ?? id} + + )) + )} +
+ ); +} diff --git a/packages/features/insights/components/RoutingFormResponsesTable.tsx b/packages/features/insights/components/RoutingFormResponsesTable.tsx index a68ffad909..21243eb4e4 100644 --- a/packages/features/insights/components/RoutingFormResponsesTable.tsx +++ b/packages/features/insights/components/RoutingFormResponsesTable.tsx @@ -6,243 +6,29 @@ import { getCoreRowModel, getFilteredRowModel, getSortedRowModel, - createColumnHelper, } from "@tanstack/react-table"; // eslint-disable-next-line no-restricted-imports -import startCase from "lodash/startCase"; -import Link from "next/link"; -import { useMemo, useId } from "react"; -import { z } from "zod"; +import { useMemo } from "react"; -import dayjs from "@calcom/dayjs"; import { DataTableWrapper, DataTableFilters, DataTableSkeleton, - dataTableFilter, useDataTable, DateRangeFilter, ColumnFilterType, type FilterableColumn, } from "@calcom/features/data-table"; -import classNames from "@calcom/lib/classNames"; -import { useCopy } from "@calcom/lib/hooks/useCopy"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { BookingStatus } from "@calcom/prisma/enums"; -import { RoutingFormFieldType } from "@calcom/routing-forms/lib/FieldTypes"; -import { trpc, type RouterOutputs } from "@calcom/trpc"; -import { - Badge, - Avatar, - Icon, - HoverCard, - HoverCardContent, - HoverCardTrigger, - type BadgeProps, - HoverCardPortal, -} from "@calcom/ui"; +import { trpc } from "@calcom/trpc"; import { RoutingFormResponsesDownload } from "../filters/Download"; import { OrgTeamsFilter } from "../filters/OrgTeamsFilter"; +import { useInsightsColumns } from "../hooks/useInsightsColumns"; +import { useInsightsFacetedUniqueValues } from "../hooks/useInsightsFacetedUniqueValues"; import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { - ZResponseMultipleValues, - ZResponseSingleValue, - ZResponseTextValue, - ZResponseNumericValue, -} from "../lib/types"; +import type { RoutingFormTableRow } from "../lib/types"; import { RoutingKPICards } from "./RoutingKPICards"; -type RoutingFormTableRow = RouterOutputs["viewer"]["insights"]["routingFormResponses"]["data"][number]; - -const statusOrder: Record = { - [BookingStatus.ACCEPTED]: 1, - [BookingStatus.PENDING]: 2, - [BookingStatus.AWAITING_HOST]: 3, - [BookingStatus.CANCELLED]: 4, - [BookingStatus.REJECTED]: 5, -}; - -function CellWithOverflowX({ children, className }: { children: React.ReactNode; className?: string }) { - return ( -
-
{ - // Only add shadow if the scroll width is greater than 200px - if (!el) return; - const nextElement = el.nextElementSibling; - if (!nextElement) return; - - if (el.scrollWidth > 200) { - nextElement.classList.remove("hidden"); - } else { - nextElement.classList.add("hidden"); - } - }}> - {children} -
-
-
- ); -} - -// Upper case the first letter of each word and replace underscores with spaces -function bookingStatusToText(status: BookingStatus) { - return status - .toLowerCase() - .replace(/_/g, " ") - .replace(/\b\w/g, (c) => c.toUpperCase()); -} - -function BookedByCell({ - attendees, - rowId, -}: { - attendees: RoutingFormTableRow["bookingAttendees"] | undefined; - rowId: number; -}) { - const cellId = useId(); - if (!attendees || attendees.length === 0) return
; - - return ( -
- {attendees.map((attendee) => ( - - - {attendee.name} - - - ))} -
- ); -} - -function ResponseValueCell({ - optionMap, - values, - rowId, -}: { - optionMap: Record; - values: string[]; - rowId: number; -}) { - const cellId = useId(); - if (values.length === 0) return
; - - return ( - - {values.length > 2 ? ( - <> - {values.slice(0, 2).map((id: string, i: number) => ( - - {optionMap[id] ?? id} - - ))} - - - +{values.length - 2} - - - -
- {values.slice(2).map((id: string, i: number) => ( - - {optionMap[id] ?? id} - - ))} -
-
-
-
- - ) : ( - values.map((id: string, i: number) => ( - - {optionMap[id] ?? id} - - )) - )} -
- ); -} - -function BookingStatusBadge({ bookingStatus }: { bookingStatus: BookingStatus | null }) { - let badgeVariant: BadgeProps["variant"] = "success"; - - if (!bookingStatus) return null; - - switch (bookingStatus) { - case BookingStatus.REJECTED: - case BookingStatus.AWAITING_HOST: - case BookingStatus.PENDING: - case BookingStatus.CANCELLED: - badgeVariant = "warning"; - break; - } - - return {bookingStatusToText(bookingStatus)}; -} - -function BookingAtCell({ - row, - rowId, - copyToClipboard, - t, -}: { - row: RoutingFormTableRow; - rowId: number; - copyToClipboard: (text: string) => void; - t: (key: string) => string; -}) { - const cellId = useId(); - - if (!row.bookingUserId || !row.bookingCreatedAt) { - return
; - } - - return ( - - -
- - - {dayjs(row.bookingCreatedAt).format("MMM D, YYYY HH:mm")} - -
-
- - -
-
- -
-

{row.bookingUserName}

-

- {row.bookingUserEmail} - -

-
-
-
- Status: - -
-
-
-
-
- ); -} - export type RoutingFormTableType = ReturnType>; const createdAtColumn: Extract = { @@ -252,8 +38,6 @@ const createdAtColumn: Extract page.data) ?? []) as RoutingFormTableRow[]; }, [data, isHeadersSuccess]); - const columnHelper = createColumnHelper(); - - const columns = useMemo(() => { - if (!isHeadersSuccess) { - return []; - } - return [ - columnHelper.accessor("formId", { - id: "formId", - header: t("routing_forms"), - enableColumnFilter: true, - enableSorting: false, - meta: { - filter: { type: ColumnFilterType.SINGLE_SELECT }, - }, - cell: () => null, - filterFn: (row, id, filterValue) => { - const cellValue = row.original.formId; - return dataTableFilter(cellValue, filterValue); - }, - }), - columnHelper.accessor("bookingUserId", { - id: "bookingUserId", - header: t("people"), - enableColumnFilter: true, - enableSorting: false, - meta: { - filter: { type: ColumnFilterType.MULTI_SELECT }, - }, - cell: () => null, - filterFn: (row, id, filterValue) => { - const cellValue = row.original.bookingUserId; - return dataTableFilter(cellValue, filterValue); - }, - }), - columnHelper.accessor("bookingAttendees", { - id: "bookingAttendees", - header: t("routing_form_insights_booked_by"), - size: 200, - enableColumnFilter: false, - enableSorting: false, - cell: (info) => { - return ; - }, - }), - - ...((headers || []).map((fieldHeader) => { - const isText = [ - RoutingFormFieldType.TEXT, - RoutingFormFieldType.EMAIL, - RoutingFormFieldType.PHONE, - RoutingFormFieldType.TEXTAREA, - ].includes(fieldHeader.type as RoutingFormFieldType); - - const isNumber = fieldHeader.type === RoutingFormFieldType.NUMBER; - - const isSingleSelect = fieldHeader.type === RoutingFormFieldType.SINGLE_SELECT; - const isMultiSelect = fieldHeader.type === RoutingFormFieldType.MULTI_SELECT; - - const filterType = isSingleSelect - ? ColumnFilterType.SINGLE_SELECT - : isNumber - ? ColumnFilterType.NUMBER - : isText - ? ColumnFilterType.TEXT - : ColumnFilterType.MULTI_SELECT; - - const optionMap = - fieldHeader.options?.reduce((acc, option) => { - if (option.id) { - acc[option.id] = option.label; - } - return acc; - }, {} as Record) ?? {}; - - return columnHelper.accessor(`response.${fieldHeader.id}`, { - id: fieldHeader.id, - header: startCase(fieldHeader.label), - enableSorting: false, - cell: (info) => { - const values = info.getValue(); - if (isMultiSelect || isSingleSelect) { - const result = z.union([ZResponseMultipleValues, ZResponseSingleValue]).safeParse(values); - return ( - result.success && ( - - ) - ); - } else if (isText || isNumber) { - const result = z.union([ZResponseTextValue, ZResponseNumericValue]).safeParse(values); - return ( - result.success && ( -
- {result.data.value} -
- ) - ); - } else { - return null; - } - }, - meta: { - filter: { type: filterType }, - }, - filterFn: (row, id, filterValue) => { - const cellValue = row.original.response[id]?.value; - return dataTableFilter(cellValue, filterValue); - }, - }); - }) ?? []), - columnHelper.accessor("bookingStatusOrder", { - id: "bookingStatusOrder", - header: t("routing_form_insights_booking_status"), - sortDescFirst: false, - cell: (info) => ( -
- -
- ), - meta: { - filter: { type: ColumnFilterType.MULTI_SELECT, icon: "circle" }, - }, - filterFn: (row, id, filterValue) => { - const cellValue = row.original.bookingStatusOrder; - return dataTableFilter(cellValue, filterValue); - }, - sortingFn: (rowA, rowB) => { - const statusA = rowA.original.bookingStatusOrder ?? 6; // put it at the end if bookingStatusOrder is null - const statusB = rowB.original.bookingStatusOrder ?? 6; - return statusA - statusB; - }, - }), - columnHelper.accessor("bookingCreatedAt", { - id: "bookingCreatedAt", - header: t("routing_form_insights_booking_at"), - enableColumnFilter: false, - cell: (info) => ( -
- -
- ), - sortingFn: (rowA, rowB) => { - const dateA = rowA.original.bookingCreatedAt; - const dateB = rowB.original.bookingCreatedAt; - if (!dateA && !dateB) return 0; - if (!dateA) return -1; - if (!dateB) return 1; - if (!(dateA instanceof Date) || !(dateB instanceof Date)) return 0; - - return dateA.getTime() - dateB.getTime(); - }, - }), - columnHelper.accessor("bookingAssignmentReason", { - id: "bookingAssignmentReason", - header: t("routing_form_insights_assignment_reason"), - enableColumnFilter: true, - enableSorting: false, - meta: { - filter: { type: ColumnFilterType.TEXT }, - }, - cell: (info) => { - const assignmentReason = info.getValue(); - return
{assignmentReason}
; - }, - filterFn: (row, id, filterValue) => { - const reason = row.original.bookingAssignmentReason; - return dataTableFilter(reason, filterValue); - }, - }), - columnHelper.accessor("createdAt", { - id: "createdAt", - header: t("routing_form_insights_submitted_at"), - // exclude from "Filters" component - // because we already have a DateRangeFilter component - enableColumnFilter: false, - cell: (info) => ( -
- {dayjs(info.getValue()).format("MMM D, YYYY HH:mm")} -
- ), - filterFn: (row, id, filterValue) => { - const createdAt = row.original.createdAt; - return dataTableFilter(createdAt, filterValue); - }, - }), - ]; - }, [isHeadersSuccess, headers, t, copyToClipboard]); + const columns = useInsightsColumns({ headers, isHeadersSuccess }); const table = useReactTable({ data: processedData, @@ -521,49 +101,7 @@ export function RoutingFormResponsesTable() { bookingUserId: false, }, }, - getFacetedUniqueValues: (_, columnId) => () => { - if (!headers) { - return new Map(); - } - - const fromArrayToMap = (array: { label: string; value: string | number }[]) => { - return new Map(array.map((option) => [{ label: option.label, value: option.value }, 1])); - }; - - const fieldHeader = headers.find((h) => h.id === columnId); - if (fieldHeader?.options) { - return fromArrayToMap( - fieldHeader.options - .filter((option): option is { id: string; label: string } => option.id !== null) - .map((option) => ({ - label: option.label, - value: option.id, - })) - ); - } else if (columnId === "bookingStatusOrder") { - return fromArrayToMap( - Object.keys(statusOrder).map((status) => ({ - value: statusOrder[status as BookingStatus], - label: bookingStatusToText(status as BookingStatus), - })) - ); - } else if (columnId === "formId") { - return fromArrayToMap( - forms?.map((form) => ({ - label: form.name, - value: form.id, - })) ?? [] - ); - } else if (columnId === "bookingUserId") { - return fromArrayToMap( - users?.map((user) => ({ - label: user.name ?? user.email, - value: user.id, - })) ?? [] - ); - } - return new Map(); - }, + getFacetedUniqueValues: getInsightsFacetedUniqueValues, }); if ((isHeadersLoading && !headers) || ((isFetching || isLoading) && !data)) { diff --git a/packages/features/insights/filters/DateSelect.css b/packages/features/insights/components/tremor.css similarity index 99% rename from packages/features/insights/filters/DateSelect.css rename to packages/features/insights/components/tremor.css index 6512dbf96b..f8b5c30746 100644 --- a/packages/features/insights/filters/DateSelect.css +++ b/packages/features/insights/components/tremor.css @@ -104,4 +104,4 @@ .tremor-TableBody-root { @apply divide-subtle; -} +} \ No newline at end of file diff --git a/packages/features/insights/context/FiltersProvider.tsx b/packages/features/insights/context/FiltersProvider.tsx deleted file mode 100644 index 440edd31cd..0000000000 --- a/packages/features/insights/context/FiltersProvider.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import { usePathname, useRouter } from "next/navigation"; -import { useState } from "react"; -import { z } from "zod"; - -import dayjs from "@calcom/dayjs"; -import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; -import { BookingStatus } from "@calcom/prisma/enums"; -import { trpc } from "@calcom/trpc"; - -import { useDefaultRoutingForm } from "../hooks/useDefaultRoutingForm"; -import type { FilterContextType } from "./provider"; -import { FilterProvider } from "./provider"; - -const querySchema = z.object({ - startTime: z.string().nullable(), - endTime: z.string().nullable(), - teamId: z.coerce.number().nullable(), - userId: z.coerce.number().nullable(), - memberUserId: z.coerce.number().nullable(), - eventTypeId: z.coerce.number().nullable(), - filter: z - .union([z.enum(["event-type", "user", "routing_forms", "booking_status"]), z.string().regex(/^rf_.*$/)]) - .nullable(), - routingFormId: z.string().nullable(), - bookingStatus: z.enum(["NO_BOOKING", ...Object.values(BookingStatus)]).nullable(), -}); - -// TODO(SEAN): We can have a big refactor here and move this all out of context and into query state. This will make it easier to create shareable links -export function FiltersProvider({ children }: { children: React.ReactNode }) { - // searchParams to get initial values from query params - const utils = trpc.useUtils(); - const searchParams = useCompatSearchParams(); - const router = useRouter(); - const pathname = usePathname(); - - let startTimeParsed, - endTimeParsed, - teamIdParsed: number | undefined | null, - userIdParsed, - eventTypeIdParsed, - filterParsed, - routingFormIdParsed, - bookingStatusParsed, - memberUserIdParsed; - - const safe = querySchema.safeParse({ - startTime: searchParams?.get("startTime") ?? null, - endTime: searchParams?.get("endTime") ?? null, - teamId: searchParams?.get("teamId") ?? null, - userId: searchParams?.get("userId") ?? null, - eventTypeId: searchParams?.get("eventTypeId") ?? null, - filter: searchParams?.get("filter") ?? null, - memberUserId: searchParams?.get("memberUserId") ?? null, - routingFormId: searchParams?.get("routingFormId") ?? null, - bookingStatus: searchParams?.get("bookingStatus") ?? null, - }); - - if (!safe.success) { - console.error("Failed to parse query params"); - } else { - startTimeParsed = safe.data.startTime; - endTimeParsed = safe.data.endTime; - teamIdParsed = safe.data.teamId; - userIdParsed = safe.data.userId; - eventTypeIdParsed = safe.data.eventTypeId; - filterParsed = safe.data.filter; - routingFormIdParsed = safe.data.routingFormId; - memberUserIdParsed = safe.data.memberUserId; - bookingStatusParsed = safe.data.bookingStatus; - } - - const [configFilters, setConfigFilters] = useState({ - dateRange: [ - startTimeParsed ? dayjs(startTimeParsed) : dayjs().subtract(1, "week"), - endTimeParsed ? dayjs(endTimeParsed) : dayjs(), - !startTimeParsed && !endTimeParsed ? "w" : null, - ], - selectedTimeView: "week", - selectedUserId: userIdParsed || null, - selectedMemberUserId: memberUserIdParsed || null, - selectedTeamId: teamIdParsed || null, - selectedTeamName: null, - selectedEventTypeId: eventTypeIdParsed || null, - selectedFilter: filterParsed - ? [filterParsed as "event-type" | "user" | "routing_forms" | `rf_${string}`] - : null, - selectedRoutingFormId: routingFormIdParsed || null, - selectedBookingStatus: bookingStatusParsed || null, - isAll: false, - initialConfig: { - userId: null, - teamId: null, - isAll: null, - }, - }); - - // Use the custom hook - const { mostPopularForm } = useDefaultRoutingForm({ - userId: userIdParsed, - teamId: teamIdParsed, - isAll: safe.success ? !!safe.data.teamId : false, - routingFormId: routingFormIdParsed, - onRoutingFormChange: (formId) => { - setConfigFilters((prev) => ({ - ...prev, - selectedFilter: prev.selectedFilter ? [...prev.selectedFilter, "routing_forms"] : ["routing_forms"], - selectedRoutingFormId: formId, - })); - }, - }); - - const { - dateRange, - selectedTimeView, - selectedMemberUserId, - selectedTeamId, - selectedUserId, - selectedEventTypeId, - selectedFilter, - selectedTeamName, - selectedRoutingFormId, - selectedBookingStatus, - selectedRoutingFormFilter, - isAll, - initialConfig, - } = configFilters; - return ( - { - setConfigFilters({ - ...configFilters, - ...newConfigFilters, - }); - utils.viewer.insights.rawData.invalidate(); - const { - selectedMemberUserId, - selectedTeamId, - selectedUserId, - selectedEventTypeId, - selectedFilter, - isAll, - dateRange, - initialConfig, - selectedRoutingFormId, - selectedBookingStatus, - } = newConfigFilters; - const [startTime, endTime] = dateRange || [null, null]; - const newSearchParams = new URLSearchParams(searchParams?.toString() ?? undefined); - function setParamsIfDefined(key: string, value: string | number | boolean | null | undefined) { - if (value !== undefined && value !== null) newSearchParams.set(key, value.toString()); - } - - setParamsIfDefined("memberUserId", selectedMemberUserId); - setParamsIfDefined("teamId", selectedTeamId || initialConfig?.teamId); - setParamsIfDefined("userId", selectedUserId || initialConfig?.userId); - setParamsIfDefined("eventTypeId", selectedEventTypeId); - setParamsIfDefined("isAll", isAll || initialConfig?.isAll); - setParamsIfDefined("startTime", startTime?.format("YYYY-MM-DD")); - setParamsIfDefined("endTime", endTime?.format("YYYY-MM-DD")); - setParamsIfDefined("filter", selectedFilter?.[0]); - setParamsIfDefined("routingFormId", selectedRoutingFormId); - setParamsIfDefined("bookingStatus", selectedBookingStatus); - router.push(`${pathname}?${newSearchParams.toString()}`); - }, - clearFilters: () => { - const { initialConfig } = configFilters; - - const teamId = initialConfig?.teamId ? initialConfig.teamId : undefined; - const userId = initialConfig?.userId ? initialConfig.userId : undefined; - setConfigFilters({ - selectedEventTypeId: null, - selectedFilter: null, - selectedMemberUserId: null, - selectedTeamId: teamId, - selectedTeamName: null, - selectedTimeView: "week", - selectedUserId: userId, - isAll: !!initialConfig?.isAll, - dateRange: [dayjs().subtract(1, "week"), dayjs(), "w"], - initialConfig, - selectedRoutingFormId: mostPopularForm?.id ?? null, // Set the most popular form as default when clearing filters - selectedBookingStatus: null, - selectedRoutingFormFilter: null, - }); - - const newSearchParams = new URLSearchParams(); - if (teamId) newSearchParams.set("teamId", teamId.toString()); - if (userId) newSearchParams.set("userId", userId.toString()); - - router.push(`${pathname}?${newSearchParams.toString()}`); - }, - }}> - {children} - - ); -} diff --git a/packages/features/insights/context/provider.ts b/packages/features/insights/context/provider.ts deleted file mode 100644 index bf31124e87..0000000000 --- a/packages/features/insights/context/provider.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { BookingStatus } from "@prisma/client"; -import * as React from "react"; - -import type { Dayjs } from "@calcom/dayjs"; - -interface IFilter { - dateRange: [Dayjs, Dayjs, null | string]; - selectedTimeView?: "year" | "week" | "month"; - selectedFilter?: Array<"user" | "event-type" | "routing_forms" | "booking_status" | `rf_${string}`> | null; - selectedTeamId?: number | null; - selectedTeamName?: string | null; - selectedUserId?: number | null; - selectedMemberUserId?: number | null; - selectedEventTypeId?: number | null; - selectedRoutingFormId?: string | null; - selectedBookingStatus?: BookingStatus | "NO_BOOKING" | null; - selectedRoutingFormFilter?: { - fieldId: string; - optionId: string; - } | null; - isAll?: boolean; - initialConfig?: { - teamId?: number | null; - userId?: number | null; - isAll?: boolean | null; - }; -} - -export type FilterContextType = { - filter: IFilter; - clearFilters: () => void; - setConfigFilters: (config: Partial) => void; -}; - -export const FilterContext = React.createContext(null); - -export function useFilterContext() { - const context = React.useContext(FilterContext); - - if (!context) { - throw new Error("useFilterContext must be used within a FilterProvider"); - } - - return context; -} - -export function FilterProvider(props: { value: F; children: React.ReactNode }) { - return React.createElement(FilterContext.Provider, { value: props.value }, props.children); -} diff --git a/packages/features/insights/filters/BookingStatusFilter.tsx b/packages/features/insights/filters/BookingStatusFilter.tsx deleted file mode 100644 index dafdd92752..0000000000 --- a/packages/features/insights/filters/BookingStatusFilter.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useMemo } from "react"; - -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { BookingStatus } from "@calcom/prisma/enums"; -import { Icon, FilterSelect } from "@calcom/ui"; - -import { useFilterContext } from "../context/provider"; - -type BookingStatusOption = { - value: BookingStatus | "NO_BOOKING"; - label: string; -}; - -export const BookingStatusFilter = () => { - const { t } = useLocale(); - const { filter, setConfigFilters } = useFilterContext(); - const { selectedBookingStatus, selectedFilter } = filter; - - const bookingStatusOptions = useMemo(() => { - const options: BookingStatusOption[] = Object.values(BookingStatus).map((status) => ({ - value: status, - label: t(`${status.toLowerCase()}`).charAt(0).toUpperCase() + t(`${status.toLowerCase()}`).slice(1), - })); - - options.push({ - value: "NO_BOOKING", - label: t("no_booking"), - }); - - return options; - }, [t]); - - if (!selectedFilter?.includes("booking_status")) { - return null; - } - - return ( - { - setConfigFilters({ selectedBookingStatus: value as BookingStatus | "NO_BOOKING" }); - }} - buttonIcon={} - placeholder={t("search")} - /> - ); -}; diff --git a/packages/features/insights/filters/ClearFilters.tsx b/packages/features/insights/filters/ClearFilters.tsx deleted file mode 100644 index 03db580efa..0000000000 --- a/packages/features/insights/filters/ClearFilters.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useDataTable } from "@calcom/features/data-table"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { Icon, Button, Tooltip } from "@calcom/ui"; - -import { useFilterContext } from "../context/provider"; - -// This component clears filters from both: -// - the filter from FilterContext -// - the data table filters state -export const ClearFilters = () => { - const { t } = useLocale(); - const { clearFilters, filter } = useFilterContext(); - const { activeFilters, clearAll } = useDataTable(); - - const clear = () => { - // clear filters from the filter context - clearFilters(); - - // clear filters from the data table state - clearAll(); - }; - - const { initialConfig, selectedTeamId, selectedMemberUserId } = filter; - - const isFilterSelected = - initialConfig?.teamId !== selectedTeamId || - initialConfig?.userId !== selectedMemberUserId || - activeFilters?.length > 0; - - if (!isFilterSelected) { - return null; - } - - return ( - - - - ); -}; diff --git a/packages/features/insights/filters/DateSelect.tsx b/packages/features/insights/filters/DateSelect.tsx deleted file mode 100644 index 1f3169c296..0000000000 --- a/packages/features/insights/filters/DateSelect.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { useState } from "react"; - -import type { Dayjs } from "@calcom/dayjs"; -import dayjs from "@calcom/dayjs"; -import { classNames } from "@calcom/lib"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { DateRangePicker } from "@calcom/ui"; -import { Select } from "@calcom/ui"; - -import { useFilterContext } from "../context/provider"; -import "./DateSelect.css"; - -export const DateSelect = ({ className }: { className?: string }) => { - const { t } = useLocale(); - const presetOptions = [ - { label: t("today"), value: "tdy" }, - { label: t("last_number_of_days", { count: 7 }), value: "w" }, - { label: t("last_number_of_days", { count: 30 }), value: "t" }, - { label: t("month_to_date"), value: "m" }, - { label: t("year_to_date"), value: "y" }, - { label: t("custom_range"), value: null }, - ]; - - const { filter, setConfigFilters } = useFilterContext(); - const currentDate = dayjs(); - const [initialStartDate, initialEndDate, range] = filter?.dateRange || [null, null, null]; - const [startDate, setStartDate] = useState(initialStartDate); - const [endDate, setEndDate] = useState(initialEndDate); - const startValue = startDate.toDate(); - const endValue = endDate?.toDate(); - const [selectedPreset, setSelectedPreset] = useState(presetOptions.find((c) => c.value === range)); - - const updateDateRange = (val: string | null) => { - setSelectedPreset(presetOptions.find((c) => c.value === val)); - let startDate = dayjs(); - let endDate = dayjs(); - - switch (val) { - case "tdy": // Today - startDate = dayjs().startOf("day"); - endDate = dayjs().endOf("day"); - break; - case "w": // Last 7 days - startDate = dayjs().subtract(1, "week").startOf("day"); - endDate = dayjs().endOf("day"); - break; - case "t": // Last 30 days - startDate = dayjs().subtract(30, "day").startOf("day"); - endDate = dayjs().endOf("day"); - break; - case "m": // Month to Date - startDate = dayjs().startOf("month"); - endDate = dayjs().endOf("day"); - break; - case "y": // Year to Date - startDate = dayjs().startOf("year"); - endDate = dayjs().endOf("day"); - break; - default: - break; - } - // Update the datepicker date selection - setStartDate(startDate); - setEndDate(endDate); - // Update the configuration filter - setConfigFilters({ - dateRange: [dayjs(startDate), dayjs(endDate), val], - }); - }; - - return ( -
- { - if (startDate && endDate) { - setConfigFilters({ - dateRange: [dayjs(startDate), dayjs(endDate), null], - }); - } - setStartDate(dayjs(startDate)); - setEndDate(endDate ? dayjs(endDate) : endDate); - setSelectedPreset(presetOptions.find((c) => c.value === null)); - }} - /> -