2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,116 @@
import type { Column } from "@tanstack/react-table";
import type { LucideIcon } from "lucide-react";
import { classNames } from "@calcom/lib";
import { Icon } from "../..";
import { Badge } from "../badge";
import { Button } from "../button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "../command";
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
interface DataTableFilter<TData, TValue> {
column?: Column<TData, TValue>;
title?: string;
options: {
label: string;
value: string;
icon?: LucideIcon;
}[];
}
export function DataTableFilter<TData, TValue>({ column, title, options }: DataTableFilter<TData, TValue>) {
const facets = column?.getFacetedUniqueValues();
const selectedValues = new Set(column?.getFilterValue() as string[]);
return (
<Popover>
<PopoverTrigger asChild>
<Button color="secondary" size="sm" className="border-subtle h-8 rounded-md">
<Icon name="filter" className="mr-2 h-4 w-4" />
{title}
{selectedValues?.size > 0 && (
<>
<div className="ml-2 hidden space-x-1 md:flex">
{selectedValues.size > 2 ? (
<Badge color="gray" className="rounded-sm px-1 font-normal">
{selectedValues.size}
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge color="gray" key={option.value} className="rounded-sm px-1 font-normal">
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
// TODO: It would be nice to pull these from data instead of options
const isSelected = selectedValues.has(option.value);
return (
<CommandItem
key={option.value}
onSelect={() => {
if (isSelected) {
selectedValues.delete(option.value);
} else {
selectedValues.add(option.value);
}
const filterValues = Array.from(selectedValues);
column?.setFilterValue(filterValues.length ? filterValues : undefined);
}}>
<div
className={classNames(
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
isSelected ? "text-emphasis" : "opacity-50 [&_svg]:invisible"
)}>
<Icon name="check" className={classNames("h-4 w-4")} />
</div>
{option.icon && <option.icon className="text-muted mr-2 h-4 w-4" />}
<span>{option.label}</span>
{facets?.get(option.value) && (
<span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => column?.setFilterValue(undefined)}
className="justify-center text-center">
Clear filters
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,59 @@
import type { Table } from "@tanstack/react-table";
import { Button } from "../button";
interface DataTablePaginationProps<TData> {
table: Table<TData>;
}
export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) {
return (
<div className="flex items-center justify-between px-2">
<div className="text-muted-foreground flex-1 text-sm">
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
selected.
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex} of {table.getPageCount() - 1}
</div>
<div className="flex items-center space-x-2">
<Button
color="secondary"
variant="icon"
StartIcon="chevrons-left"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}>
<span className="sr-only">Go to first page</span>
</Button>
<Button
color="secondary"
variant="icon"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
StartIcon="chevron-left">
<span className="sr-only">Go to previous page</span>
</Button>
<Button
color="secondary"
variant="icon"
StartIcon="chevron-right"
className="h-8 w-8 p-0"
disabled={!table.getCanNextPage()}
onClick={() => table.nextPage()}>
<span className="sr-only">Go to next page</span>
</Button>
<Button
color="secondary"
variant="icon"
className="hidden h-8 w-8 p-0 lg:flex"
StartIcon="chevrons-right"
onClick={() => table.setPageIndex(table.getPageCount())}>
<span className="sr-only">Go to last page</span>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import type { Table } from "@tanstack/react-table";
import type { Table as TableType } from "@tanstack/table-core/build/lib/types";
import { AnimatePresence } from "framer-motion";
import { Fragment } from "react";
import type { IconName } from "../..";
import { Button } from "../button";
export type ActionItem<TData> =
| {
type: "action";
label: string;
onClick: () => void;
icon?: IconName;
needsXSelected?: number;
}
| {
type: "render";
render: (table: Table<TData>) => React.ReactNode;
needsXSelected?: number;
};
interface DataTableSelectionBarProps<TData> {
table: Table<TData>;
actions?: ActionItem<TData>[];
renderAboveSelection?: (table: TableType<TData>) => React.ReactNode;
}
export function DataTableSelectionBar<TData>({
table,
actions,
renderAboveSelection,
}: DataTableSelectionBarProps<TData>) {
const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
const isVisible = numberOfSelectedRows > 0;
// Hacky left % to center
const actionsVisible = actions?.filter((a) => {
if (!a.needsXSelected) return true;
return a.needsXSelected <= numberOfSelectedRows;
});
return (
<AnimatePresence>
{isVisible ? (
<div className="fade-in fixed bottom-6 left-1/2 hidden -translate-x-1/2 gap-1 md:flex md:flex-col">
{renderAboveSelection && renderAboveSelection(table)}
<div className="bg-brand-default text-brand hidden items-center justify-between rounded-lg p-2 md:flex">
<p className="text-brand-subtle w-full px-2 text-center leading-none">
{numberOfSelectedRows} selected
</p>
{actionsVisible?.map((action, index) => {
return (
<Fragment key={index}>
{action.type === "action" ? (
<Button aria-label={action.label} onClick={action.onClick} StartIcon={action.icon}>
{action.label}
</Button>
) : action.type === "render" ? (
action.render(table)
) : null}
</Fragment>
);
})}
</div>
</div>
) : null}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,99 @@
"use client";
import type { Table } from "@tanstack/react-table";
import type { LucideIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "../button";
import { Input } from "../form";
import { DataTableFilter } from "./DataTableFilter";
export type FilterableItems = {
title: string;
tableAccessor: string;
options: {
label: string;
value: string;
icon?: LucideIcon;
}[];
}[];
interface DataTableToolbarProps<TData> {
table: Table<TData>;
filterableItems?: FilterableItems;
searchKey?: string;
tableCTA?: React.ReactNode;
onSearch?: (value: string) => void;
}
export function DataTableToolbar<TData>({
table,
filterableItems,
tableCTA,
searchKey,
onSearch,
}: DataTableToolbarProps<TData>) {
// TODO: Is there a better way to check if the table is filtered?
// If you select ALL filters for a column, the table is not filtered and we dont get a reset button
const isFiltered = table.getState().columnFilters.length > 0;
const [searchTerm, setSearchTerm] = useState("");
const debouncedSearchTerm = useDebounce(searchTerm, 500);
useEffect(() => {
onSearch?.(debouncedSearchTerm);
}, [debouncedSearchTerm, onSearch]);
const { t } = useLocale();
return (
<div className="flex items-center justify-end py-4">
{searchKey && (
<Input
className="max-w-64 mb-0 mr-auto rounded-md"
placeholder="Search"
value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}
onChange={(event) => table.getColumn(searchKey)?.setFilterValue(event.target.value.trim())}
/>
)}
{onSearch && (
<Input
className="max-w-64 mb-0 mr-auto rounded-md"
placeholder="Search"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
}}
/>
)}
{isFiltered && (
<Button
color="minimal"
EndIcon="x"
onClick={() => table.resetColumnFilters()}
className="h-8 px-2 lg:px-3">
{t("clear")}
</Button>
)}
{filterableItems &&
filterableItems?.map((item) => {
const foundColumn = table.getColumn(item.tableAccessor);
if (foundColumn?.getCanFilter()) {
return (
<DataTableFilter
column={foundColumn}
title={item.title}
options={item.options}
key={item.title}
/>
);
}
})}
{tableCTA ? tableCTA : null}
</div>
);
}

View File

@@ -0,0 +1,78 @@
import type { ColumnDef } from "@tanstack/react-table";
import { Badge } from "../../badge";
import { Checkbox } from "../../form";
import type { FilterableItems } from "../DataTableToolbar";
import type { DataTableUserStorybook } from "./data";
export const columns: ColumnDef<DataTableUserStorybook>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
className="translate-y-[2px]"
/>
),
},
{
accessorKey: "username",
header: "Username",
},
{
accessorKey: "email",
header: "Email",
},
{
accessorKey: "role",
header: "Role",
cell: ({ row, table }) => {
const user = row.original;
const BadgeColor = user.role === "admin" ? "blue" : "gray";
return (
<Badge
color={BadgeColor}
onClick={() => {
table.getColumn("role")?.setFilterValue(user.role);
}}>
{user.role}
</Badge>
);
},
filterFn: (rows, id, filterValue) => {
return filterValue.includes(rows.getValue(id));
},
},
];
export const filterableItems: FilterableItems = [
{
title: "Role",
tableAccessor: "role",
options: [
{
label: "Admin",
value: "admin",
},
{
label: "User",
value: "user",
},
{
label: "Owner",
value: "owner",
},
],
},
];

View File

@@ -0,0 +1,98 @@
export type DataTableUserStorybook = {
id: string;
username: string;
email: string;
role: "admin" | "user";
};
export const dataTableSelectionActions = [
{
label: "Add To Team",
onClick: () => {
console.log("Add To Team");
},
icon: "users",
},
{
label: "Delete",
onClick: () => {
console.log("Delete");
},
icon: "stop-circle",
},
];
export const dataTableDemousers: DataTableUserStorybook[] = [
{
id: "728ed52f",
email: "m@example.com",
username: "m",
role: "admin",
},
{
id: "489e1d42",
email: "example@gmail.com",
username: "e",
role: "user",
},
{
id: "7b8a6f1d-2d2d-4d29-9c1a-0a8b3f5f9d2f",
email: "Keshawn_Schroeder@hotmail.com",
username: "Ava_Waelchi",
role: "user",
},
{
id: "f4d9e2a3-7e3c-4d6e-8e4c-8d0d7d1c2c9b",
email: "Jovanny_Grant@hotmail.com",
username: "Kamren_Gerhold",
role: "admin",
},
{
id: "1b2a4b6e-5b2d-4c38-9c7e-9d5e8f9c0a6a",
email: "Emilie.McKenzie@yahoo.com",
username: "Lennie_Harber",
role: "user",
},
{
id: "d6f3e6e9-9c2a-4c8a-8f3c-0d63a0eaf5a5",
email: "Jolie_Beatty@hotmail.com",
username: "Lorenzo_Will",
role: "admin",
},
{
id: "7c1e5d1d-8b9c-4b1c-9d1b-7d9f8b5a7e3e",
email: "Giovanny_Cruickshank@hotmail.com",
username: "Monserrat_Lang",
role: "user",
},
{
id: "f7d8b7a2-0a5c-4f8d-9f4f-8d1a2c3e4b3e",
email: "Lela_Haag@hotmail.com",
username: "Eddie_Effertz",
role: "user",
},
{
id: "2f8b9c8d-1a5c-4e3d-9b7a-6c5d4e3f2b1a",
email: "Lura_Kohler@gmail.com",
username: "Alyce_Olson",
role: "user",
},
{
id: "d8c7b6a5-4e3d-2b1a-9c8d-1f2e3d4c5b6a",
email: "Maurice.Koch@hotmail.com",
username: "Jovanny_Kiehn",
role: "admin",
},
{
id: "3c2b1a5d-4e3d-8c7b-9a6f-0d1e2f3g4h5i",
email: "Brenda_Bernhard@yahoo.com",
username: "Aurelia_Kemmer",
role: "user",
},
{
id: "e4d3c2b1-5e4d-3c2b-1a9f-8g7h6i5j4k3l",
email: "Lorenzo_Rippin@hotmail.com",
username: "Waino_Lang",
role: "admin",
},
];

View File

@@ -0,0 +1,67 @@
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import {
Examples,
Example,
Note,
Title,
CustomArgsTable,
VariantsTable,
VariantRow,
} from "@calcom/storybook/components";
import { DataTable } from "../";
import { columns, filterableItems } from "./columns";
import { dataTableDemousers, dataTableSelectionActions } from "./data";
<Meta title="UI/table/DataTable" component={DataTable} />
<Title title="DataTable" suffix="Brief" subtitle="Version 3.0 — Last Update: 28 Aug 2023" />
## Definition
The `DataTable` component facilitates tabular data display with configurable columns, virtual scrolling, filtering, and interactive features for seamless dynamic table creation.
## Structure
The `DataTable` setup for tabular data, with columns, virtual scroll, sticky headers, and interactive features like filtering and row selection.
<CustomArgsTable of={DataTable} />
## Dialog Story
<Canvas>
<Story
name="DataTable"
args={{
columns: columns,
data: dataTableDemousers,
isPending: false,
searchKey: "username",
filterableItems: filterableItems,
tableContainerRef: { current: null },
selectionOptions: dataTableSelectionActions,
}}
argTypes={{
tableContainerRef: { table: { disable: true } },
searchKey: {
control: {
type: "select",
options: ["username", "email"],
},
},
selectionOptions: { control: { type: "object" } },
onScroll: { table: { disable: true } },
tableOverlay: { table: { disable: true } },
CTA: { table: { disable: true } },
tableCTA: { table: { disable: true } },
}}>
{(args) => (
<VariantsTable titles={["Default"]} columnMinWidth={1000}>
<VariantRow>
<DataTable {...args} />
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@@ -0,0 +1,184 @@
import type {
ColumnDef,
ColumnFiltersState,
Row,
SortingState,
VisibilityState,
Table as TableType,
} from "@tanstack/react-table";
import {
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { useState } from "react";
import { useVirtual } from "react-virtual";
import classNames from "@calcom/lib/classNames";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../table/TableNew";
import type { ActionItem } from "./DataTableSelectionBar";
import { DataTableSelectionBar } from "./DataTableSelectionBar";
import type { FilterableItems } from "./DataTableToolbar";
import { DataTableToolbar } from "./DataTableToolbar";
export interface DataTableProps<TData, TValue> {
tableContainerRef: React.RefObject<HTMLDivElement>;
columns: ColumnDef<TData, TValue>[];
data: TData[];
searchKey?: string;
onSearch?: (value: string) => void;
filterableItems?: FilterableItems;
selectionOptions?: ActionItem<TData>[];
renderAboveSelection?: (table: TableType<TData>) => React.ReactNode;
tableCTA?: React.ReactNode;
isPending?: boolean;
onRowMouseclick?: (row: Row<TData>) => void;
onScroll?: (e: React.UIEvent<HTMLDivElement, UIEvent>) => void;
CTA?: React.ReactNode;
tableOverlay?: React.ReactNode;
variant?: "default" | "compact";
"data-testId"?: string;
}
export function DataTable<TData, TValue>({
columns,
data,
filterableItems,
tableCTA,
searchKey,
selectionOptions,
tableContainerRef,
isPending,
tableOverlay,
variant,
renderAboveSelection,
/** This should only really be used if you dont have actions in a row. */
onSearch,
onRowMouseclick,
onScroll,
...rest
}: DataTableProps<TData, TValue>) {
const [rowSelection, setRowSelection] = useState({});
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
},
enableRowSelection: true,
debugTable: true,
manualPagination: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
});
const { rows } = table.getRowModel();
const rowVirtualizer = useVirtual({
parentRef: tableContainerRef,
size: rows.length,
overscan: 10,
});
const { virtualItems: virtualRows, totalSize } = rowVirtualizer;
const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0;
const paddingBottom =
virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0;
return (
<div className="relative space-y-4">
<DataTableToolbar
table={table}
filterableItems={filterableItems}
searchKey={searchKey}
onSearch={onSearch}
tableCTA={tableCTA}
/>
<div ref={tableContainerRef} onScroll={onScroll} data-testId={rest["data-testId"] ?? "data-table"}>
<Table data-testId="">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{paddingTop > 0 && (
<tr>
<td style={{ height: `${paddingTop}px` }} />
</tr>
)}
{virtualRows && !isPending ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index] as Row<TData>;
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
onClick={() => onRowMouseclick && onRowMouseclick(row)}
className={classNames(
onRowMouseclick && "hover:cursor-pointer",
variant === "compact" && "!border-0"
)}>
{row.getVisibleCells().map((cell) => {
return (
<TableCell key={cell.id} className={classNames(variant === "compact" && "p-1.5")}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
{paddingBottom > 0 && (
<tr>
<td style={{ height: `${paddingBottom}px` }} />
</tr>
)}
</TableBody>
{tableOverlay && tableOverlay}
</Table>
</div>
{/* <DataTablePagination table={table} /> */}
<DataTableSelectionBar
table={table}
actions={selectionOptions}
renderAboveSelection={renderAboveSelection}
/>
</div>
);
}