fix: large org routing insights UI issues (#17687)

* fix overflow colour in darkmode

* truncate/show overflow scroll indicator when overflowing on response values

* remove total created count

* overflow Y on failed response container

* fix darkmode styles on barlist

* radix hovercards to properly allow overflow in container

* column visible state

* remove constrained width on dropdowns to w-fit

* export type
This commit is contained in:
sean-brydon
2024-11-16 11:01:26 -03:00
committed by GitHub
parent 7fef3e7b85
commit b1a0cdc8e2
12 changed files with 296 additions and 50 deletions
@@ -1,9 +1,12 @@
"use client";
import { useState } from "react";
import {
RoutingFormResponsesTable,
RoutingKPICards,
FailedBookingsByField,
type RoutingFormTableType,
} from "@calcom/features/insights/components";
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
import { Filters } from "@calcom/features/insights/filters";
@@ -13,16 +16,17 @@ import InsightsLayout from "./layout";
export default function InsightsPage() {
const { t } = useLocale();
const [routingTable, setRoutingTable] = useState<RoutingFormTableType | null>(null);
return (
<InsightsLayout>
<FiltersProvider>
<Filters showRoutingFilters />
<Filters showRoutingFilters routingTable={routingTable} />
<div className="mb-4 space-y-4">
<RoutingKPICards />
<RoutingFormResponsesTable />
<RoutingFormResponsesTable onTableReady={setRoutingTable} />
<FailedBookingsByField />
@@ -1,4 +1,3 @@
import { BarList } from "@tremor/react";
import { useState } from "react";
import { classNames } from "@calcom/lib";
@@ -6,6 +5,7 @@ import { trpc } from "@calcom/trpc";
import { ToggleGroup } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
import { BarList } from "./tremor/BarList";
interface FormCardProps {
formName: string;
@@ -27,7 +27,7 @@ function FormCard({ formName, fields }: FormCardProps) {
}));
return (
<div className="border-subtle h-[400px] w-full rounded-md border">
<div className="border-subtle w-full rounded-md border">
<div className="flex flex-col">
<div className="bg-subtle border-subtle flex h-12 items-center border-b">
<h3 className="text-default px-2 text-left align-middle font-medium">{formName}</h3>
@@ -40,7 +40,7 @@ function FormCard({ formName, fields }: FormCardProps) {
onValueChange={(value) => value && setSelectedField(value)}
/>
{selectedFieldData && (
<div className="scrollbar-thin mt-4 h-full overflow-y-auto">
<div className="scrollbar-thin mt-4 h-[400px] overflow-y-auto">
<BarList
data={selectedFieldData}
valueFormatter={(value: number) => value.toString()}
@@ -9,7 +9,7 @@ import {
createColumnHelper,
} from "@tanstack/react-table";
import Link from "next/link";
import { useRef, useMemo, useId } from "react";
import { useRef, useMemo, useId, useEffect } from "react";
import dayjs from "@calcom/dayjs";
import classNames from "@calcom/lib/classNames";
@@ -17,7 +17,16 @@ import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { BookingStatus } from "@calcom/prisma/enums";
import { trpc, type RouterOutputs } from "@calcom/trpc";
import { DataTable, useFetchMoreOnBottomReached, Badge, Avatar, Icon } from "@calcom/ui";
import {
DataTable,
useFetchMoreOnBottomReached,
Badge,
Avatar,
Icon,
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@calcom/ui";
import type { BadgeProps } from "@calcom/ui/components/badge/Badge";
import { useFilterContext } from "../context/provider";
@@ -36,7 +45,7 @@ function CellWithOverflowX({ children, className }: { children: React.ReactNode;
return (
<div className={classNames("group relative max-w-[200px]", className)}>
<div
className="no-scrollbar overflow-x-auto whitespace-nowrap"
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;
@@ -51,7 +60,7 @@ function CellWithOverflowX({ children, className }: { children: React.ReactNode;
}}>
{children}
</div>
<div className="absolute right-0 top-0 hidden h-full w-8 bg-gradient-to-l from-white to-transparent group-hover:from-gray-100" />
<div className="from-default absolute right-0 top-0 hidden h-full w-8 bg-gradient-to-l to-transparent " />
</div>
);
}
@@ -92,7 +101,7 @@ function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number })
if (value.length === 0) return <div className="h-6 w-[200px]" />;
return (
<div className="flex w-[200px] flex-wrap gap-1">
<CellWithOverflowX className="flex w-[200px] gap-1">
{value.length > 2 ? (
<>
{value.slice(0, 2).map((v: string, i: number) => (
@@ -100,18 +109,20 @@ function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number })
{v}
</Badge>
))}
<div className="group/badge relative">
<Badge variant="gray">+{value.length - 2}</Badge>
<div className="bg-default invisible absolute left-0 top-full z-20 translate-y-[-8px] rounded-md p-2 opacity-0 shadow-md transition-all duration-200 group-hover/badge:visible group-hover/badge:translate-y-0 group-hover/badge:opacity-100">
<HoverCard>
<HoverCardTrigger>
<Badge variant="gray">+{value.length - 2}</Badge>
</HoverCardTrigger>
<HoverCardContent side="bottom" align="start" className="w-fit">
<div className="flex flex-col gap-1">
{value.slice(2).map((v: string, i: number) => (
<span key={`${cellId}-overflow-${i}-${rowId}`} className="text-sm text-gray-600">
<span key={`${cellId}-overflow-${i}-${rowId}`} className="text-default text-sm">
{v}
</span>
))}
</div>
</div>
</div>
</HoverCardContent>
</HoverCard>
</>
) : (
value.map((v: string, i: number) => (
@@ -120,7 +131,7 @@ function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number })
</Badge>
))
)}
</div>
</CellWithOverflowX>
);
}
@@ -158,12 +169,16 @@ function BookingStatusCell({
}
return (
<div className="group/booking_status relative flex items-center gap-2" key={`${cellId}-booking-${rowId}`}>
<Avatar size="xs" imageSrc={booking.user.avatarUrl ?? ""} alt={booking.user.name ?? ""} />
<Link href={`/booking/${booking.uid}`}>
<Badge variant={badgeVariant}>{dayjs(booking.createdAt).format("MMM D, YYYY HH:mm")}</Badge>
</Link>
<div className="bg-default invisible absolute left-0 top-full z-20 translate-y-[-8px] rounded-md p-2 opacity-0 shadow-md transition-all duration-200 group-hover/booking_status:visible group-hover/booking_status:translate-y-0 group-hover/booking_status:opacity-100 ">
<HoverCard>
<HoverCardTrigger asChild>
<div className="flex items-center gap-2" key={`${cellId}-booking-${rowId}`}>
<Avatar size="xs" imageSrc={booking.user.avatarUrl ?? ""} alt={booking.user.name ?? ""} />
<Link href={`/booking/${booking.uid}`}>
<Badge variant={badgeVariant}>{dayjs(booking.createdAt).format("MMM D, YYYY HH:mm")}</Badge>
</Link>
</div>
</HoverCardTrigger>
<HoverCardContent>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<Avatar size="sm" imageSrc={booking.user.avatarUrl ?? ""} alt={booking.user.name ?? ""} />
@@ -188,12 +203,18 @@ function BookingStatusCell({
<Badge variant={badgeVariant}>{bookingStatusToText(booking.status)}</Badge>
</div>
</div>
</div>
</div>
</HoverCardContent>
</HoverCard>
);
}
export function RoutingFormResponsesTable() {
export type RoutingFormTableType = ReturnType<typeof useReactTable<RoutingFormTableRow>>;
export function RoutingFormResponsesTable({
onTableReady,
}: {
onTableReady?: (table: RoutingFormTableType) => void;
}) {
const { t } = useLocale();
const { filter } = useFilterContext();
const tableContainerRef = useRef<HTMLDivElement>(null);
@@ -345,6 +366,10 @@ export function RoutingFormResponsesTable() {
},
});
useEffect(() => {
onTableReady?.(table);
}, [table, onTableReady]);
const fetchMoreOnBottomReached = useFetchMoreOnBottomReached(
tableContainerRef,
fetchNextPage,
@@ -51,17 +51,8 @@ export const RoutingKPICards = () => {
const categories: {
title: string;
index:
| "created"
| "active"
| "total_responses"
| "total_responses_without_booking"
| "total_responses_with_booking";
index: "active" | "total_responses" | "total_responses_without_booking" | "total_responses_with_booking";
}[] = [
{
title: t("routing_forms_created"),
index: "created",
},
{
title: t("routing_forms_total_responses"),
index: "total_responses",
@@ -84,7 +75,7 @@ export const RoutingKPICards = () => {
return (
<>
<Grid numColsSm={2} numColsLg={4} className="mt-4 gap-x-4 gap-y-4">
<Grid numColsSm={1} numColsLg={3} className="mt-4 gap-x-4 gap-y-4">
{categories.map((item) => (
<CardInsights key={item.title}>
<Text className="text-default">{item.title}</Text>
@@ -9,5 +9,5 @@ export { RecentFeedbackTable } from "./RecentFeedbackTable";
export { HighestNoShowHostTable } from "./HighestNoShowHostTable";
export { HighestRatedMembersTable } from "./HighestRatedMembersTable";
export { LowestRatedMembersTable } from "./LowestRatedMembersTable";
export { RoutingFormResponsesTable } from "./RoutingFormResponsesTable";
export { RoutingFormResponsesTable, type RoutingFormTableType } from "./RoutingFormResponsesTable";
export { FailedBookingsByField } from "./FailedBookingsByField";
@@ -0,0 +1,165 @@
// Tremor BarList [v0.1.1]
import React from "react";
import { classNames } from "@calcom/lib";
type Bar<T> = T & {
key?: string;
href?: string;
value: number;
name: string;
};
const focusRing = [
// base
"outline outline-offset-2 outline-0 focus-visible:outline-2",
// outline color
"outline-brand",
];
interface BarListProps<T = unknown> extends React.HTMLAttributes<HTMLDivElement> {
data: Bar<T>[];
valueFormatter?: (value: number) => string;
showAnimation?: boolean;
onValueChange?: (payload: Bar<T>) => void;
sortOrder?: "ascending" | "descending" | "none";
}
function BarListInner<T>(
{
data = [],
valueFormatter = (value) => value.toString(),
showAnimation = false,
onValueChange,
sortOrder = "descending",
className,
...props
}: BarListProps<T>,
forwardedRef: React.ForwardedRef<HTMLDivElement>
) {
const Component = onValueChange ? "button" : "div";
const sortedData = React.useMemo(() => {
if (sortOrder === "none") {
return data;
}
return [...data].sort((a, b) => {
return sortOrder === "ascending" ? a.value - b.value : b.value - a.value;
});
}, [data, sortOrder]);
const widths = React.useMemo(() => {
const maxValue = Math.max(...sortedData.map((item) => item.value), 0);
return sortedData.map((item) => (item.value === 0 ? 0 : Math.max((item.value / maxValue) * 100, 2)));
}, [sortedData]);
const rowHeight = "h-8";
return (
<div
ref={forwardedRef}
className={classNames("flex justify-between space-x-6", className)}
aria-sort={sortOrder}
tremor-id="tremor-raw"
{...props}>
<div className="relative w-full space-y-1.5">
{sortedData.map((item, index) => (
<Component
key={item.key ?? item.name}
onClick={() => {
onValueChange?.(item);
}}
className={classNames(
// base
"group w-full rounded",
// focus
focusRing,
onValueChange
? [
"!-m-0 cursor-pointer",
// hover
"hover:bg-gray-50 hover:dark:bg-gray-900",
]
: ""
)}>
<div
className={classNames(
// base
"flex items-center rounded transition-all",
rowHeight,
// background color
"bg-blue-200 dark:bg-blue-900",
onValueChange ? "group-hover:bg-blue-300 group-hover:dark:bg-blue-800" : "",
// margin and duration
{
"mb-0": index === sortedData.length - 1,
"duration-800": showAnimation,
}
)}
style={{ width: `${widths[index]}%` }}>
<div className={classNames("absolute left-2 flex max-w-full pr-2")}>
{item.href ? (
<a
href={item.href}
className={classNames(
// base
"truncate whitespace-nowrap rounded text-sm",
// text color
"text-default",
// hover
"hover:underline hover:underline-offset-2",
// focus
focusRing
)}
target="_blank"
rel="noreferrer"
onClick={(event) => event.stopPropagation()}>
{item.name}
</a>
) : (
<p
className={classNames(
// base
"truncate whitespace-nowrap text-sm",
// text color
"text-default"
)}>
{item.name}
</p>
)}
</div>
</div>
</Component>
))}
</div>
<div>
{sortedData.map((item, index) => (
<div
key={item.key ?? item.name}
className={classNames(
"flex items-center justify-end",
rowHeight,
index === sortedData.length - 1 ? "mb-0" : "mb-1.5"
)}>
<p
className={classNames(
// base
"truncate whitespace-nowrap text-sm leading-none",
// text color
"text-default"
)}>
{valueFormatter(item.value)}
</p>
</div>
))}
</div>
</div>
);
}
BarListInner.displayName = "BarList";
const BarList = React.forwardRef(BarListInner) as <T>(
p: BarListProps<T> & { ref?: React.ForwardedRef<HTMLDivElement> }
) => ReturnType<typeof BarListInner>;
export { BarList, type BarListProps };
@@ -108,9 +108,9 @@ export const FilterType = ({ showRoutingFilters = false }: { showRoutingFilters?
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="z-50 w-44">
<DropdownMenuContent className="z-50">
{filterOptions?.map((option) => (
<DropdownMenuItem key={option.label} className="w-44">
<DropdownMenuItem key={option.label} className="w-full">
<DropdownItem
type="button"
StartIcon={option.StartIcon}
@@ -124,9 +124,11 @@ export const FilterType = ({ showRoutingFilters = false }: { showRoutingFilters?
});
}}
childrenClassName="w-full">
<div className="flex w-full items-center justify-between">
{t(option.label)}
{selectedFilter?.includes(option.value) && <Icon name="check" className="h-4 w-4" />}
<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>
+11 -2
View File
@@ -1,6 +1,7 @@
import type { RoutingFormTableType } from "@calcom/features/insights/components/RoutingFormResponsesTable";
import { useFilterContext } from "@calcom/features/insights/context/provider";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Icon, Tooltip } from "@calcom/ui";
import { Button, Icon, Tooltip, DataTableFilters } from "@calcom/ui";
import { BookingStatusFilter } from "./BookingStatusFilter";
import { DateSelect } from "./DateSelect";
@@ -37,7 +38,13 @@ const ClearFilters = () => {
);
};
export const Filters = ({ showRoutingFilters = false }: { showRoutingFilters?: boolean }) => {
export const Filters = ({
showRoutingFilters = false,
routingTable = null,
}: {
showRoutingFilters?: boolean;
routingTable?: RoutingFormTableType | null;
}) => {
const { filter } = useFilterContext();
const { selectedFilter } = filter;
@@ -98,6 +105,8 @@ export const Filters = ({ showRoutingFilters = false }: { showRoutingFilters?: b
<div className="flex flex-col-reverse sm:flex-row sm:flex-nowrap sm:justify-between">
{showRoutingFilters ? <RoutingDownload /> : <Download />}
<DateSelect />
{routingTable && <DataTableFilters.ColumnVisibilityButton table={routingTable} />}
</div>
</div>
);
@@ -41,6 +41,9 @@ function ColumnVisibilityButtonComponent<TData>(
const { t } = useLocale();
const allColumns = table.getAllLeafColumns();
const [open, setOpen] = useState(false);
const [visibleColumns, setVisibleColumns] = useState(
() => new Set(allColumns.filter((col) => col.getIsVisible()).map((col) => col.id))
);
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -59,15 +62,28 @@ function ColumnVisibilityButtonComponent<TData>(
const canHide = column.getCanHide();
if (!column.columnDef.header || typeof column.columnDef.header !== "string" || !canHide)
return null;
const isVisible = column.getIsVisible();
const isVisible = visibleColumns.has(column.id);
return (
<CommandItem key={column.id} onSelect={() => column.toggleVisibility(!isVisible)}>
<CommandItem
key={column.id}
onSelect={() => {
column.toggleVisibility(!isVisible);
setVisibleColumns((prev) => {
const next = new Set(prev);
if (isVisible) {
next.delete(column.id);
} else {
next.add(column.id);
}
return next;
});
}}>
<div
className={classNames(
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
isVisible ? "text-emphasis" : "opacity-50 [&_svg]:invisible"
)}>
<Icon name="check" className={classNames("h-4 w-4")} />
<Icon name="check" className={classNames("h-4 w-4", !isVisible && "invisible")} />
</div>
{column.columnDef.header}
</CommandItem>
@@ -78,7 +94,10 @@ function ColumnVisibilityButtonComponent<TData>(
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => allColumns.forEach((column) => column.toggleVisibility(true))}
onSelect={() => {
allColumns.forEach((column) => column.toggleVisibility(true));
setVisibleColumns(new Set(allColumns.map((col) => col.id)));
}}
className={classNames(
"w-full justify-center text-center",
buttonClasses({ color: "secondary" })
+1 -1
View File
@@ -39,7 +39,7 @@ export const DropdownMenuContent = forwardRef<HTMLDivElement, DropdownMenuConten
{...props}
sideOffset={sideOffset}
className={classNames(
"shadow-dropdown w-50 bg-default border-subtle relative z-10 ml-1.5 origin-top-right rounded-md border text-sm",
"shadow-dropdown bg-default border-subtle relative z-10 ml-1.5 origin-top-right rounded-md border text-sm",
"[&>*:first-child]:mt-1 [&>*:last-child]:mb-1",
props.className
)}
@@ -0,0 +1,29 @@
"use client";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import * as React from "react";
import { classNames } from "@calcom/lib";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={classNames(
"bg-default text-default data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-subtle z-50 w-64 rounded-md border p-2 shadow-sm outline-none",
className
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };
+2
View File
@@ -170,6 +170,8 @@ export {
CommandShortcut,
} from "./components/command";
export { HoverCard, HoverCardTrigger, HoverCardContent } from "./components/hover-card";
export { OrgBanner } from "./components/organization-banner";
export type { OrgBannerProps } from "./components/organization-banner";