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 <sldisek783@gmail.com>
This commit is contained in:
Eunjae Lee
2025-02-10 20:28:48 +01:00
committed by GitHub
co-authored by Benny Joo
parent 9bf3732717
commit 720136fcf9
55 changed files with 1037 additions and 1829 deletions
+45 -7
View File
@@ -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 (
<DataTableProvider>
<InsightsOrgTeamsProvider>
<InsightsPageContent />
</InsightsOrgTeamsProvider>
</DataTableProvider>
);
}
const createdAtColumn: Extract<FilterableColumn, { type: ColumnFilterType.DATE_RANGE }> = {
id: "createdAt",
title: "createdAt",
type: ColumnFilterType.DATE_RANGE,
};
function InsightsPageContent() {
const { t } = useLocale();
const { table } = useInsightsBookings();
const { isAll, teamId, userId } = useInsightsOrgTeams();
return (
<FiltersProvider>
<Filters />
<>
<div
className="flex flex-wrap items-center gap-2"
data-testid={`insights-filters-${isAll}-${teamId}-${userId}`}>
<OrgTeamsFilter />
<DataTableFilters.AddFilterButton table={table} />
<DataTableFilters.ActiveFilters table={table} />
<DataTableFilters.ClearFiltersButton exclude={["createdAt"]} />
<div className="grow" />
<Download />
<DateRangeFilter column={createdAtColumn} />
</div>
<div className="mb-4 space-y-4">
<div className="my-4 space-y-4">
<BookingKPICards />
<BookingStatusLineChart />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<PopularEventsTable />
<AverageEventDurationChart />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -53,6 +91,6 @@ export default function InsightsPage() {
</a>
</small>
</div>
</FiltersProvider>
</>
);
}
+55 -61
View File
@@ -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();
// <div class="flex select-none truncate font-medium" data-state="closed">People</div>
// 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 }) => {
@@ -63,7 +63,8 @@ function AddFilterButtonComponent<TData>(
<CommandItem
key={column.id}
onSelect={() => handleAddFilter(column.id)}
className="px-4 py-2">
className="px-4 py-2"
data-testid={`add-filter-item-${column.id}`}>
{startCase(column.title)}
</CommandItem>
);
@@ -13,6 +13,7 @@ export const ClearFiltersButton = ({ exclude }: { exclude?: string[] }) => {
return (
<Tooltip content={t("clear_filters")}>
<Button
data-testid="clear-filters-button"
variant="icon"
color="secondary"
target="_blank"
@@ -18,37 +18,22 @@ import {
} from "@calcom/ui";
import { useDataTable, useFilterValue } from "../../hooks";
import {
CUSTOM_PRESET,
CUSTOM_PRESET_VALUE,
DEFAULT_PRESET,
PRESET_OPTIONS,
getDefaultStartDate,
getDefaultEndDate,
type PresetOption,
} from "../../lib/dateRange";
import type { FilterableColumn } from "../../lib/types";
import { ZDateRangeFilterValue, ColumnFilterType } from "../../lib/types";
type PresetOption = {
labelKey: string;
i18nOptions?: Record<string, string | number>;
value: string;
};
type DateRangeFilterProps = {
column: Extract<FilterableColumn, { type: ColumnFilterType.DATE_RANGE }>;
};
const CUSTOM_PRESET_VALUE = "c" as const;
const DEFAULT_PRESET: PresetOption = {
labelKey: "last_number_of_days",
i18nOptions: { count: 7 },
value: "w",
};
const CUSTOM_PRESET: PresetOption = { labelKey: "custom_range", value: CUSTOM_PRESET_VALUE };
const PRESET_OPTIONS: PresetOption[] = [
{ labelKey: "today", value: "tdy" },
DEFAULT_PRESET,
{ labelKey: "last_number_of_days", i18nOptions: { count: 30 }, value: "t" },
{ labelKey: "month_to_date", value: "m" },
{ labelKey: "year_to_date", value: "y" },
CUSTOM_PRESET,
];
const getDateRangeFromPreset = (val: string | null) => {
let startDate;
let endDate;
@@ -87,10 +72,6 @@ const getDateRangeFromPreset = (val: string | null) => {
return { startDate, endDate, preset };
};
const getDefaultStartDate = () => dayjs().subtract(1, "week").startOf("day");
const getDefaultEndDate = () => dayjs().endOf("day");
export const DateRangeFilter = ({ column }: DateRangeFilterProps) => {
const filterValue = useFilterValue(column.id, ZDateRangeFilterValue);
const { updateFilter } = useDataTable();
@@ -134,7 +134,10 @@ export function FilterPopover({ column }: FilterPopoverProps) {
return (
<Popover>
<PopoverTrigger asChild>
<Button color="secondary" className="items-center">
<Button
color="secondary"
className="items-center"
data-testid={`filter-popover-trigger-${column.id}`}>
<Icon name={icon} className="mr-2 h-4 w-4" />
<span>{startCase(column.title)}</span>
<AppliedFilterValue column={column} filterValue={filterValue} />
@@ -28,7 +28,7 @@ export function MultiSelectFilterOptions({ column }: MultiSelectFilterOptionsPro
const { updateFilter, removeFilter } = useDataTable();
return (
<Command>
<Command data-testid={`multi-select-options-${column.id}`}>
<CommandInput placeholder={t("search")} />
<CommandList>
<CommandEmpty>{t("no_options_found")}</CommandEmpty>
@@ -28,7 +28,7 @@ export function SingleSelectFilterOptions({ column }: SingleSelectFilterOptionsP
const { updateFilter, removeFilter } = useDataTable();
return (
<Command>
<Command data-testid={`single-select-options-${column.id}`}>
<CommandInput placeholder={t("search")} />
<CommandList>
<CommandEmpty>{t("no_options_found")}</CommandEmpty>
@@ -4,12 +4,14 @@ import { type Table } from "@tanstack/react-table";
// eslint-disable-next-line no-restricted-imports
import { useMemo } from "react";
import type { FilterableColumn } from "../lib/types";
import type { FilterableColumn, FacetedValue } from "../lib/types";
import { ColumnFilterType } from "../lib/types";
import { convertMapToFacetedValues } from "../lib/utils";
export function useFilterableColumns<TData>(table: Table<TData>) {
const columns = useMemo(
() => table.getAllColumns().filter((column) => column.getCanFilter()),
[table.getAllColumns()]
);
@@ -25,20 +27,18 @@ export function useFilterableColumns<TData>(table: Table<TData>) {
type,
};
if (type === ColumnFilterType.MULTI_SELECT || type === ColumnFilterType.SINGLE_SELECT) {
const values = column.getFacetedUniqueValues();
const options = Array.from(values.keys()).map((option) => {
if (typeof option === "string") {
return {
label: option,
value: option,
};
} else {
return {
label: option.label as string,
value: option.value as string | number,
};
}
});
// `column.getFacetedUniqueValues` gets out of sync
// when we pass a new `getFacetedUniqueValues` to
// `useReactTable({ ... })`.
//
// So we use `table.options.getFacetedUniqueValues` instead.
let values: Map<FacetedValue, number> | (() => Map<FacetedValue, number>) | undefined =
table.options?.getFacetedUniqueValues?.(table, column.id);
if (typeof values === "function") {
values = values();
}
const options = convertMapToFacetedValues(values);
return {
...base,
options,
@@ -50,7 +50,10 @@ export function useFilterableColumns<TData>(table: Table<TData>) {
}
})
.filter((column): column is FilterableColumn => Boolean(column)),
[columns]
// re-calculate this when the `getFacetedUniqueValues` changes
// eslint-disable-next-line react-hooks/exhaustive-deps
[columns, table.options.getFacetedUniqueValues]
);
return filterableColumns;
@@ -0,0 +1,31 @@
import dayjs from "@calcom/dayjs";
export type PresetOptionValue = "c" | "w" | "m" | "y" | "t" | "tdy";
export type PresetOption = {
labelKey: string;
i18nOptions?: Record<string, string | number>;
value: PresetOptionValue;
};
export const CUSTOM_PRESET_VALUE = "c" as const;
export const DEFAULT_PRESET: PresetOption = {
labelKey: "last_number_of_days",
i18nOptions: { count: 7 },
value: "w",
};
export const CUSTOM_PRESET: PresetOption = { labelKey: "custom_range", value: CUSTOM_PRESET_VALUE };
export const PRESET_OPTIONS: PresetOption[] = [
{ labelKey: "today", value: "tdy" },
DEFAULT_PRESET,
{ labelKey: "last_number_of_days", i18nOptions: { count: 30 }, value: "t" },
{ labelKey: "month_to_date", value: "m" },
{ labelKey: "year_to_date", value: "y" },
CUSTOM_PRESET,
];
export const getDefaultStartDate = () => dayjs().subtract(1, "week").startOf("day");
export const getDefaultEndDate = () => dayjs().endOf("day");
@@ -148,3 +148,8 @@ export const ZSorting = z.object({
export type SortingState = Array<z.infer<typeof ZSorting>>;
export const ZColumnVisibility = z.record(z.string(), z.boolean());
export type FacetedValue = {
label: string;
value: string | number;
};
+19
View File
@@ -7,6 +7,7 @@ import type {
FilterValue,
NumberFilterValue,
DateRangeFilterValue,
FacetedValue,
} from "./types";
import {
ZNumberFilterValue,
@@ -147,3 +148,21 @@ export const dataTableFilter = (cellValue: unknown, filterValue: FilterValue) =>
}
return false;
};
export const convertFacetedValuesToMap = (array: FacetedValue[]) => {
return new Map<FacetedValue, number>(
array.map((option) => [{ label: option.label, value: option.value }, 1])
);
};
export const convertMapToFacetedValues = (map: Map<FacetedValue, number> | undefined) => {
if (!map || !(map instanceof Map)) {
return [];
}
return Array.from(map.keys()).map((option) => {
if (typeof option === "string") {
return { label: option, value: option };
}
return { label: option.label as string, value: option.value as string | number };
});
};
@@ -318,7 +318,7 @@ function MemberListContent(props: Props) {
id: "member",
accessorFn: (data) => data.email,
enableHiding: false,
header: `Member (${totalDBRowCount})`,
header: "Member",
size: 250,
cell: ({ row }) => {
const { username, email, avatarUrl, accepted, name } = row.original;
@@ -621,7 +621,6 @@ function MemberListContent(props: Props) {
}, [props.isOrgAdminOrOwner, dispatch, totalDBRowCount, session?.user.id]);
//we must flatten the array of arrays from the useInfiniteQuery hook
const flatData = useMemo(() => data?.pages?.flatMap((page) => page.members) ?? [], [data]) as User[];
const totalFetched = flatData.length;
const table = useReactTable({
data: flatData,
@@ -154,38 +154,43 @@ export const FilterCheckboxFieldsContainer = ({
type Props = InputHTMLAttributes<HTMLInputElement> & {
label: string;
testId?: string;
icon?: ReactNode;
};
export const FilterCheckboxField = forwardRef<HTMLInputElement, Props>(({ label, icon, ...rest }, ref) => {
return (
<div className="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer">
<label className="flex w-full max-w-full items-center justify-between hover:cursor-pointer">
<div className="flex items-center truncate">
{icon && (
<div className="text-default flex h-4 w-4 items-center justify-center ltr:mr-2 rtl:ml-2">
{icon}
</div>
)}
<Tooltip content={label}>
<label
htmlFor={rest.id}
className="text-default me-1 cursor-pointer truncate text-sm font-medium">
{label}
</label>
</Tooltip>
</div>
<div className="flex h-5 items-center">
<input
{...rest}
ref={ref}
type="checkbox"
className="text-emphasis dark:text-muted focus:ring-emphasis border-default bg-default h-4 w-4 rounded transition hover:cursor-pointer"
/>
</div>
</label>
</div>
);
});
export const FilterCheckboxField = forwardRef<HTMLInputElement, Props>(
({ label, icon, testId, ...rest }, ref) => {
return (
<div
data-testid={testId}
className="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer">
<label className="flex w-full max-w-full items-center justify-between hover:cursor-pointer">
<div className="flex items-center truncate">
{icon && (
<div className="text-default flex h-4 w-4 items-center justify-center ltr:mr-2 rtl:ml-2">
{icon}
</div>
)}
<Tooltip content={label}>
<label
htmlFor={rest.id}
className="text-default me-1 cursor-pointer truncate text-sm font-medium">
{label}
</label>
</Tooltip>
</div>
<div className="flex h-5 items-center">
<input
{...rest}
ref={ref}
type="checkbox"
className="text-emphasis dark:text-muted focus:ring-emphasis border-default bg-default h-4 w-4 rounded transition hover:cursor-pointer"
/>
</div>
</label>
</div>
);
}
);
FilterCheckboxField.displayName = "FilterCheckboxField";
@@ -1,10 +1,9 @@
import { Title } from "@tremor/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,19 +11,16 @@ import { LoadingInsight } from "./LoadingInsights";
export const AverageEventDurationChart = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const { dateRange, selectedMemberUserId, isAll, initialConfig } = filter;
const [startDate, endDate] = dateRange;
const { selectedTeamId: teamId, selectedUserId, selectedEventTypeId } = filter;
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
const { isAll, teamId, userId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters();
const { data, isSuccess, isPending } = trpc.viewer.insights.averageEventDuration.useQuery(
{
startDate: dayjs.utc(startDate).toISOString(),
endDate: dayjs.utc(endDate).toISOString(),
startDate,
endDate,
teamId,
eventTypeId: selectedEventTypeId ?? undefined,
memberUserId: selectedMemberUserId ?? undefined,
userId: selectedUserId ?? undefined,
eventTypeId,
memberUserId,
userId,
isAll,
},
{
@@ -32,15 +28,13 @@ export const AverageEventDurationChart = () => {
trpc: {
context: { skipBatch: true },
},
// At least one of the following initial configs should have a value
enabled: initialConfigIsReady,
}
);
if (isPending) return <LoadingInsight />;
if (!isSuccess || !startDate || !endDate || (!teamId && !selectedUserId)) return null;
const isNoData = (data && data.length === 0) || data.every((item) => item["Average"] === 0);
if (!isSuccess || !data) return null;
const isNoData = data.every((item) => item["Average"] === 0);
return (
<CardInsights>
<Title className="text-emphasis">{t("average_event_duration")}</Title>
@@ -0,0 +1,29 @@
import { useId } from "react";
import { Badge } from "@calcom/ui";
import type { RoutingFormTableRow } from "../lib/types";
import { CellWithOverflowX } from "./CellWithOverflowX";
export function BookedByCell({
attendees,
rowId,
}: {
attendees: RoutingFormTableRow["bookingAttendees"] | undefined;
rowId: number;
}) {
const cellId = useId();
if (!attendees || attendees.length === 0) return <div className="min-w-[200px]" />;
return (
<div className="flex min-w-[200px] flex-wrap gap-1">
{attendees.map((attendee) => (
<CellWithOverflowX key={`${cellId}-${attendee.email}-${rowId}`} className="w-[200px]">
<Badge variant="gray" className="whitespace-nowrap" title={attendee.email}>
{attendee.name}
</Badge>
</CellWithOverflowX>
))}
</div>
);
}
@@ -0,0 +1,67 @@
import Link from "next/link";
import { useId } from "react";
import dayjs from "@calcom/dayjs";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import {
Badge,
Avatar,
Icon,
HoverCard,
HoverCardContent,
HoverCardTrigger,
HoverCardPortal,
} from "@calcom/ui";
import type { RoutingFormTableRow } from "../lib/types";
import { BookingStatusBadge } from "./BookingStatusBadge";
export function BookingAtCell({ row, rowId }: { row: RoutingFormTableRow; rowId: number }) {
const cellId = useId();
const { copyToClipboard } = useCopy();
if (!row.bookingUserId || !row.bookingCreatedAt) {
return <div className="w-[250px]" />;
}
return (
<HoverCard>
<HoverCardTrigger asChild>
<div className="flex items-center gap-2" key={`${cellId}-booking-${rowId}`}>
<Avatar size="xs" imageSrc={row.bookingUserAvatarUrl ?? ""} alt={row.bookingUserName ?? ""} />
<Link href={`/booking/${row.bookingUid}`}>
<Badge variant="gray">{dayjs(row.bookingCreatedAt).format("MMM D, YYYY HH:mm")}</Badge>
</Link>
</div>
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<Avatar size="sm" imageSrc={row.bookingUserAvatarUrl ?? ""} alt={row.bookingUserName ?? ""} />
<div>
<p className="text-sm font-medium">{row.bookingUserName}</p>
<p className="group/booking_status_email text-subtle flex items-center text-xs">
<span className="truncate">{row.bookingUserEmail}</span>
<button
className="invisible ml-2 group-hover/booking_status_email:visible"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
copyToClipboard(row.bookingUserEmail ?? "");
}}>
<Icon name="copy" />
</button>
</p>
</div>
</div>
<div className="text-emphasis mt-4 flex items-center gap-2 text-xs">
<span>Status:</span>
<BookingStatusBadge bookingStatus={row.bookingStatus} />
</div>
</div>
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
);
}
@@ -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 <LoadingKPICards categories={categories} />;
}
if (!isSuccess || !startDate || !endDate || (!teamId && !selectedUserId)) return null;
if (!isSuccess || !data) return null;
return (
<>
@@ -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 <Badge variant={badgeVariant}>{bookingStatusToText(bookingStatus)}</Badge>;
}
@@ -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,
}
);
@@ -0,0 +1,31 @@
import classNames from "@calcom/lib/classNames";
export function CellWithOverflowX({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={classNames("group relative max-w-[200px]", className)}>
<div
className="no-scrollbar flex gap-1 overflow-x-auto whitespace-nowrap"
ref={(el) => {
// 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}
</div>
<div className="from-default absolute right-0 top-0 hidden h-full w-8 bg-gradient-to-l to-transparent " />
</div>
);
}
@@ -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 <LoadingInsight />;
if (!isSuccess || !startDate || !endDate || !teamId) return null;
if (!isSuccess || !data) return null;
return data && data.length > 0 ? (
<CardInsights className="shadow-none">
@@ -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 <LoadingInsight />;
if (!isSuccess || !startDate || !endDate || !teamId) return null;
if (!isSuccess || !data) return null;
return data && data.length > 0 ? (
<CardInsights className="shadow-none">
@@ -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 <LoadingInsight />;
if (!isSuccess || !startDate || !endDate || !teamId) return null;
if (!isSuccess || !data) return null;
return (
<CardInsights>
@@ -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 <LoadingInsight />;
if (!isSuccess || !startDate || !endDate || !teamId) return null;
if (!isSuccess || !data) return null;
return data && data.length > 0 ? (
<CardInsights className="shadow-none">
@@ -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 <LoadingInsight />;
if (!isSuccess || !startDate || !endDate || !teamId) return null;
if (!isSuccess || !data) return null;
return (
<CardInsights className="shadow-none">
@@ -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 <LoadingInsight />;
if (!isSuccess || !startDate || !endDate || (!teamId && !selectedUserId)) return null;
if (!isSuccess || !data) return null;
return (
<CardInsights>
@@ -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 <LoadingInsight />;
if (!isSuccess || !startDate || !endDate || !teamId) return null;
if (!isSuccess || !data) return null;
return (
<CardInsights>
@@ -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<string, string>;
values: string[];
rowId: number;
}) {
const cellId = useId();
if (values.length === 0) return <div className="h-6 w-[200px]" />;
return (
<CellWithOverflowX className="flex w-[200px] gap-1">
{values.length > 2 ? (
<>
{values.slice(0, 2).map((id: string, i: number) => (
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
{optionMap[id] ?? id}
</Badge>
))}
<HoverCard>
<HoverCardTrigger>
<Badge variant="gray">+{values.length - 2}</Badge>
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent side="bottom" align="start" className="w-fit">
<div className="flex flex-col gap-1">
{values.slice(2).map((id: string, i: number) => (
<span key={`${cellId}-overflow-${i}-${rowId}`} className="text-default text-sm">
{optionMap[id] ?? id}
</span>
))}
</div>
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
</>
) : (
values.map((id: string, i: number) => (
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
{optionMap[id] ?? id}
</Badge>
))
)}
</CellWithOverflowX>
);
}
@@ -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, number> = {
[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 (
<div className={classNames("group relative max-w-[200px]", className)}>
<div
className="no-scrollbar flex gap-1 overflow-x-auto whitespace-nowrap"
ref={(el) => {
// 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}
</div>
<div className="from-default absolute right-0 top-0 hidden h-full w-8 bg-gradient-to-l to-transparent " />
</div>
);
}
// 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 <div className="min-w-[200px]" />;
return (
<div className="flex min-w-[200px] flex-wrap gap-1">
{attendees.map((attendee) => (
<CellWithOverflowX key={`${cellId}-${attendee.email}-${rowId}`} className="w-[200px]">
<Badge variant="gray" className="whitespace-nowrap" title={attendee.email}>
{attendee.name}
</Badge>
</CellWithOverflowX>
))}
</div>
);
}
function ResponseValueCell({
optionMap,
values,
rowId,
}: {
optionMap: Record<string, string>;
values: string[];
rowId: number;
}) {
const cellId = useId();
if (values.length === 0) return <div className="h-6 w-[200px]" />;
return (
<CellWithOverflowX className="flex w-[200px] gap-1">
{values.length > 2 ? (
<>
{values.slice(0, 2).map((id: string, i: number) => (
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
{optionMap[id] ?? id}
</Badge>
))}
<HoverCard>
<HoverCardTrigger>
<Badge variant="gray">+{values.length - 2}</Badge>
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent side="bottom" align="start" className="w-fit">
<div className="flex flex-col gap-1">
{values.slice(2).map((id: string, i: number) => (
<span key={`${cellId}-overflow-${i}-${rowId}`} className="text-default text-sm">
{optionMap[id] ?? id}
</span>
))}
</div>
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
</>
) : (
values.map((id: string, i: number) => (
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
{optionMap[id] ?? id}
</Badge>
))
)}
</CellWithOverflowX>
);
}
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 <Badge variant={badgeVariant}>{bookingStatusToText(bookingStatus)}</Badge>;
}
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 <div className="w-[250px]" />;
}
return (
<HoverCard>
<HoverCardTrigger asChild>
<div className="flex items-center gap-2" key={`${cellId}-booking-${rowId}`}>
<Avatar size="xs" imageSrc={row.bookingUserAvatarUrl ?? ""} alt={row.bookingUserName ?? ""} />
<Link href={`/booking/${row.bookingUid}`}>
<Badge variant="gray">{dayjs(row.bookingCreatedAt).format("MMM D, YYYY HH:mm")}</Badge>
</Link>
</div>
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<Avatar size="sm" imageSrc={row.bookingUserAvatarUrl ?? ""} alt={row.bookingUserName ?? ""} />
<div>
<p className="text-sm font-medium">{row.bookingUserName}</p>
<p className="group/booking_status_email text-subtle flex items-center text-xs">
<span className="truncate">{row.bookingUserEmail}</span>
<button
className="invisible ml-2 group-hover/booking_status_email:visible"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
copyToClipboard(row.bookingUserEmail ?? "");
}}>
<Icon name="copy" />
</button>
</p>
</div>
</div>
<div className="text-emphasis mt-4 flex items-center gap-2 text-xs">
<span>Status:</span>
<BookingStatusBadge bookingStatus={row.bookingStatus} />
</div>
</div>
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
);
}
export type RoutingFormTableType = ReturnType<typeof useReactTable<RoutingFormTableRow>>;
const createdAtColumn: Extract<FilterableColumn, { type: ColumnFilterType.DATE_RANGE }> = {
@@ -252,8 +38,6 @@ const createdAtColumn: Extract<FilterableColumn, { type: ColumnFilterType.DATE_R
};
export function RoutingFormResponsesTable() {
const { t } = useLocale();
const { copyToClipboard } = useCopy();
const { isAll, teamId, userId, memberUserIds, routingFormId, startDate, endDate, columnFilters } =
useInsightsParameters();
@@ -268,16 +52,7 @@ export function RoutingFormResponsesTable() {
routingFormId,
});
const { data: forms } = trpc.viewer.insights.getRoutingFormsForFilters.useQuery({
userId,
teamId,
isAll,
});
const { data: users } = trpc.viewer.insights.userList.useQuery({
teamId: teamId ?? -1,
isAll,
});
const getInsightsFacetedUniqueValues = useInsightsFacetedUniqueValues({ headers, userId, teamId, isAll });
const { sorting } = useDataTable();
@@ -309,202 +84,7 @@ export function RoutingFormResponsesTable() {
return (data?.pages?.flatMap((page) => page.data) ?? []) as RoutingFormTableRow[];
}, [data, isHeadersSuccess]);
const columnHelper = createColumnHelper<RoutingFormTableRow>();
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 <BookedByCell attendees={info.getValue()} rowId={info.row.original.id} />;
},
}),
...((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<string, string>) ?? {};
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 && (
<ResponseValueCell
optionMap={optionMap}
values={Array.isArray(result.data.value) ? result.data.value : [result.data.value]}
rowId={info.row.original.id}
/>
)
);
} else if (isText || isNumber) {
const result = z.union([ZResponseTextValue, ZResponseNumericValue]).safeParse(values);
return (
result.success && (
<div className="truncate">
<span title={String(result.data.value)}>{result.data.value}</span>
</div>
)
);
} 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) => (
<div className="max-w-[250px]">
<BookingStatusBadge bookingStatus={info.row.original.bookingStatus} />
</div>
),
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) => (
<div className="max-w-[250px]">
<BookingAtCell
row={info.row.original}
rowId={info.row.original.id}
copyToClipboard={copyToClipboard}
t={t}
/>
</div>
),
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 <div className="max-w-[250px]">{assignmentReason}</div>;
},
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) => (
<div className="whitespace-nowrap">
<Badge variant="gray">{dayjs(info.getValue()).format("MMM D, YYYY HH:mm")}</Badge>
</div>
),
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<RoutingFormTableRow>({
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)) {
@@ -104,4 +104,4 @@
.tremor-TableBody-root {
@apply divide-subtle;
}
}
@@ -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<FilterContextType["filter"]>({
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 (
<FilterProvider
value={{
filter: {
dateRange,
selectedTimeView,
selectedMemberUserId,
selectedTeamId,
selectedUserId,
selectedTeamName,
selectedEventTypeId,
selectedFilter,
isAll,
initialConfig,
selectedRoutingFormId,
selectedBookingStatus,
selectedRoutingFormFilter,
},
setConfigFilters: (newConfigFilters) => {
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}
</FilterProvider>
);
}
@@ -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<IFilter>) => void;
};
export const FilterContext = React.createContext<FilterContextType | null>(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<F extends FilterContextType>(props: { value: F; children: React.ReactNode }) {
return React.createElement(FilterContext.Provider, { value: props.value }, props.children);
}
@@ -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 (
<FilterSelect
title={t("routing_form_insights_booking_status")}
options={bookingStatusOptions}
selectedValue={selectedBookingStatus}
onChange={(value) => {
setConfigFilters({ selectedBookingStatus: value as BookingStatus | "NO_BOOKING" });
}}
buttonIcon={<Icon name="circle" className="mr-2 h-4 w-4" />}
placeholder={t("search")}
/>
);
};
@@ -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 (
<Tooltip content={t("clear_filters")}>
<Button
variant="icon"
color="secondary"
target="_blank"
rel="noreferrer"
className="min-w-24 h-[38px] border-0"
onClick={clear}>
<Icon name="x" className="mr-1 h-4 w-4" />
{t("clear")}
</Button>
</Tooltip>
);
};
@@ -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<Dayjs>(initialStartDate);
const [endDate, setEndDate] = useState<Dayjs | undefined>(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 (
<div className={classNames("ml inline-flex space-x-2 rtl:space-x-reverse", className)}>
<DateRangePicker
dates={{
startDate: startValue,
endDate: endValue,
}}
minDate={currentDate.subtract(2, "year").toDate()}
maxDate={currentDate.toDate()}
disabled={false}
onDatesChange={({ startDate, endDate }) => {
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));
}}
/>
<Select
variant="default"
data-testid="insights-preset"
options={presetOptions}
value={selectedPreset}
className="w-40 capitalize text-black"
defaultValue={selectedPreset}
styles={{ menuPortal: (base) => ({ ...base, zIndex: 9999 }) }}
menuPortalTarget={document.body}
onChange={(e) => {
if (e) {
updateDateRange(e.value);
}
}}
/>
</div>
);
};
@@ -1,22 +1,23 @@
import { useFilterContext } from "@calcom/features/insights/context/provider";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { Button, Dropdown, DropdownItem, DropdownMenuContent, DropdownMenuTrigger } from "@calcom/ui";
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
const Download = () => {
const { filter } = useFilterContext();
const { t } = useLocale();
const { startDate, endDate, teamId, userId, eventTypeId, memberUserId } = useInsightsParameters();
const { data, isPending } = trpc.viewer.insights.rawData.useQuery(
{
startDate: filter.dateRange[0].toISOString(),
endDate: filter.dateRange[1].toISOString(),
teamId: filter.selectedTeamId,
userId: filter.selectedUserId,
eventTypeId: filter.selectedEventTypeId,
memberUserId: filter.selectedMemberUserId,
startDate,
endDate,
teamId,
userId,
eventTypeId,
memberUserId,
},
{
refetchOnWindowFocus: false,
@@ -1,47 +0,0 @@
import { memo } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { Icon, FilterSelect } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
type EventType = RouterOutputs["viewer"]["insights"]["eventTypeList"][number];
const mapEventTypeToOption = (eventType: EventType) => ({
value: eventType.id,
label: eventType.teamId ? `${eventType.title} (${eventType.team?.name})` : eventType.title,
});
export const EventTypeList = memo(() => {
const { t } = useLocale();
const { filter, setConfigFilters } = useFilterContext();
const { selectedTeamId, selectedEventTypeId, selectedUserId, isAll } = filter;
const { selectedFilter } = filter;
const { data, isSuccess } = trpc.viewer.insights.eventTypeList.useQuery({
teamId: selectedTeamId ?? undefined,
userId: selectedUserId ?? undefined,
isAll,
});
if (!selectedFilter?.includes("event-type")) return null;
if (!selectedTeamId && !selectedUserId) return null;
if (!isSuccess || !data || !Array.isArray(data)) return null;
const filterOptions = data.map(mapEventTypeToOption);
return (
<FilterSelect
title={t("event_type")}
options={filterOptions}
selectedValue={selectedEventTypeId}
onChange={(value) => setConfigFilters({ selectedEventTypeId: value ? Number(value) : null })}
buttonIcon={<Icon name="calendar" className="mr-2 h-4 w-4" />}
emptyText={t("no_options_available")}
/>
);
});
EventTypeList.displayName = "EventTypeList";
@@ -1,91 +0,0 @@
import { useMemo } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { IconName } from "@calcom/ui";
import {
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Icon,
Tooltip,
} from "@calcom/ui";
import { useFilterContext } from "../context/provider";
type FilterType = "event-type" | "user" | "routing_forms" | `rf_${string}` | "booking_status";
type Option = {
value: FilterType;
label: string;
StartIcon: IconName;
};
export const FilterType = () => {
const { t } = useLocale();
const { filter, setConfigFilters } = useFilterContext();
const { selectedFilter, selectedUserId } = filter;
const filterOptions = useMemo(() => {
let options: Option[] = [
{
label: t("user"),
value: "user",
StartIcon: "users" as IconName,
},
];
options.push({
label: t("event_type"),
value: "event-type",
StartIcon: "link",
});
if (selectedUserId) {
// remove user option from filterOptions
options = options.filter((option) => option.value !== "user");
}
return options;
}, [t, selectedUserId]);
return (
<Dropdown>
<DropdownMenuTrigger asChild>
<div className="hover:border-emphasis border-default text-default hover:text-emphasis focus:border-subtle mb-4 flex h-9 max-h-72 items-center justify-between whitespace-nowrap rounded-md border px-3 py-2 text-sm hover:cursor-pointer focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-1">
<Icon name="plus" className="mr-2 h-4 w-4" />
<Tooltip content={t("add_filter")}>
<div>{selectedFilter?.length ? `${selectedFilter.length} ${t("filters")}` : t("add_filter")}</div>
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="z-50">
{filterOptions?.map((option) => (
<DropdownMenuItem key={option.label} className="w-full">
<DropdownItem
type="button"
StartIcon={option.StartIcon}
onClick={() => {
setConfigFilters({
selectedFilter: selectedFilter
? selectedFilter.includes(option.value)
? selectedFilter.filter((f) => f !== option.value)
: [...selectedFilter, option.value]
: [option.value],
});
}}
childrenClassName="w-full">
<div className="flex w-full items-center justify-between whitespace-normal">
<span className="mr-2">{t(option.label)}</span>
{selectedFilter?.includes(option.value) && (
<Icon name="check" className="h-4 w-4 flex-shrink-0" />
)}
</div>
</DropdownItem>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</Dropdown>
);
};
@@ -14,19 +14,16 @@ import { useInsightsOrgTeams } from "../hooks/useInsightsOrgTeams";
export type OrgTeamsType = "org" | "team" | "yours";
// This is a clone of TeamAndSelfList, but without useFilterContext().
// It's meant to be used in the RoutingFormResponsesTable.
export const OrgTeamsFilter = () => {
const { orgTeamsType, selectedTeamId, setOrgTeamsType, setSelectedTeamId } = useInsightsOrgTeams();
const { t } = useLocale();
const session = useSession();
const currentOrgId = session.data?.user.org?.id;
const currentUserId = session.data?.user.id;
const currentUserName = session.data?.user.name;
const [query, setQuery] = useState<string>("");
const { data, isSuccess } = trpc.viewer.insights.teamListForUser.useQuery(undefined, {
const { data } = trpc.viewer.insights.teamListForUser.useQuery(undefined, {
// Teams don't change that frequently
refetchOnWindowFocus: false,
trpc: {
@@ -60,8 +57,16 @@ export const OrgTeamsFilter = () => {
return { text: t("select"), imageUrl: undefined };
};
const resetSelection = () => {
if (isOrgDataAvailable) {
onSelected({ type: "org", teamId: currentOrgId });
} else {
onSelected({ type: "yours", teamId: undefined });
}
};
const { text, placeholder, imageUrl } = getPopoverProps();
const isOrgDataAvailable = !!data && data.length > 0 && !!data[0].isOrg;
const isOrgDataAvailable = !!data && data.length > 0 && !!data[0].isOrg && data[0].id === currentOrgId;
const PrefixComponent =
orgTeamsType !== undefined && (imageUrl || placeholder) ? (
@@ -86,10 +91,15 @@ export const OrgTeamsFilter = () => {
{isOrgDataAvailable && (
<FilterCheckboxField
id="all"
testId="org-teams-filter-item"
icon={<Icon name="layers" className="h-4 w-4" />}
checked={orgTeamsType === "org"}
onChange={(e) => {
onSelected({ type: "org", teamId: undefined });
if (e.target.checked) {
onSelected({ type: "org", teamId: currentOrgId });
} else {
resetSelection();
}
}}
label={t("all")}
/>
@@ -97,6 +107,7 @@ export const OrgTeamsFilter = () => {
<FilterCheckboxField
id="yours"
testId="org-teams-filter-item"
icon={
<Avatar
alt={`${currentUserName} avatar`}
@@ -109,19 +120,20 @@ export const OrgTeamsFilter = () => {
if (e.target.checked) {
onSelected({ type: "yours", teamId: undefined });
} else if (!e.target.checked) {
onSelected({ type: currentOrgId ? "org" : "yours", teamId: undefined });
resetSelection();
}
}}
label={t("yours")}
/>
<Divider />
{data
?.filter((team) => !team.isOrg)
{(data || [])
.filter((team) => !team.isOrg)
.filter((team) => team.name?.toLowerCase().includes(query.toLowerCase()))
.map((team) => {
return (
<FilterCheckboxField
testId="org-teams-filter-item"
key={team.id}
id={team.name || ""}
label={team.name || ""}
@@ -130,7 +142,7 @@ export const OrgTeamsFilter = () => {
if (e.target.checked) {
onSelected({ type: "team", teamId: team.id });
} else if (!e.target.checked) {
onSelected({ type: currentOrgId ? "org" : "yours", teamId: undefined });
resetSelection();
}
}}
icon={
@@ -1,64 +0,0 @@
import { useMemo } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Icon, FilterSelect } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
export const RoutingFormFieldFilter = ({ fieldId }: { fieldId: string }) => {
const { t } = useLocale();
const { filter, setConfigFilters } = useFilterContext();
const { selectedTeamId, selectedUserId, isAll, selectedRoutingFormId, selectedRoutingFormFilter } = filter;
const { data: fieldOptions } = trpc.viewer.insights.getRoutingFormFieldOptions.useQuery(
{
userId: selectedUserId ?? undefined,
teamId: selectedTeamId ?? undefined,
isAll: !!isAll,
routingFormId: selectedRoutingFormId ?? undefined,
},
{
enabled: !!selectedTeamId || !!isAll,
}
);
const currentField = useMemo(() => {
return fieldOptions?.find((option) => `${option.id}` === fieldId);
}, [fieldOptions]);
if (!currentField) return null;
const options =
currentField.options?.map((option) => ({
value: option.id ?? "",
label: option.label,
})) ?? [];
return (
<>
<FilterSelect
title={currentField.label}
options={options}
selectedValue={selectedRoutingFormFilter?.optionId}
onChange={(value) => {
console.log("value", value);
if (!value) {
setConfigFilters({
selectedRoutingFormFilter: null,
});
} else {
setConfigFilters({
selectedRoutingFormFilter: {
fieldId,
optionId: value as string,
},
});
}
}}
buttonIcon={<Icon name="layers" className="mr-2 h-4 w-4" />}
placeholder={t("search")}
/>
</>
);
};
@@ -1,51 +0,0 @@
import { memo } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { Icon, FilterSelect } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
type Form = RouterOutputs["viewer"]["insights"]["getRoutingFormsForFilters"][number];
function buildFilterOptions(forms: Form[]) {
return forms.map((form) => ({
value: form.id,
label: form.name,
}));
}
export const RoutingFormFilterList = memo(() => {
const { t } = useLocale();
const { filter, setConfigFilters } = useFilterContext();
const { selectedTeamId, selectedUserId, selectedRoutingFormId, isAll } = filter;
const { selectedFilter } = filter;
const { data: allForms } = trpc.viewer.insights.getRoutingFormsForFilters.useQuery(
{
userId: selectedUserId ?? undefined,
teamId: selectedTeamId ?? undefined,
isAll: isAll ?? false,
},
{
enabled: selectedFilter?.includes("routing_forms"),
}
);
if (!selectedFilter?.includes("routing_forms")) return null;
const filterOptions = buildFilterOptions(allForms || []);
return (
<FilterSelect
title={t("routing_forms")}
options={filterOptions}
selectedValue={selectedRoutingFormId}
onChange={(value) => setConfigFilters({ selectedRoutingFormId: value as string })}
buttonIcon={<Icon name="filter" className="mr-2 h-4 w-4" />}
/>
);
});
RoutingFormFilterList.displayName = "RoutingFormFilterList";
@@ -1,182 +0,0 @@
import { useSession } from "next-auth/react";
import { useEffect } from "react";
import {
FilterCheckboxField,
FilterCheckboxFieldsContainer,
} from "@calcom/features/filters/components/TeamsFilter";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { AnimatedPopover, Avatar, Divider, Icon } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
export const TeamAndSelfList = ({
omitOrg = false,
className = "",
}: {
omitOrg?: boolean;
className?: string;
}) => {
const { t } = useLocale();
const session = useSession();
const currentOrgId = session.data?.user.org?.id;
const { filter, setConfigFilters } = useFilterContext();
const { selectedTeamId, selectedUserId, isAll } = filter;
const { data, isSuccess } = trpc.viewer.insights.teamListForUser.useQuery(undefined, {
// Teams don't change that frequently
refetchOnWindowFocus: false,
trpc: {
context: {
skipBatch: true,
},
},
});
useEffect(() => {
const isInitialSetupAlready = !!(
filter.initialConfig?.teamId ||
filter.initialConfig?.userId ||
filter.initialConfig?.isAll
);
if (isInitialSetupAlready) return;
if (isSuccess && session.data?.user.id) {
// We have a team?
if (data[0]?.id && data && data?.length > 0) {
const isAllSelected = !!data[0]?.isOrg;
setConfigFilters({
selectedTeamId: data[0].id,
selectedUserId: null,
isAll: isAllSelected,
initialConfig: {
teamId: data[0].id,
userId: null,
isAll: isAllSelected,
},
});
} else if (session.data?.user.id) {
// default to user
setConfigFilters({
selectedUserId: session.data?.user.id,
initialConfig: {
teamId: null,
userId: session.data?.user.id,
isAll: false,
},
});
}
}
}, [data, session.data?.user.id, filter.initialConfig, isSuccess, setConfigFilters]);
const getTextPopover = () => {
if (isAll) {
return `${t("all")}`;
} else if (selectedUserId) {
return `${t("yours")}`;
} else if (selectedTeamId) {
const selectedTeam = data?.find((item) => {
return item.id === selectedTeamId;
});
return `${t("team")}: ${selectedTeam?.name}`;
}
return t("select");
};
const text = getTextPopover();
const isOrgDataAvailable = !!data && data.length > 0 && !!data[0].isOrg;
return (
<AnimatedPopover text={text} popoverTriggerClassNames={className}>
<FilterCheckboxFieldsContainer>
{isOrgDataAvailable && (
<FilterCheckboxField
id="all"
icon={<Icon name="layers" className="h-4 w-4" />}
checked={isAll}
onChange={(e) => {
setConfigFilters({
selectedTeamId: data[0].id,
selectedUserId: null,
selectedTeamName: null,
selectedRoutingFormId: null,
isAll: true,
});
}}
label={t("all")}
/>
)}
<Divider />
{data?.map((team) => {
if (omitOrg && team.id === currentOrgId) return null;
return (
<FilterCheckboxField
key={team.id}
id={team.name || ""}
label={team.name || ""}
checked={selectedTeamId === team.id && !isAll}
onChange={(e) => {
if (e.target.checked) {
setConfigFilters({
selectedTeamId: team.id,
selectedUserId: null,
selectedTeamName: team.name,
isAll: false,
// Setting these to null to reset the filters
selectedEventTypeId: null,
selectedMemberUserId: null,
selectedFilter: null,
selectedRoutingFormId: null,
});
} else if (!e.target.checked) {
setConfigFilters({
selectedTeamId: isOrgDataAvailable ? data[0].id : null,
selectedTeamName: null,
selectedRoutingFormId: null,
isAll: isOrgDataAvailable,
});
}
}}
icon={
<Avatar
alt={team.name || ""}
imageSrc={getPlaceholderAvatar(team.logoUrl, team.name)}
size="xs"
/>
}
/>
);
})}
<Divider />
<FilterCheckboxField
id="yours"
icon={<Icon name="user" className="h-4 w-4" />}
checked={selectedUserId === session.data?.user.id}
onChange={(e) => {
if (e.target.checked) {
setConfigFilters({
selectedRoutingFormId: null,
selectedUserId: session.data?.user.id,
selectedMemberUserId: null,
selectedTeamId: null,
isAll: false,
});
} else if (!e.target.checked) {
setConfigFilters({
selectedTeamId: isOrgDataAvailable ? data[0].id : null,
selectedUserId: null,
selectedTeamName: null,
selectedRoutingFormId: null,
isAll: isOrgDataAvailable,
});
}
}}
label={t("yours")}
/>
</FilterCheckboxFieldsContainer>
</AnimatedPopover>
);
};
@@ -1,52 +0,0 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { Icon, Avatar, FilterSelect } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
type User = RouterOutputs["viewer"]["insights"]["userList"][number];
const mapUserToOption = (user: User) => ({
value: user.id,
label: user.name ?? user.email,
icon: <Avatar alt={`${user.name} avatar`} imageSrc={user.avatarUrl} size="sm" className="mr-2" />,
});
export const UserListInTeam = ({ onClear }: { onClear?: () => void }) => {
const { t } = useLocale();
const { filter, setConfigFilters } = useFilterContext();
const { selectedFilter, selectedTeamId, selectedMemberUserId, isAll } = filter;
const { data, isSuccess } = trpc.viewer.insights.userList.useQuery({
teamId: selectedTeamId ?? -1,
isAll: !!isAll,
});
if (!selectedFilter?.includes("user")) {
return null;
}
if (!selectedTeamId || !isSuccess || data?.length === 0) {
return null;
}
const userListOptions = data.map(mapUserToOption);
return (
<FilterSelect
title={t("people")}
options={userListOptions}
selectedValue={selectedMemberUserId}
onChange={(value) => {
setConfigFilters({ selectedMemberUserId: value === null ? null : Number(value) });
if (value === null) {
onClear?.();
}
}}
buttonIcon={<Icon name="users" className="mr-2 h-4 w-4" />}
placeholder={t("search")}
testId="people-filter"
/>
);
};
@@ -1,91 +0,0 @@
import { useFilterContext } from "@calcom/features/insights/context/provider";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Icon, Tooltip } from "@calcom/ui";
import { DateSelect } from "./DateSelect";
import { Download } from "./Download";
import { EventTypeList } from "./EventTypeList";
import { FilterType } from "./FilterType";
import { TeamAndSelfList } from "./TeamAndSelfList";
import { UserListInTeam } from "./UserListInTeam";
const ClearFilters = () => {
const { t } = useLocale();
const { filter, clearFilters } = useFilterContext();
const { selectedFilter } = filter;
if (!selectedFilter || selectedFilter?.length < 1) return null;
return (
<Tooltip content={t("clear_filters")}>
<Button
variant="icon"
color="secondary"
target="_blank"
rel="noreferrer"
className="min-w-24 h-[38px] border-0"
onClick={() => {
clearFilters();
}}>
<Icon name="x" className="mr-1 h-4 w-4" />
{t("clear")}
</Button>
</Tooltip>
);
};
export const Filters = () => {
const { filter } = useFilterContext();
const { selectedFilter } = filter;
// Get all filters that relate to the routing form
const routingFormFieldIds = selectedFilter
? selectedFilter.map((filter) => {
if (filter.startsWith("rf_")) return filter.substring(3);
})
: [];
return (
<div className="ml-auto mt-6 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-nowrap sm:justify-start">
<TeamAndSelfList omitOrg={false} />
<UserListInTeam />
<EventTypeList />
<FilterType />
<ClearFilters />
</div>
{/* @NOTE: To be released in next iteration */}
{/* <ButtonGroup combined containerProps={{ className: "hidden lg:flex mr-2" }}>
<Tooltip content={t("settings")}>
<Button
variant="icon"
color="secondary"
target="_blank"
rel="noreferrer"
StartIcon="settings"
className="h-[38px]"
/>
</Tooltip>
<Tooltip content={t("download_csv")>
<Button
variant="icon"
color="secondary"
target="_blank"
rel="noreferrer"
StartIcon="download"
className="h-[38px]"
/>
</Tooltip>
</ButtonGroup> */}
<div className="flex flex-col-reverse sm:flex-row sm:flex-nowrap sm:justify-between">
<Download />
<DateSelect className="me-2 ms-2" />
</div>
</div>
);
};
@@ -0,0 +1,77 @@
import {
createColumnHelper,
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
} from "@tanstack/react-table";
import { useMemo } from "react";
import { ColumnFilterType } from "@calcom/features/data-table";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { HeaderRow, RoutingFormTableRow } from "../lib/types";
import { useInsightsFacetedUniqueValues } from "./useInsightsFacetedUniqueValues";
import { useInsightsParameters } from "./useInsightsParameters";
type DummyTableRow = {
bookingUserId: RoutingFormTableRow["bookingUserId"];
eventTypeId: number | null;
};
const emptyData: DummyTableRow[] = [];
const dummyHeaders: HeaderRow[] = [];
export const useInsightsBookings = () => {
const { t } = useLocale();
const { isAll, teamId, userId } = useInsightsParameters();
const getInsightsFacetedUniqueValues = useInsightsFacetedUniqueValues({
headers: dummyHeaders,
userId,
teamId,
isAll,
});
const columns = useMemo(() => {
const columnHelper = createColumnHelper<DummyTableRow>();
return [
columnHelper.accessor("bookingUserId", {
id: "bookingUserId",
header: t("user"),
enableColumnFilter: true,
enableSorting: false,
meta: {
filter: {
type: ColumnFilterType.SINGLE_SELECT,
},
},
cell: () => null,
}),
columnHelper.accessor("eventTypeId", {
id: "eventTypeId",
header: t("event_type"),
size: 200,
meta: {
filter: {
type: ColumnFilterType.SINGLE_SELECT,
},
},
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
}),
];
}, [t]);
const table = useReactTable<DummyTableRow>({
data: emptyData,
columns,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedUniqueValues: getInsightsFacetedUniqueValues,
});
return { table };
};
@@ -0,0 +1,227 @@
import { createColumnHelper } from "@tanstack/react-table";
// eslint-disable-next-line no-restricted-imports
import startCase from "lodash/startCase";
import { useMemo } from "react";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { dataTableFilter, ColumnFilterType } from "@calcom/features/data-table";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RoutingFormFieldType } from "@calcom/routing-forms/lib/FieldTypes";
import { Badge } from "@calcom/ui";
import { BookedByCell } from "../components/BookedByCell";
import { BookingAtCell } from "../components/BookingAtCell";
import { BookingStatusBadge } from "../components/BookingStatusBadge";
import { ResponseValueCell } from "../components/ResponseValueCell";
import type { HeaderRow, RoutingFormTableRow } from "../lib/types";
import {
ZResponseMultipleValues,
ZResponseSingleValue,
ZResponseTextValue,
ZResponseNumericValue,
} from "../lib/types";
export const useInsightsColumns = ({
headers,
isHeadersSuccess,
}: {
headers: HeaderRow[] | undefined;
isHeadersSuccess: boolean;
}) => {
const { t } = useLocale();
return useMemo(() => {
if (!isHeadersSuccess) {
return [];
}
const columnHelper = createColumnHelper<RoutingFormTableRow>();
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("user"),
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 <BookedByCell attendees={info.getValue()} rowId={info.row.original.id} />;
},
}),
...((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<string, string>) ?? {};
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 && (
<ResponseValueCell
optionMap={optionMap}
values={Array.isArray(result.data.value) ? result.data.value : [result.data.value]}
rowId={info.row.original.id}
/>
)
);
} else if (isText || isNumber) {
const result = z.union([ZResponseTextValue, ZResponseNumericValue]).safeParse(values);
return (
result.success && (
<div className="truncate">
<span title={String(result.data.value)}>{result.data.value}</span>
</div>
)
);
} 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) => (
<div className="max-w-[250px]">
<BookingStatusBadge bookingStatus={info.row.original.bookingStatus} />
</div>
),
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) => (
<div className="max-w-[250px]">
<BookingAtCell row={info.row.original} rowId={info.row.original.id} />
</div>
),
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 <div className="max-w-[250px]">{assignmentReason}</div>;
},
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) => (
<div className="whitespace-nowrap">
<Badge variant="gray">{dayjs(info.getValue()).format("MMM D, YYYY HH:mm")}</Badge>
</div>
),
filterFn: (row, id, filterValue) => {
const createdAt = row.original.createdAt;
return dataTableFilter(createdAt, filterValue);
},
}),
];
}, [isHeadersSuccess, headers]);
};
@@ -0,0 +1,96 @@
import type { Table } from "@tanstack/react-table";
import { useCallback } from "react";
import { convertFacetedValuesToMap, type FacetedValue } from "@calcom/features/data-table";
import { BookingStatus } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
import { bookingStatusToText } from "../lib/bookingStatusToText";
import type { HeaderRow } from "../lib/types";
const statusOrder: Record<BookingStatus, number> = {
[BookingStatus.ACCEPTED]: 1,
[BookingStatus.PENDING]: 2,
[BookingStatus.AWAITING_HOST]: 3,
[BookingStatus.CANCELLED]: 4,
[BookingStatus.REJECTED]: 5,
};
export const useInsightsFacetedUniqueValues = ({
headers,
userId,
teamId,
isAll,
}: {
headers: HeaderRow[] | undefined;
userId: number | undefined;
teamId: number | undefined;
isAll: boolean;
}) => {
const { data: forms } = trpc.viewer.insights.getRoutingFormsForFilters.useQuery({
userId,
teamId,
isAll,
});
const { data: users } = trpc.viewer.insights.userList.useQuery({
teamId,
isAll,
});
const { data: eventTypes } = trpc.viewer.insights.eventTypeList.useQuery({
teamId,
userId,
isAll,
});
return useCallback(
(_: Table<any>, columnId: string) => (): Map<FacetedValue, number> => {
if (!headers) {
return new Map<FacetedValue, number>();
}
const fieldHeader = headers.find((h) => h.id === columnId);
if (fieldHeader?.options) {
return convertFacetedValuesToMap(
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 convertFacetedValuesToMap(
Object.keys(statusOrder).map((status) => ({
value: statusOrder[status as BookingStatus],
label: bookingStatusToText(status as BookingStatus),
}))
);
} else if (columnId === "formId") {
return convertFacetedValuesToMap(
forms?.map((form) => ({
label: form.name,
value: form.id,
})) ?? []
);
} else if (columnId === "bookingUserId") {
return convertFacetedValuesToMap(
users?.map((user) => ({
label: user.name ?? user.email,
value: user.id,
})) ?? []
);
} else if (columnId === "eventTypeId") {
return convertFacetedValuesToMap(
eventTypes?.map((eventType) => ({
value: eventType.id,
label: eventType.teamId ? `${eventType.title} (${eventType.team?.name})` : eventType.title,
})) ?? []
);
}
return new Map<FacetedValue, number>();
},
[headers, forms, users, eventTypes]
);
};
@@ -8,10 +8,11 @@ export function useInsightsOrgTeams() {
if (!context) {
throw new Error("useInsightsOrgTeams must be used within a InsightsOrgTeamsProvider");
}
const { orgTeamsType, selectedTeamId, setOrgTeamsType, setSelectedTeamId } = context;
const session = useSession();
const currentOrgId = session.data?.user.org?.id;
const { orgTeamsType, selectedTeamId, setOrgTeamsType, setSelectedTeamId } = context;
const isAll = orgTeamsType === "org";
const teamId = orgTeamsType === "team" ? selectedTeamId : undefined;
const teamId = orgTeamsType === "org" ? currentOrgId : orgTeamsType === "team" ? selectedTeamId : undefined;
const userId = orgTeamsType === "yours" ? session.data?.user.id : undefined;
return {
@@ -1,6 +1,5 @@
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import {
useFilterValue,
useColumnFilters,
@@ -8,6 +7,12 @@ import {
ZSingleSelectFilterValue,
ZDateRangeFilterValue,
} from "@calcom/features/data-table";
import {
getDefaultStartDate,
getDefaultEndDate,
CUSTOM_PRESET_VALUE,
type PresetOptionValue,
} from "@calcom/features/data-table/lib/dateRange";
import { useInsightsOrgTeams } from "./useInsightsOrgTeams";
@@ -17,26 +22,37 @@ export function useInsightsParameters() {
const memberUserIds = useFilterValue("bookingUserId", ZMultiSelectFilterValue)?.data as
| number[]
| undefined;
const memberUserId = useFilterValue("bookingUserId", ZSingleSelectFilterValue)?.data as number | undefined;
const eventTypeId = useFilterValue("eventTypeId", ZSingleSelectFilterValue)?.data as number | undefined;
const routingFormId = useFilterValue("formId", ZSingleSelectFilterValue)?.data as string | undefined;
const createdAtRange = useFilterValue("createdAt", ZDateRangeFilterValue)?.data;
const startDate = useMemo(
() => createdAtRange?.startDate ?? dayjs().subtract(1, "week").startOf("day").toISOString(),
() => createdAtRange?.startDate ?? getDefaultStartDate().toISOString(),
[createdAtRange?.startDate]
);
const endDate = useMemo(
() => createdAtRange?.endDate ?? dayjs().endOf("day").toISOString(),
() => createdAtRange?.endDate ?? getDefaultEndDate().toISOString(),
[createdAtRange?.endDate]
);
const columnFilters = useColumnFilters({ exclude: ["bookingUserId", "formId", "createdAt"] });
const dateRangePreset = useMemo<PresetOptionValue>(() => {
return (createdAtRange?.preset as PresetOptionValue) ?? CUSTOM_PRESET_VALUE;
}, [createdAtRange?.preset]);
const columnFilters = useColumnFilters({
exclude: ["bookingUserId", "formId", "createdAt", "eventTypeId"],
});
return {
isAll,
teamId,
userId,
memberUserIds,
memberUserId,
eventTypeId,
routingFormId,
startDate,
endDate,
dateRangePreset,
columnFilters,
};
}
@@ -0,0 +1,9 @@
import type { BookingStatus } from "@calcom/prisma/enums";
// Upper case the first letter of each word and replace underscores with spaces
export function bookingStatusToText(status: BookingStatus) {
return status
.toLowerCase()
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
+6
View File
@@ -1,5 +1,11 @@
import { z } from "zod";
import type { RouterOutputs } from "@calcom/trpc";
export type RoutingFormTableRow = RouterOutputs["viewer"]["insights"]["routingFormResponses"]["data"][number];
export type HeaderRow = RouterOutputs["viewer"]["insights"]["routingFormResponsesHeaders"][number];
export const ZResponseMultipleValues = z.object({
label: z.string(),
value: z.array(z.string()),
@@ -70,6 +70,23 @@ class RoutingEventsInsights {
teamIds = [teamId];
}
// Filter teamIds to only include teams the user has access to
if (teamIds.length > 0) {
const accessibleTeams = await prisma.membership.findMany({
where: {
userId: userId ?? -1,
teamId: {
in: teamIds,
},
accepted: true,
},
select: {
teamId: true,
},
});
teamIds = accessibleTeams.map((membership) => membership.teamId);
}
// Base where condition for forms
const formsWhereCondition: WhereForTeamOrAllTeams = {
...(teamIds.length > 0
@@ -145,7 +162,7 @@ class RoutingEventsInsights {
isAll,
organizationId,
}: {
userId?: number;
userId: number;
teamId?: number;
isAll: boolean;
organizationId?: number | undefined;
@@ -987,7 +987,7 @@ export const insightsRouter = router({
userList: authedProcedure
.input(
z.object({
teamId: z.coerce.number().nullable(),
teamId: z.number().optional(),
isAll: z.boolean().nullable(),
})
)
@@ -995,10 +995,6 @@ export const insightsRouter = router({
const user = ctx.user;
const { teamId, isAll } = input;
if (!teamId) {
return [];
}
if (isAll && user.organizationId && user.organization.isOrgAdmin) {
const usersInTeam = await ctx.insightsDb.membership.findMany({
where: {
@@ -1016,6 +1012,10 @@ export const insightsRouter = router({
return usersInTeam.map((membership) => membership.user);
}
if (!teamId) {
return [];
}
const membership = await ctx.insightsDb.membership.findFirst({
where: {
userId: user.id,
@@ -1552,7 +1552,7 @@ export const insightsRouter = router({
.query(async ({ ctx, input }) => {
const { userId, teamId, isAll } = input;
return await RoutingEventsInsights.getRoutingFormsForFilters({
userId,
userId: ctx.user.id,
teamId,
isAll,
organizationId: ctx.user.organizationId ?? undefined,
@@ -1569,7 +1569,7 @@ export const insightsRouter = router({
organizationId: ctx.user.organizationId ?? null,
routingFormId: input.routingFormId,
cursor: input.cursor,
userId: input.userId,
userId: ctx.user.id,
memberUserIds: input.memberUserIds,
limit: input.limit,
columnFilters: input.columnFilters,
@@ -1587,7 +1587,7 @@ export const insightsRouter = router({
organizationId: ctx.user.organizationId ?? null,
routingFormId: input.routingFormId,
cursor: input.cursor,
userId: input.userId,
userId: ctx.user.id,
memberUserIds: input.memberUserIds,
limit: input.limit,
columnFilters: input.columnFilters,
@@ -1605,7 +1605,7 @@ export const insightsRouter = router({
organizationId: ctx.user.organizationId ?? null,
routingFormId: input.routingFormId,
cursor: input.cursor,
userId: input.userId,
userId: ctx.user.id,
memberUserIds: input.memberUserIds,
limit: input.limit ?? BATCH_SIZE,
columnFilters: input.columnFilters,
@@ -1624,6 +1624,7 @@ export const insightsRouter = router({
.query(async ({ input, ctx }) => {
const options = await RoutingEventsInsights.getRoutingFormFieldOptions({
...input,
userId: ctx.user.id,
organizationId: ctx.user.organizationId ?? null,
});
return options;
@@ -1640,6 +1641,7 @@ export const insightsRouter = router({
.query(async ({ ctx, input }) => {
return await RoutingEventsInsights.getFailedBookingsByRoutingFormGroup({
...input,
userId: ctx.user.id,
organizationId: ctx.user.organizationId ?? null,
});
}),
@@ -1654,7 +1656,7 @@ export const insightsRouter = router({
)
.query(async ({ ctx, input }) => {
const headers = await RoutingEventsInsights.getRoutingFormHeaders({
userId: input.userId ?? null,
userId: ctx.user.id,
teamId: input.teamId ?? null,
isAll: input.isAll,
organizationId: ctx.user.organizationId ?? null,
@@ -1683,7 +1685,7 @@ export const insightsRouter = router({
input;
return await RoutingEventsInsights.routedToPerPeriod({
userId: userId ?? null,
userId: ctx.user.id,
teamId: teamId ?? null,
startDate,
endDate,
@@ -1709,7 +1711,7 @@ export const insightsRouter = router({
const { startDate, endDate } = input;
try {
const csvData = await RoutingEventsInsights.routedToPerPeriodCsv({
userId: input.userId ?? null,
userId: ctx.user.id,
teamId: input.teamId ?? null,
startDate,
endDate,
@@ -20,6 +20,7 @@ import {
singleSelectFilter,
multiSelectFilter,
ColumnFilterType,
convertFacetedValuesToMap,
} from "@calcom/features/data-table";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import classNames from "@calcom/lib/classNames";
@@ -458,13 +459,28 @@ function UserListTableContent() {
if (facetedTeamValues) {
switch (columnId) {
case "role":
return new Map(facetedTeamValues.roles.map((role) => [role, 1]));
return convertFacetedValuesToMap(
facetedTeamValues.roles.map((role) => ({
label: role,
value: role,
}))
);
case "teams":
return new Map(facetedTeamValues.teams.map((team) => [team.name, 1]));
return convertFacetedValuesToMap(
facetedTeamValues.teams.map((team) => ({
label: team.name,
value: team.name,
}))
);
default:
const attribute = facetedTeamValues.attributes.find((attr) => attr.id === columnId);
if (attribute) {
return new Map(attribute?.options.map(({ value }) => [value, 1]) ?? []);
return convertFacetedValuesToMap(
attribute?.options.map(({ value }) => ({
label: value,
value,
})) ?? []
);
}
return new Map();
}