feat: add text & numeric filters to insights routing responses (#18016)

* feat: improve text filters (WIP)

* move function to bottom

* apply some styles

* fix selection of TextFilterOptions

* rename value to operand

* remove unused file

* merge filters/filters into filters/utils

* fix regression of not putting url params correctly

* move makeWhereClause to filters/utils

* fix negative, empty, and not empty operators

* fix initial filtering from search state (url)

* fix type errors

* do not send an empty array to query

* update yarn.lock

* i18n for text filter operators

* extract logic as useColumnFilters()

* add missing import

* fix type error

* revert yarn.lock

* use i18n

* insensitive text match

* move data-table to @calcom/features

* fix type errors

* fix type errors

* fix type errors

* feat: support DataTable filters for Insights Routing WIP

* remove unused filters

* remove additionalFilters and fix types

* clean up filter components

* support icons for ActiveFilters

* support filters on json

* fix filter ui

* fix type error and clean up

* revert changes

* revert change

* clean up

* revert change

* fix compatibility with insights booking page

* remove unused params

* fix type errors

* update yarn.lock

* fix field filter and adjust ui

* chore: update yarn.lock

* fix text filter

* add Clear Filters button

* add more test data

* feat: support numeric filter WIP

* feat: support numeric filter

* feat: move People to an external filter

* fixing query states

* rename state to activeFilters

* add useDataTable

* fix ResponseCellValue bug and enable resizing for RoutingFormResponsesTable

* fix response values

* truncate attribute text

* seed booking attendees

* fix accessor (no actual change though)

* add a gap

* rename

* use pathname as default identifier for DataTable

* fix type error

* support text filter for assignment reason

* Apply suggestions from code review

Co-authored-by: Alex van Andel <me@alexvanandel.com>

* rename fields to headers

* use safeParse

* fix type error

* push yarn.lock with faker

* fix MemberList

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: sean-brydon <sean@cal.com>
This commit is contained in:
Eunjae Lee
2024-12-12 16:06:30 +00:00
committed by GitHub
co-authored by Alex van Andel Udit Takkar sean-brydon
parent e366c10521
commit a7e7561efa
36 changed files with 1162 additions and 482 deletions
@@ -1,35 +1,19 @@
"use client";
import {
FailedBookingsByField,
RoutingFormResponsesTable,
RoutingKPICards,
RoutedToPerPeriod,
} from "@calcom/features/insights/components";
import { FailedBookingsByField, RoutingFormResponsesTable, RoutedToPerPeriod } from "@calcom/features/insights/components";
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
import { RoutingInsightsFilters } from "@calcom/features/insights/filters/routing/FilterBar";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import InsightsLayout from "./layout";
export default function InsightsPage() {
export default function InsightsRoutingFormResponsesPage() {
const { t } = useLocale();
return (
<InsightsLayout>
<FiltersProvider>
<div className="mb-4 space-y-4">
<RoutingFormResponsesTable>
{/* We now render the "filters and KPI as a children of the table but we still need to pass the table instance to it so we can access column status in the toolbar.*/}
{(table) => (
<div className="header mb-4">
<div className="flex items-center justify-between">
<RoutingInsightsFilters table={table} />
</div>
<RoutingKPICards />
</div>
)}
</RoutingFormResponsesTable>
<RoutingFormResponsesTable />
<RoutedToPerPeriod />
+8
View File
@@ -51,6 +51,14 @@
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" ></path>
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" ></path>
</symbol>
<symbol class="lucide lucide-binary" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="binary">
<rect x="14" y="14" width="4" height="6" rx="2" ></rect>
<rect x="6" y="4" width="4" height="6" rx="2" ></rect>
<path d="M6 20h4" ></path>
<path d="M14 10h4" ></path>
<path d="M6 14h2v6" ></path>
<path d="M14 4h2v6" ></path>
</symbol>
<symbol class="lucide lucide-blocks" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="blocks">
<rect width="7" height="7" x="14" y="3" rx="1" ></rect>
<path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3" ></path>

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

+1 -1
View File
@@ -980,7 +980,7 @@ export default class EventManager {
const createdEvent = await crm.createEvent(event, currentAppOption).catch((error) => {
success = false;
// We don't know the type of the error here, so for an Error instance we can read message but otherwise we stringify the error
let errorMsg = error instanceof Error ? error.message : JSON.stringify(error);
const errorMsg = error instanceof Error ? error.message : JSON.stringify(error);
log.warn(`Error creating crm event for ${credential.type}`, errorMsg);
});
+1 -1
View File
@@ -158,7 +158,7 @@ const AdminAppsList = ({
<form
{...rest}
className={
classNames?.form ?? "max-w-80 bg-default mb-4 rounded-md px-0 pt-0 md:max-w-full md:px-8 md:pt-10"
classNames?.form ?? "bg-default mb-4 max-w-80 rounded-md px-0 pt-0 md:max-w-full md:px-8 md:pt-10"
}
onSubmit={(e) => {
e.preventDefault();
@@ -6,6 +6,7 @@ import type { Table as ReactTableType } from "@tanstack/react-table";
import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual";
// eslint-disable-next-line no-restricted-imports
import kebabCase from "lodash/kebabCase";
import { usePathname } from "next/navigation";
import { useMemo, useEffect, memo } from "react";
import classNames from "@calcom/lib/classNames";
@@ -23,8 +24,10 @@ export interface DataTableProps<TData, TValue> {
variant?: "default" | "compact";
"data-testid"?: string;
children?: React.ReactNode;
enableColumnResizing?: { name: string };
identifier?: string;
enableColumnResizing?: boolean;
}
export function DataTable<TData, TValue>({
table,
tableContainerRef,
@@ -33,9 +36,13 @@ export function DataTable<TData, TValue>({
onRowMouseclick,
onScroll,
children,
identifier: _identifier,
enableColumnResizing,
...rest
}: DataTableProps<TData, TValue> & React.ComponentPropsWithoutRef<"div">) {
const pathname = usePathname();
const identifier = _identifier ?? pathname;
const { rows } = table.getRowModel();
// https://stackblitz.com/github/tanstack/table/tree/main/examples/react/virtualized-infinite-scrolling
@@ -82,7 +89,7 @@ export function DataTable<TData, TValue>({
usePersistentColumnResizing({
enabled: Boolean(enableColumnResizing),
table,
name: enableColumnResizing?.name,
identifier,
});
return (
@@ -10,6 +10,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { ButtonProps } from "@calcom/ui";
import { Button, Input } from "@calcom/ui";
import { useColumnFilters } from "../lib/utils";
interface DataTableToolbarProps extends ComponentPropsWithoutRef<"div"> {
children: React.ReactNode;
}
@@ -89,7 +91,8 @@ function ClearFiltersButtonComponent<TData>(
ref: React.Ref<HTMLButtonElement>
) {
const { t } = useLocale();
const isFiltered = table.getState().columnFilters.length > 0;
const columnFilters = useColumnFilters();
const isFiltered = columnFilters.length > 0;
if (!isFiltered) return null;
return (
<Button
@@ -1,58 +1,21 @@
import { type Table } from "@tanstack/react-table";
"use client";
import type { FilterableColumn, FilterValue, SelectFilterValue, TextFilterValue } from "../../lib/types";
import type { ActiveFilter, FiltersSearchState, SetFiltersSearchState } from "../../lib/utils";
import type { FilterableColumn } from "../../lib/types";
import { MultiSelectFilterOptions } from "./MultiSelectFilterOptions";
import { NumberFilterOptions } from "./NumberFilterOptions";
import { TextFilterOptions } from "./TextFilterOptions";
export type FilterOptionsProps<TData> = {
export type FilterOptionsProps = {
column: FilterableColumn;
filter: ActiveFilter;
state: FiltersSearchState;
setState: SetFiltersSearchState;
table: Table<TData>;
};
export function FilterOptions<TData>({ column, filter, state, setState, table }: FilterOptionsProps<TData>) {
const filterValue = table.getColumn(column.id)?.getFilterValue() as FilterValue | undefined;
const setMultiSelectFilterValue = (value: SelectFilterValue) => {
setState({
activeFilters: state.activeFilters.map((item) => (item.f === filter.f ? { ...item, v: value } : item)),
});
table.getColumn(column.id)?.setFilterValue(value);
};
const setTextFilterValue = (value: TextFilterValue) => {
setState({
activeFilters: state.activeFilters.map((item) => (item.f === filter.f ? { ...item, v: value } : item)),
});
table.getColumn(column.id)?.setFilterValue(value);
};
const removeFilter = (columnId: string) => {
setState({ activeFilters: (state.activeFilters || []).filter((filter) => filter.f !== columnId) });
table.getColumn(columnId)?.setFilterValue(undefined);
};
if (column.filterType === "text") {
return (
<TextFilterOptions
column={column}
filterValue={filterValue as TextFilterValue | undefined}
setFilterValue={setTextFilterValue}
removeFilter={removeFilter}
/>
);
} else if (column.filterType === "select") {
return (
<MultiSelectFilterOptions
column={column}
filterValue={filterValue as SelectFilterValue | undefined}
setFilterValue={setMultiSelectFilterValue}
removeFilter={removeFilter}
/>
);
export function FilterOptions({ column }: FilterOptionsProps) {
if (column.type === "text") {
return <TextFilterOptions column={column} />;
} else if (column.type === "select") {
return <MultiSelectFilterOptions column={column} />;
} else if (column.type === "number") {
return <NumberFilterOptions column={column} />;
} else {
return null;
}
@@ -1,3 +1,5 @@
"use client";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import {
@@ -12,22 +14,18 @@ import {
Icon,
} from "@calcom/ui";
import type { FilterableColumn, SelectFilterValue } from "../../lib/types";
import type { FilterableColumn } from "../../lib/types";
import { ZSelectFilterValue } from "../../lib/types";
import { useDataTable, useFilterValue } from "../../lib/utils";
export type MultiSelectFilterOptionsProps = {
column: FilterableColumn;
filterValue?: SelectFilterValue;
setFilterValue: (value: SelectFilterValue) => void;
removeFilter: (columnId: string) => void;
column: Extract<FilterableColumn, { type: "select" }>;
};
export function MultiSelectFilterOptions({
column,
filterValue,
setFilterValue,
removeFilter,
}: MultiSelectFilterOptionsProps) {
export function MultiSelectFilterOptions({ column }: MultiSelectFilterOptionsProps) {
const { t } = useLocale();
const filterValue = useFilterValue(column.id, ZSelectFilterValue);
const { updateFilter, removeFilter } = useDataTable();
return (
<Command>
@@ -36,27 +34,30 @@ export function MultiSelectFilterOptions({
<CommandEmpty>{t("no_options_found")}</CommandEmpty>
{Array.from(column.options.keys()).map((option) => {
if (!option) return null;
const { label: optionLabel, value: optionValue } =
typeof option === "string" ? { label: option, value: option } : option;
return (
<CommandItem
key={option}
key={optionValue}
onSelect={() => {
const newFilterValue = filterValue?.includes(option)
? filterValue?.filter((value) => value !== option)
: [...(filterValue || []), option];
setFilterValue(newFilterValue);
const newFilterValue = filterValue?.includes(optionValue)
? filterValue?.filter((value) => value !== optionValue)
: [...(filterValue || []), optionValue];
updateFilter(column.id, newFilterValue);
}}>
<div
className={classNames(
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
Array.isArray(filterValue) && (filterValue as string[])?.includes(option)
Array.isArray(filterValue) && (filterValue as string[])?.includes(optionValue)
? "bg-primary"
: "opacity-50"
)}>
{Array.isArray(filterValue) && (filterValue as string[])?.includes(option) && (
{Array.isArray(filterValue) && (filterValue as string[])?.includes(optionValue) && (
<Icon name="check" className="text-primary-foreground h-4 w-4" />
)}
</div>
{option}
{optionLabel}
</CommandItem>
);
})}
@@ -0,0 +1,103 @@
"use client";
import { useForm, Controller } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Form, Input, Select, Button } from "@calcom/ui";
import type { FilterableColumn, NumberFilterOperator } from "../../lib/types";
import { ZNumberFilterValue } from "../../lib/types";
import { useFilterValue, useDataTable } from "../../lib/utils";
export type NumberFilterOperatorOption = {
label: string;
value: NumberFilterOperator;
};
const numberFilterOperatorOptions: NumberFilterOperatorOption[] = [
{ value: "eq", label: "=" },
{ value: "neq", label: "≠" },
{ value: "gt", label: ">" },
{ value: "gte", label: "≥" },
{ value: "lt", label: "<" },
{ value: "lte", label: "≤" },
];
export type NumberFilterOptionsProps = {
column: Extract<FilterableColumn, { type: "number" }>;
};
export function NumberFilterOptions({ column }: NumberFilterOptionsProps) {
const { t } = useLocale();
const filterValue = useFilterValue(column.id, ZNumberFilterValue);
const { updateFilter, removeFilter } = useDataTable();
const form = useForm({
defaultValues: {
operatorOption: filterValue
? numberFilterOperatorOptions.find((o) => o.value === filterValue.data.operator)
: numberFilterOperatorOptions[0],
operand: filterValue?.data.operand || "",
},
});
return (
<div className="mx-3 my-2">
<Form
form={form}
handleSubmit={({ operatorOption, operand }) => {
if (operatorOption) {
updateFilter(column.id, {
type: "number",
data: {
operator: operatorOption.value,
operand: Number(operand),
},
});
}
}}>
<div>
<Controller
name="operatorOption"
control={form.control}
render={({ field: { value } }) => (
<div className="-mt-2 flex items-center gap-2">
<Select
className="basis-1/3"
options={numberFilterOperatorOptions}
value={value}
isSearchable={false}
onChange={(event) => {
if (event) {
form.setValue("operatorOption", { ...event }, { shouldDirty: true });
}
}}
/>
<Input type="number" className="mt-2 basis-2/3" {...form.register("operand")} />
</div>
)}
/>
<div className="bg-subtle -mx-3 mb-2 h-px" role="separator" />
<div className="flex items-center justify-between">
<Button
type="button"
color="secondary"
disabled={form.formState.isSubmitting}
onClick={() => removeFilter(column.id)}>
{t("clear")}
</Button>
<Button
type="submit"
color="primary"
loading={form.formState.isSubmitting}
disabled={form.formState.isSubmitting}>
{t("apply")}
</Button>
</div>
</div>
</Form>
</div>
);
}
@@ -1,9 +1,13 @@
"use client";
import { useForm, Controller } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Form, Input, Select, Button } from "@calcom/ui";
import type { FilterableColumn, TextFilterValue, TextFilterOperator } from "../../lib/types";
import type { FilterableColumn, TextFilterOperator } from "../../lib/types";
import { ZTextFilterValue } from "../../lib/types";
import { useFilterValue, useDataTable } from "../../lib/utils";
export type TextFilterOperatorOption = {
label: string;
@@ -26,20 +30,14 @@ const useTextFilterOperatorOptions = (): TextFilterOperatorOption[] => {
};
export type TextFilterOptionsProps = {
column: FilterableColumn;
filterValue?: TextFilterValue;
setFilterValue: (value: TextFilterValue) => void;
removeFilter: (columnId: string) => void;
column: Extract<FilterableColumn, { type: "text" }>;
};
export function TextFilterOptions({
column,
filterValue,
setFilterValue,
removeFilter,
}: TextFilterOptionsProps) {
export function TextFilterOptions({ column }: TextFilterOptionsProps) {
const { t } = useLocale();
const textFilterOperatorOptions = useTextFilterOperatorOptions();
const filterValue = useFilterValue(column.id, ZTextFilterValue);
const { updateFilter, removeFilter } = useDataTable();
const form = useForm({
defaultValues: {
@@ -56,7 +54,7 @@ export function TextFilterOptions({
form={form}
handleSubmit={({ operatorOption, operand }) => {
if (operatorOption) {
setFilterValue({
updateFilter(column.id, {
type: "text",
data: {
operator: operatorOption.value,
@@ -1,7 +1,7 @@
"use client";
import { type Table } from "@tanstack/react-table";
import { forwardRef, useState, useMemo } from "react";
import { forwardRef, useState, useMemo, useCallback, Fragment } from "react";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -22,8 +22,8 @@ import {
Icon,
} from "@calcom/ui";
import type { FilterableColumn } from "../../lib/types";
import { useFiltersSearchState } from "../../lib/utils";
import type { FilterableColumn, ExternalFilter } from "../../lib/types";
import { convertToTitleCase, useDataTable } from "../../lib/utils";
import { FilterOptions } from "./FilterOptions";
interface ColumnVisiblityProps<TData> {
@@ -118,37 +118,30 @@ const ColumnVisibilityButton = forwardRef(ColumnVisibilityButtonComponent) as <T
) => ReturnType<typeof ColumnVisibilityButtonComponent>;
// Filters
interface FilterButtonProps<TData> {
interface AddFilterButtonProps<TData> {
table: Table<TData>;
omit?: string[];
externalFilters?: ExternalFilter[];
}
function FilterButtonComponent<TData>(
{ table, omit }: FilterButtonProps<TData>,
function AddFilterButtonComponent<TData>(
{ table, omit, externalFilters }: AddFilterButtonProps<TData>,
ref: React.Ref<HTMLButtonElement>
) {
const { t } = useLocale();
const [_state, _setState] = useFiltersSearchState();
const { activeFilters, setActiveFilters, displayedExternalFilters, setDisplayedExternalFilters } =
useDataTable();
const activeFilters = _state.activeFilters;
const columns = table
.getAllColumns()
.filter((column) => column.getCanFilter())
.filter((column) => !omit?.includes(column.id));
const filterableColumns = useFilterableColumns(table, omit);
const filterableColumns = useMemo(() => {
return columns.map((column) => ({
id: column.id,
title: typeof column.columnDef.header === "string" ? column.columnDef.header : column.id,
options: column.getFacetedUniqueValues(),
}));
}, [columns]);
const handleAddFilter = (columnId: string) => {
if (!activeFilters?.some((filter) => filter.f === columnId)) {
_setState({ activeFilters: [...activeFilters, { f: columnId, v: [] }] });
}
};
const handleAddFilter = useCallback(
(columnId: string) => {
if (!activeFilters?.some((filter) => filter.f === columnId)) {
setActiveFilters([...activeFilters, { f: columnId, v: undefined }]);
}
},
[activeFilters, setActiveFilters]
);
return (
<div className="flex items-center space-x-2">
@@ -167,11 +160,24 @@ function FilterButtonComponent<TData>(
{filterableColumns.map((column) => {
if (activeFilters?.some((filter) => filter.f === column.id)) return null;
return (
<CommandItem key={column.id} onSelect={() => handleAddFilter(column.id)}>
{column.title}
<CommandItem
key={column.id}
onSelect={() => handleAddFilter(column.id)}
className="px-4 py-2">
{convertToTitleCase(column.title)}
</CommandItem>
);
})}
{(externalFilters || [])
.filter((filter) => !displayedExternalFilters.includes(filter.key))
.map((filter, index) => (
<CommandItem
key={index}
onSelect={() => setDisplayedExternalFilters((prev) => [...prev, filter.key])}
className="px-4 py-2">
{t(filter.titleKey)}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
@@ -180,59 +186,94 @@ function FilterButtonComponent<TData>(
);
}
const FilterButton = forwardRef(FilterButtonComponent) as <TData>(
props: FilterButtonProps<TData> & { ref?: React.Ref<HTMLButtonElement>; omit?: string[] }
) => ReturnType<typeof FilterButtonComponent>;
function useFilterableColumns<TData>(table: Table<TData>, omit?: string[]) {
const columns = useMemo(
() =>
table
.getAllColumns()
.filter((column) => column.getCanFilter())
.filter((column) => !omit?.includes(column.id)),
[table.getAllColumns(), omit]
);
const filterableColumns = useMemo<FilterableColumn[]>(
() =>
columns
.map((column) => {
const type = column.columnDef.meta?.filter?.type || "select";
const base = {
id: column.id,
title: typeof column.columnDef.header === "string" ? column.columnDef.header : column.id,
...(column.columnDef.meta?.filter || {}),
type,
};
if (type === "select") {
return {
...base,
options: column.getFacetedUniqueValues(),
};
} else {
return {
...base,
};
}
})
.filter((column): column is FilterableColumn => Boolean(column)),
[columns]
);
return filterableColumns;
}
const AddFilterButton = forwardRef(AddFilterButtonComponent) as <TData>(
props: AddFilterButtonProps<TData> & { ref?: React.Ref<HTMLButtonElement>; omit?: string[] }
) => ReturnType<typeof AddFilterButtonComponent>;
// Add the new ActiveFilters component
interface ActiveFiltersProps<TData> {
table: Table<TData>;
externalFilters?: ExternalFilter[];
}
function ActiveFilters<TData>({ table }: ActiveFiltersProps<TData>) {
const [_state, _setState] = useFiltersSearchState();
const filterIcons = {
text: "file-text",
number: "binary",
select: "layers",
} as const;
const columns = table.getAllColumns().filter((column) => column.getCanFilter());
const filterableColumns = useMemo<FilterableColumn[]>(() => {
return columns.map((column) => {
return {
id: column.id,
title: typeof column.columnDef.header === "string" ? column.columnDef.header : column.id,
filterType: column.columnDef.meta?.filterType || "select",
options: column.getFacetedUniqueValues(),
};
});
}, [columns]);
function ActiveFilters<TData>({ table, externalFilters }: ActiveFiltersProps<TData>) {
const { activeFilters, displayedExternalFilters } = useDataTable();
const filterableColumns = useFilterableColumns(table);
return (
<>
{(_state.activeFilters || []).map((filter) => {
{activeFilters.map((filter) => {
const column = filterableColumns.find((col) => col.id === filter.f);
if (!column) return null;
const icon = column.icon || filterIcons[column.type];
return (
<Popover key={column.id}>
<PopoverTrigger asChild>
<Button color="secondary">
{column.title}
<Icon name={icon} className="mr-2 h-4 w-4" />
{convertToTitleCase(column.title)}
<Icon name="chevron-down" className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<FilterOptions
column={column}
filter={filter}
state={_state}
setState={_setState}
table={table}
/>
<FilterOptions column={column} />
</PopoverContent>
</Popover>
);
})}
{(displayedExternalFilters || []).map((key) => {
const filter = externalFilters?.find((filter) => filter.key === key);
if (!filter) return null;
return <Fragment key={key}>{filter.component()}</Fragment>;
})}
</>
);
}
// Update the export to include ActiveFilters
export const DataTableFilters = { ColumnVisibilityButton, FilterButton, ActiveFilters };
export const DataTableFilters = { ColumnVisibilityButton, AddFilterButton, ActiveFilters };
+1
View File
@@ -1,4 +1,5 @@
export * from "./components";
export * from "./lib/types";
export * from "./lib/utils";
export * from "./lib/context";
export * from "./lib/resizing";
@@ -0,0 +1,80 @@
"use client";
import { useQueryState, parseAsArrayOf, parseAsJson } from "nuqs";
import { createContext, useCallback, useState, type Dispatch, type SetStateAction } from "react";
import { z } from "zod";
import { type FilterValue, ZFilterValue } from "./types";
const dataTableFiltersSchema = z.object({
f: z.string(),
v: ZFilterValue.optional(),
});
type ActiveFilter = z.infer<typeof dataTableFiltersSchema>;
export type DataTableContextType = {
activeFilters: ActiveFilter[];
setActiveFilters: (filters: ActiveFilter[]) => void;
clearAll: () => void;
updateFilter: (columnId: string, value: FilterValue) => void;
removeFilter: (columnId: string) => void;
displayedExternalFilters: string[];
setDisplayedExternalFilters: Dispatch<SetStateAction<string[]>>;
removeDisplayedExternalFilter: (key: string) => void;
};
export const DataTableContext = createContext<DataTableContextType | null>(null);
export function DataTableProvider({ children }: { children: React.ReactNode }) {
const [activeFilters, setActiveFilters] = useQueryState(
"activeFilters",
parseAsArrayOf(parseAsJson(dataTableFiltersSchema.parse)).withDefault([])
);
const [displayedExternalFilters, setDisplayedExternalFilters] = useState<string[]>([]);
const removeDisplayedExternalFilter = useCallback(
(key: string) => {
setDisplayedExternalFilters((prev) => prev.filter((f) => f !== key));
},
[setDisplayedExternalFilters]
);
const clearAll = useCallback(() => {
setActiveFilters([]);
}, [setActiveFilters]);
const updateFilter = useCallback(
(columnId: string, value: FilterValue) => {
setActiveFilters((prev) => {
return prev.map((item) => (item.f === columnId ? { ...item, v: value } : item));
});
},
[setActiveFilters]
);
const removeFilter = useCallback(
(columnId: string) => {
setActiveFilters((prev) => prev.filter((filter) => filter.f !== columnId));
},
[setActiveFilters]
);
return (
<DataTableContext.Provider
value={{
activeFilters,
setActiveFilters,
clearAll,
updateFilter,
removeFilter,
displayedExternalFilters,
setDisplayedExternalFilters,
removeDisplayedExternalFilter,
}}>
{children}
</DataTableContext.Provider>
);
}
+15 -15
View File
@@ -6,24 +6,24 @@ import { useState, useCallback, useEffect } from "react";
type UsePersistentColumnResizingProps<TData> = {
enabled: boolean;
table: Table<TData>;
name?: string;
identifier?: string;
};
function getLocalStorageKey(name: string) {
return `data-table-column-sizing-${name}`;
function getLocalStorageKey(identifier: string) {
return `data-table-column-sizing-${identifier}`;
}
function loadColumnSizing(name: string) {
function loadColumnSizing(identifier: string) {
try {
return JSON.parse(localStorage.getItem(getLocalStorageKey(name)) || "{}");
return JSON.parse(localStorage.getItem(getLocalStorageKey(identifier)) || "{}");
} catch (error) {
return {};
}
return {};
}
function saveColumnSizing(name: string, columnSizing: ColumnSizingState) {
localStorage.setItem(getLocalStorageKey(name), JSON.stringify(columnSizing));
function saveColumnSizing(identifier: string, columnSizing: ColumnSizingState) {
localStorage.setItem(getLocalStorageKey(identifier), JSON.stringify(columnSizing));
}
const debouncedSaveColumnSizing = debounce(saveColumnSizing, 1000);
@@ -31,19 +31,19 @@ const debouncedSaveColumnSizing = debounce(saveColumnSizing, 1000);
export function usePersistentColumnResizing<TData>({
enabled,
table,
name,
identifier,
}: UsePersistentColumnResizingProps<TData>) {
const [_, setColumnSizing] = useState<ColumnSizingState>({});
const onColumnSizingChange = useCallback(
(updater: ColumnSizingState | ((old: ColumnSizingState) => ColumnSizingState)) => {
// `!name` is checked already in the `useEffect` hook,
// `!identifier` is checked already in the `useEffect` hook,
// but TS doesn't know that, and this won't happen.
if (!name) return;
if (!identifier) return;
table.setState((oldTableState) => {
const newColumnSizing = typeof updater === "function" ? updater(oldTableState.columnSizing) : updater;
debouncedSaveColumnSizing(name, newColumnSizing);
debouncedSaveColumnSizing(identifier, newColumnSizing);
setColumnSizing(newColumnSizing);
return {
@@ -52,13 +52,13 @@ export function usePersistentColumnResizing<TData>({
};
});
},
[name, table]
[identifier, table]
);
useEffect(() => {
if (!enabled || !name) return;
if (!enabled || !identifier) return;
const newColumnSizing = loadColumnSizing(name);
const newColumnSizing = loadColumnSizing(identifier);
setColumnSizing(newColumnSizing);
table.setState((old) => ({
...old,
@@ -69,5 +69,5 @@ export function usePersistentColumnResizing<TData>({
columnResizeMode: "onChange",
onColumnSizingChange,
}));
}, [enabled, name, table, onColumnSizingChange]);
}, [enabled, identifier, table, onColumnSizingChange]);
}
+77 -12
View File
@@ -1,62 +1,76 @@
import type { FilterValue } from "./types";
import { isSelectFilterValue, isTextFilterValue } from "./utils";
import { isSelectFilterValue, isTextFilterValue, isNumberFilterValue } from "./utils";
export function makeWhereClause(columnName: string, filterValue: FilterValue) {
type makeWhereClauseProps = {
columnName: string;
filterValue: FilterValue;
json?: true | { path: string[] };
};
export function makeWhereClause(props: makeWhereClauseProps) {
const { columnName, filterValue } = props;
const isJson = props.json === true || (typeof props.json === "object" && props.json.path?.length > 0);
const jsonPath = isJson && typeof props.json === "object" ? props.json.path : undefined;
const jsonPathObj = isJson && jsonPath ? { path: jsonPath } : {};
if (isSelectFilterValue(filterValue)) {
return {
[columnName]: {
in: filterValue,
...jsonPathObj,
...(isJson ? { array_contains: filterValue } : { in: filterValue }),
},
};
} else if (isTextFilterValue(filterValue)) {
const { operator, operand } = filterValue.data;
switch (operator) {
case "equals":
return {
[columnName]: {
...jsonPathObj,
equals: operand,
},
};
case "notEquals":
return {
[columnName]: {
...jsonPathObj,
not: operand,
},
};
case "contains":
return {
[columnName]: {
contains: operand,
mode: "insensitive",
...jsonPathObj,
...(isJson ? { string_contains: operand } : { contains: operand, mode: "insensitive" }),
},
};
case "notContains":
return {
NOT: {
[columnName]: {
contains: operand,
mode: "insensitive",
...jsonPathObj,
...(isJson ? { string_contains: operand } : { contains: operand, mode: "insensitive" }),
},
},
};
case "startsWith":
return {
[columnName]: {
startsWith: operand,
mode: "insensitive",
...jsonPathObj,
...(isJson ? { string_starts_with: operand } : { startsWith: operand, mode: "insensitive" }),
},
};
case "endsWith":
return {
[columnName]: {
endsWith: operand,
mode: "insensitive",
...jsonPathObj,
...(isJson ? { string_ends_with: operand } : { endsWith: operand, mode: "insensitive" }),
},
};
case "isEmpty":
return {
[columnName]: {
...jsonPathObj,
equals: "",
},
};
@@ -64,10 +78,61 @@ export function makeWhereClause(columnName: string, filterValue: FilterValue) {
return {
NOT: {
[columnName]: {
...jsonPathObj,
equals: "",
},
},
};
default:
throw new Error(`Invalid operator for text filter: ${operator}`);
}
} else if (isNumberFilterValue(filterValue)) {
const { operator, operand } = filterValue.data;
switch (operator) {
case "eq":
return {
[columnName]: {
...jsonPathObj,
equals: operand,
},
};
case "neq":
return {
[columnName]: {
...jsonPathObj,
not: operand,
},
};
case "gt":
return {
[columnName]: {
...jsonPathObj,
gt: operand,
},
};
case "gte":
return {
[columnName]: {
...jsonPathObj,
gte: operand,
},
};
case "lt":
return {
[columnName]: {
...jsonPathObj,
lt: operand,
},
};
case "lte":
return {
[columnName]: {
...jsonPathObj,
lte: operand,
},
};
default:
throw new Error(`Invalid operator for number filter: ${operator}`);
}
}
return {};
+69 -3
View File
@@ -1,5 +1,7 @@
import { z } from "zod";
import type { IconName } from "@calcom/ui";
export const ZTextFilterOperator = z.enum([
"equals",
"notEquals",
@@ -27,13 +29,77 @@ export const ZTextFilterValue = z.object({
export type TextFilterValue = z.infer<typeof ZTextFilterValue>;
export const ZFilterValue = z.union([ZSelectFilterValue, ZTextFilterValue]);
export const ZNumberFilterOperator = z.enum([
"eq", // =
"neq", // !=
"gt", // >
"gte", // >=
"lt", // <
"lte", // <=
]);
export type NumberFilterOperator = z.infer<typeof ZNumberFilterOperator>;
export const ZNumberFilterValue = z.object({
type: z.literal("number"),
data: z.object({
operator: ZNumberFilterOperator,
operand: z.number(),
}),
});
export type NumberFilterValue = z.infer<typeof ZNumberFilterValue>;
export const ZFilterValue = z.union([ZSelectFilterValue, ZTextFilterValue, ZNumberFilterValue]);
export type FilterValue = z.infer<typeof ZFilterValue>;
export type ColumnFilterType = "select" | "text" | "number";
export type ColumnFilterMeta = {
type?: ColumnFilterType;
icon?: IconName;
};
export type FilterableColumn = {
id: string;
title: string;
filterType: "text" | "select";
options: Map<string, number>;
} & (
| {
type: "select";
icon?: IconName;
options: Map<string | { label: string; value: string }, number>;
}
| {
type: "text";
icon?: IconName;
}
| {
type: "number";
icon?: IconName;
}
);
export const ZColumnFilter = z.object({
id: z.string(),
value: ZFilterValue,
});
export type ColumnFilter = z.infer<typeof ZColumnFilter>;
export type TypedColumnFilter<T extends ColumnFilterType> = {
id: string;
value: T extends "text"
? TextFilterValue
: T extends "number"
? NumberFilterValue
: T extends "select"
? SelectFilterValue
: never;
};
export type ExternalFilter = {
key: string;
titleKey: string;
component: () => React.ReactNode;
};
+112 -39
View File
@@ -1,51 +1,78 @@
"use client";
import { parseAsArrayOf, parseAsJson, useQueryStates } from "nuqs";
import { useMemo } from "react";
import { z } from "zod";
import { useMemo, useContext } from "react";
import type { z } from "zod";
import type { SelectFilterValue, TextFilterValue } from "./types";
import { ZSelectFilterValue, ZTextFilterValue } from "./types";
import { DataTableContext } from "./context";
import type {
SelectFilterValue,
TextFilterValue,
FilterValue,
NumberFilterValue,
ColumnFilter,
} from "./types";
import { ZFilterValue, ZNumberFilterValue, ZSelectFilterValue, ZTextFilterValue } from "./types";
const filterSchema = z.object({
f: z.string(),
v: z.union([ZSelectFilterValue, ZTextFilterValue]),
});
export const filtersSearchParams = {
activeFilters: parseAsArrayOf(parseAsJson(filterSchema.parse)).withDefault([]),
};
export function useFiltersSearchState() {
return useQueryStates(filtersSearchParams);
export function useDataTable() {
const context = useContext(DataTableContext);
if (!context) {
throw new Error("useDataTable must be used within a DataTableProvider");
}
return context;
}
export function useColumnFilters() {
const [filtersSearchState] = useFiltersSearchState();
const columnFilters = useMemo(() => {
return (filtersSearchState.activeFilters || [])
.map((filter) => ({
id: filter.f,
value: filter.v,
}))
export function useFilterValue<T>(columnId: string, schema: z.ZodType<T>) {
const { activeFilters } = useDataTable();
return useMemo(() => {
const value = activeFilters.find((filter) => filter.f === columnId)?.v;
if (schema && value) {
const result = schema.safeParse(value);
if (result.success) {
return result.data;
}
}
return undefined;
}, [activeFilters, columnId, schema]);
}
export function useColumnFilters(): ColumnFilter[] {
const { activeFilters } = useDataTable();
return useMemo(() => {
return (activeFilters || [])
.filter(
(filter) =>
typeof filter === "object" && filter && "f" in filter && "v" in filter && filter.v !== undefined
)
.map((filter) => {
const parsedValue = ZFilterValue.safeParse(filter.v);
if (!parsedValue.success) return null;
return {
id: filter.f,
value: parsedValue.data,
};
})
.filter((filter): filter is ColumnFilter => filter !== null)
.filter((filter) => {
// The empty arrays in `filtersSearchState` keep the filter UI component,
// but we do not send them to the actual query.
// Otherwise, { value: [] } would result in nothing being returned.
if (Array.isArray(filter.value) && filter.value.length === 0) {
// Otherwise, `{ my_column_name: { in: []} }` would result in nothing being returned.
if (isSelectFilterValue(filter.value) && filter.value.length === 0) {
return false;
}
return true;
});
}, [filtersSearchState.activeFilters]);
return columnFilters;
}, [activeFilters]);
}
export type FiltersSearchState = ReturnType<typeof useFiltersSearchState>[0];
export type SetFiltersSearchState = ReturnType<typeof useFiltersSearchState>[1];
export type ActiveFilter = NonNullable<FiltersSearchState["activeFilters"]>[number];
export const textFilter = (cellValue: unknown, filterValue: TextFilterValue) => {
if (filterValue.data.operator === "isEmpty" && cellValue === undefined) {
return true;
}
if (typeof cellValue !== "string") {
return false;
}
export const textFilter = (cellValue: string, filterValue: TextFilterValue) => {
switch (filterValue.data.operator) {
case "equals":
return cellValue.toLowerCase() === (filterValue.data.operand || "").toLowerCase();
@@ -69,14 +96,60 @@ export const textFilter = (cellValue: string, filterValue: TextFilterValue) => {
};
export const isTextFilterValue = (filterValue: unknown): filterValue is TextFilterValue => {
return (
typeof filterValue === "object" &&
filterValue !== null &&
"type" in filterValue &&
filterValue.type === "text"
);
return ZTextFilterValue.safeParse(filterValue).success;
};
export const selectFilter = (cellValue: unknown | undefined, filterValue: SelectFilterValue) => {
const cellValueArray = Array.isArray(cellValue) ? cellValue : [cellValue];
if (!cellValueArray.every((value) => typeof value === "string")) {
return false;
}
return filterValue.length === 0 ? true : cellValueArray.some((v) => filterValue.includes(v));
};
export const isSelectFilterValue = (filterValue: unknown): filterValue is SelectFilterValue => {
return Array.isArray(filterValue) && filterValue.every((item) => typeof item === "string");
return ZSelectFilterValue.safeParse(filterValue).success;
};
export const numberFilter = (cellValue: unknown, filterValue: NumberFilterValue) => {
if (typeof cellValue !== "number") {
return false;
}
switch (filterValue.data.operator) {
case "eq":
return cellValue === filterValue.data.operand;
case "neq":
return cellValue !== filterValue.data.operand;
case "gt":
return cellValue > filterValue.data.operand;
case "gte":
return cellValue >= filterValue.data.operand;
case "lt":
return cellValue < filterValue.data.operand;
case "lte":
return cellValue <= filterValue.data.operand;
}
return false;
};
export const isNumberFilterValue = (filterValue: unknown): filterValue is NumberFilterValue => {
return ZNumberFilterValue.safeParse(filterValue).success;
};
export const dataTableFilter = (cellValue: unknown, filterValue: FilterValue) => {
if (isSelectFilterValue(filterValue)) {
return selectFilter(cellValue, filterValue);
} else if (isTextFilterValue(filterValue)) {
return textFilter(cellValue, filterValue);
} else if (isNumberFilterValue(filterValue)) {
return numberFilter(cellValue, filterValue);
}
return false;
};
export const convertToTitleCase = (str: string) => {
return str.replace(/\b\w/g, (char) => char.toUpperCase());
};
@@ -1,7 +1,6 @@
"use client";
import { keepPreviousData } from "@tanstack/react-query";
import type { ColumnFiltersState } from "@tanstack/react-table";
import {
getCoreRowModel,
getFilteredRowModel,
@@ -19,10 +18,12 @@ import type { Dispatch, SetStateAction } from "react";
import {
DataTable,
DataTableProvider,
DataTableToolbar,
DataTableFilters,
DataTableSelectionBar,
useFetchMoreOnBottomReached,
useColumnFilters,
} from "@calcom/features/data-table";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { DynamicLink } from "@calcom/features/users/components/UserTable/BulkActions/DynamicLink";
@@ -150,6 +151,14 @@ interface Props {
}
export default function MemberList(props: Props) {
return (
<DataTableProvider>
<MemberListContent {...props} />
</DataTableProvider>
);
}
function MemberListContent(props: Props) {
const [dynamicLinkVisible, setDynamicLinkVisible] = useQueryState("dynamicLink", parseAsBoolean);
const { t, i18n } = useLocale();
const { data: session } = useSession();
@@ -168,6 +177,8 @@ export default function MemberList(props: Props) {
limit: 10,
searchTerm: debouncedSearchTerm,
teamId: props.team.id,
// TODO: send `columnFilters` to server for server side filtering
// filters: columnFilters,
},
{
enabled: !!props.team.id,
@@ -179,8 +190,7 @@ export default function MemberList(props: Props) {
}
);
// TODO (SEAN): Make Column filters a trpc query param so we can fetch serverside even if the data is not loaded
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const columnFilters = useColumnFilters();
const [rowSelection, setRowSelection] = useState({});
const removeMemberFromCache = ({
@@ -625,7 +635,6 @@ export default function MemberList(props: Props) {
columnFilters,
rowSelection,
},
onColumnFiltersChange: setColumnFilters,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
@@ -654,7 +663,7 @@ export default function MemberList(props: Props) {
<DataTableToolbar.Root>
<div className="flex w-full gap-2">
<DataTableToolbar.SearchBar table={table} onSearch={(value) => setDebouncedSearchTerm(value)} />
<DataTableFilters.FilterButton table={table} />
<DataTableFilters.AddFilterButton table={table} />
<DataTableFilters.ColumnVisibilityButton table={table} />
{isAdminOrOwner && (
<DataTableToolbar.CTA
@@ -10,13 +10,28 @@ import {
} from "@tanstack/react-table";
import Link from "next/link";
import { useRef, useMemo, useId } from "react";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { DataTable, DataTableSkeleton, useFetchMoreOnBottomReached } from "@calcom/features/data-table";
import type { FilterValue, ExternalFilter } from "@calcom/features/data-table";
import {
DataTableProvider,
DataTable,
DataTableSkeleton,
DataTableFilters,
useFetchMoreOnBottomReached,
useColumnFilters,
selectFilter,
textFilter,
dataTableFilter,
convertToTitleCase,
useDataTable,
} 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,
@@ -30,6 +45,13 @@ import {
} from "@calcom/ui";
import { useFilterContext } from "../context/provider";
import { ClearFilters } from "../filters/ClearFilters";
import { DateSelect } from "../filters/DateSelect";
import { RoutingDownload } from "../filters/Download";
import { RoutingFormFilterList } from "../filters/RoutingFormFilterList";
import { TeamAndSelfList } from "../filters/TeamAndSelfList";
import { UserListInTeam } from "../filters/UserListInTeam";
import { RoutingKPICards } from "./RoutingKPICards";
type RoutingFormResponse = RouterOutputs["viewer"]["insights"]["routingFormResponses"]["data"][number];
@@ -41,6 +63,17 @@ type RoutingFormTableRow = {
[key: string]: any;
};
type FieldCellValue = { label: string; value: string };
const ZResponseValues = z.array(
z.object({
label: z.string(),
value: z.string(),
})
);
type ResponseValues = FieldCellValue[];
function CellWithOverflowX({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<div className={classNames("group relative max-w-[200px]", className)}>
@@ -96,29 +129,29 @@ function BookedByCell({
);
}
function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number }) {
function ResponseValueCell({ values, rowId }: { values: ResponseValues; rowId: number }) {
const cellId = useId();
if (value.length === 0) return <div className="h-6 w-[200px]" />;
if (values.length === 0) return <div className="h-6 w-[200px]" />;
return (
<CellWithOverflowX className="flex w-[200px] gap-1">
{value.length > 2 ? (
{values.length > 2 ? (
<>
{value.slice(0, 2).map((v: string, i: number) => (
{values.slice(0, 2).map((value, i: number) => (
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
{v}
{value.label}
</Badge>
))}
<HoverCard>
<HoverCardTrigger>
<Badge variant="gray">+{value.length - 2}</Badge>
<Badge variant="gray">+{values.length - 2}</Badge>
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent side="bottom" align="start" className="w-fit">
<div className="flex flex-col gap-1">
{value.slice(2).map((v: string, i: number) => (
{values.slice(2).map((value, i: number) => (
<span key={`${cellId}-overflow-${i}-${rowId}`} className="text-default text-sm">
{v}
{value.label}
</span>
))}
</div>
@@ -127,9 +160,9 @@ function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number })
</HoverCard>
</>
) : (
value.map((v: string, i: number) => (
values.map((value, i: number) => (
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
{v}
{value.label}
</Badge>
))
)}
@@ -215,7 +248,15 @@ function BookingAtCell({
export type RoutingFormTableType = ReturnType<typeof useReactTable<RoutingFormTableRow>>;
export function RoutingFormResponsesTable({
export function RoutingFormResponsesTable() {
return (
<DataTableProvider>
<RoutingFormResponsesTableContent />
</DataTableProvider>
);
}
export function RoutingFormResponsesTableContent({
children,
}: {
children?: React.ReactNode | ((table: RoutingFormTableType) => React.ReactNode);
@@ -223,21 +264,15 @@ export function RoutingFormResponsesTable({
const { t } = useLocale();
const { filter } = useFilterContext();
const tableContainerRef = useRef<HTMLDivElement>(null);
const { copyToClipboard, isCopied } = useCopy();
const { copyToClipboard } = useCopy();
const {
dateRange,
selectedTeamId,
isAll,
initialConfig,
selectedRoutingFormId,
selectedMemberUserId,
selectedBookingStatus,
selectedRoutingFormFilter,
} = filter;
const { dateRange, selectedTeamId, isAll, initialConfig, selectedRoutingFormId, selectedMemberUserId } =
filter;
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
const [startDate, endDate] = dateRange;
const columnFilters = useColumnFilters();
const { data: headers, isLoading: isHeadersLoading } =
trpc.viewer.insights.routingFormResponsesHeaders.useQuery(
{
@@ -259,8 +294,7 @@ export function RoutingFormResponsesTable({
userId: selectedMemberUserId ?? undefined,
isAll: isAll ?? false,
routingFormId: selectedRoutingFormId ?? undefined,
bookingStatus: selectedBookingStatus ?? undefined,
fieldFilter: selectedRoutingFormFilter ?? undefined,
columnFilters,
limit: 30,
},
{
@@ -289,20 +323,27 @@ export function RoutingFormResponsesTable({
};
Object.entries(response.response).forEach(([fieldId, field]) => {
const header = headers?.find((h) => h.id === fieldId);
const fieldHeader = (headers || []).find((h) => h.id === fieldId);
if (header?.options) {
if (fieldHeader?.options) {
if (Array.isArray(field.value)) {
// Map the IDs to their corresponding labels for array values
const labels = field.value.map((id) => {
const option = header.options?.find((opt) => opt.id === id);
return option?.label ?? id;
});
const labels = field.value
.map((id) => {
const option = fieldHeader.options?.find((opt) => opt.id === id);
if (!option) {
return undefined;
}
return { label: option.label, value: option.id };
})
.filter(Boolean);
row[fieldId] = labels;
} else {
// Handle single value case
const option = header.options?.find((opt) => opt.id === field.value);
row[fieldId] = option?.label ?? field.value;
const option = fieldHeader.options?.find((opt) => opt.id === field.value);
if (option) {
row[fieldId] = { label: option.label, value: option.id };
}
}
} else {
row[fieldId] = field.value;
@@ -325,30 +366,64 @@ export function RoutingFormResponsesTable({
const columns = useMemo(
() => [
columnHelper.accessor("bookedAttendees", {
columnHelper.accessor("routedToBooking", {
id: "bookedBy",
header: t("routing_form_insights_booked_by"),
size: 200,
enableColumnFilter: false,
cell: (info) => {
const row = info.row.original;
return <BookedByCell attendees={row.routedToBooking?.attendees || []} rowId={row.id} />;
},
}),
...(headers?.map((header) => {
return columnHelper.accessor(header.id, {
id: header.id,
header: header.label,
...((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 isSelect =
fieldHeader.type === RoutingFormFieldType.SINGLE_SELECT ||
fieldHeader.type === RoutingFormFieldType.MULTI_SELECT;
const filterType = isSelect ? "select" : isNumber ? "number" : "text";
return columnHelper.accessor(fieldHeader.id, {
id: fieldHeader.id,
header: convertToTitleCase(fieldHeader.label),
size: 200,
cell: (info) => {
let value = info.getValue();
value = Array.isArray(value) ? value : [value];
return (
<div className="max-w-[200px]">
<ResponseValueCell value={value} rowId={info.row.original.id} />
const values = info.getValue();
const result = isSelect ? ZResponseValues.safeParse(values) : null;
return isSelect && result?.success ? (
<ResponseValueCell values={result.data} rowId={info.row.original.id} />
) : (
<div className="truncate">
<span title={values}>{values}</span>
</div>
);
},
meta: {
filter: { type: filterType },
},
filterFn: (row, id, filterValue: FilterValue) => {
let cellValue: unknown;
if (Array.isArray(fieldHeader.options)) {
cellValue = Array.isArray(row.original[id])
? row.original[id].map((item: FieldCellValue) => item.value)
: row.original[id].value;
} else {
cellValue = row.original[id];
}
return dataTableFilter(cellValue, filterValue);
},
});
}) ?? []),
columnHelper.accessor("routedToBooking", {
@@ -360,6 +435,12 @@ export function RoutingFormResponsesTable({
<BookingStatusBadge booking={info.getValue()} />
</div>
),
meta: {
filter: { type: "select", icon: "circle" },
},
filterFn: (row, id, filterValue) => {
return selectFilter(row.original.routedToBooking?.status, filterValue);
},
sortingFn: (rowA, rowB) => {
const statusA = rowA.original.routedToBooking?.status;
const statusB = rowB.original.routedToBooking?.status;
@@ -373,6 +454,7 @@ export function RoutingFormResponsesTable({
id: "bookingAt",
header: t("routing_form_insights_booking_at"),
size: 250,
enableColumnFilter: false,
cell: (info) => (
<div className="max-w-[250px]">
<BookingAtCell
@@ -388,6 +470,9 @@ export function RoutingFormResponsesTable({
id: "assignmentReason",
header: t("routing_form_insights_assignment_reason"),
size: 250,
meta: {
filter: { type: "text" },
},
cell: (info) => {
const assignmentReason = info.getValue()?.assignmentReason;
return (
@@ -396,11 +481,15 @@ export function RoutingFormResponsesTable({
</div>
);
},
filterFn: (row, id, filterValue) => {
return textFilter(row.original.routedToBooking?.assignmentReason?.[0]?.reasonString, filterValue);
},
}),
columnHelper.accessor("createdAt", {
id: "submittedAt",
header: t("routing_form_insights_submitted_at"),
size: 250,
enableColumnFilter: false,
cell: (info) => (
<div className="whitespace-nowrap">
<Badge variant="gray">{dayjs(info.getValue()).format("MMM D, YYYY HH:mm")}</Badge>
@@ -420,6 +509,27 @@ export function RoutingFormResponsesTable({
defaultColumn: {
size: 200,
},
state: {
columnFilters,
},
getFacetedUniqueValues: (_, columnId) => () => {
if (!headers) {
return new Map();
}
const fieldHeader = headers.find((h) => h.id === columnId);
if (fieldHeader?.options) {
return new Map(fieldHeader.options.map((option) => [{ label: option.label, value: option.id }, 1]));
} else if (columnId === "bookingStatus") {
return new Map(
Object.keys(BookingStatus).map((status) => [
{ value: status, label: bookingStatusToText(status as BookingStatus) },
1,
])
);
}
return new Map();
},
});
const fetchMoreOnBottomReached = useFetchMoreOnBottomReached({
@@ -429,6 +539,24 @@ export function RoutingFormResponsesTable({
isFetching,
});
const { removeDisplayedExternalFilter } = useDataTable();
const externalFilters = useMemo<ExternalFilter[]>(
() => [
{
key: "memberUserId",
titleKey: "people",
component: () => (
<UserListInTeam
showOnlyWhenSelectedInContext={false}
onClear={() => removeDisplayedExternalFilter("memberUserId")}
/>
),
},
],
[removeDisplayedExternalFilter]
);
if (isHeadersLoading || ((isFetching || isLoading) && !data)) {
return <DataTableSkeleton columns={4} columnWidths={[200, 200, 250, 250]} />;
}
@@ -443,8 +571,22 @@ export function RoutingFormResponsesTable({
fetchMoreOnBottomReached(e.target as HTMLDivElement);
}
}}
enableColumnResizing={true}
isPending={isFetching && !data}>
{typeof children === "function" ? children(table) : children}
<div className="header mb-4">
<div className="flex flex-wrap items-start gap-2">
<TeamAndSelfList omitOrg={true} className="mb-0" />
<DataTableFilters.AddFilterButton table={table} externalFilters={externalFilters} />
<RoutingFormFilterList showOnlyWhenSelectedInContext={false} />
<DataTableFilters.ActiveFilters table={table} externalFilters={externalFilters} />
<ClearFilters />
<div className="grow" />
<DateSelect />
<RoutingDownload />
<DataTableFilters.ColumnVisibilityButton table={table} />
</div>
<RoutingKPICards />
</div>
</DataTable>
</div>
);
@@ -0,0 +1,48 @@
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>
);
};
@@ -2,6 +2,7 @@ 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";
@@ -9,7 +10,7 @@ import { Select } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
import "./DateSelect.css";
export const DateSelect = () => {
export const DateSelect = ({ className }: { className?: string }) => {
const { t } = useLocale();
const presetOptions = [
{ label: t("today"), value: "tdy" },
@@ -68,7 +69,7 @@ export const DateSelect = () => {
};
return (
<div className="ml me-2 ms-2 inline-flex space-x-2 rtl:space-x-reverse">
<div className={classNames("ml inline-flex space-x-2 rtl:space-x-reverse", className)}>
<DateRangePicker
dates={{
startDate: startValue,
@@ -16,35 +16,37 @@ function buildFilterOptions(forms: Form[]) {
}));
}
export const RoutingFormFilterList = memo(() => {
const { t } = useLocale();
const { filter, setConfigFilters } = useFilterContext();
const { selectedTeamId, selectedRoutingFormId, isAll } = filter;
const { selectedFilter } = filter;
export const RoutingFormFilterList = memo(
({ showOnlyWhenSelectedInContext = true }: { showOnlyWhenSelectedInContext?: boolean }) => {
const { t } = useLocale();
const { filter, setConfigFilters } = useFilterContext();
const { selectedTeamId, selectedRoutingFormId, isAll } = filter;
const { selectedFilter } = filter;
const { data: allForms } = trpc.viewer.insights.getRoutingFormsForFilters.useQuery(
{
teamId: selectedTeamId ?? undefined,
isAll: isAll ?? false,
},
{
enabled: selectedFilter?.includes("routing_forms"),
}
);
const { data: allForms } = trpc.viewer.insights.getRoutingFormsForFilters.useQuery(
{
teamId: selectedTeamId ?? undefined,
isAll: isAll ?? false,
},
{
enabled: selectedFilter?.includes("routing_forms"),
}
);
if (!selectedFilter?.includes("routing_forms")) return null;
if (showOnlyWhenSelectedInContext && !selectedFilter?.includes("routing_forms")) return null;
const filterOptions = buildFilterOptions(allForms || []);
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" />}
/>
);
});
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";
@@ -12,7 +12,13 @@ import { AnimatedPopover, Avatar, Divider, Icon } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
export const TeamAndSelfList = ({ omitOrg = false }: { omitOrg?: boolean }) => {
export const TeamAndSelfList = ({
omitOrg = false,
className = "",
}: {
omitOrg?: boolean;
className?: string;
}) => {
const { t } = useLocale();
const session = useSession();
const currentOrgId = session.data?.user.org?.id;
@@ -82,7 +88,7 @@ export const TeamAndSelfList = ({ omitOrg = false }: { omitOrg?: boolean }) => {
const isOrgDataAvailable = !!data && data.length > 0 && !!data[0].isOrg;
return (
<AnimatedPopover text={text}>
<AnimatedPopover text={text} popoverTriggerClassNames={className}>
<FilterCheckboxFieldsContainer>
{isOrgDataAvailable && (
<FilterCheckboxField
@@ -13,7 +13,13 @@ const mapUserToOption = (user: User) => ({
icon: <Avatar alt={`${user.name} avatar`} imageSrc={user.avatarUrl} size="sm" className="mr-2" />,
});
export const UserListInTeam = () => {
export const UserListInTeam = ({
showOnlyWhenSelectedInContext = true,
onClear,
}: {
showOnlyWhenSelectedInContext?: boolean;
onClear?: () => void;
}) => {
const { t } = useLocale();
const { filter, setConfigFilters } = useFilterContext();
const { selectedFilter, selectedTeamId, selectedMemberUserId, isAll } = filter;
@@ -23,7 +29,11 @@ export const UserListInTeam = () => {
isAll: !!isAll,
});
if (!selectedFilter?.includes("user") || !selectedTeamId || !isSuccess || data?.length === 0) {
if (showOnlyWhenSelectedInContext && !selectedFilter?.includes("user")) {
return null;
}
if (!selectedTeamId || !isSuccess || data?.length === 0) {
return null;
}
@@ -34,7 +44,12 @@ export const UserListInTeam = () => {
title={t("people")}
options={userListOptions}
selectedValue={selectedMemberUserId}
onChange={(value) => setConfigFilters({ selectedMemberUserId: Number(value) })}
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"
+2 -2
View File
@@ -10,7 +10,7 @@ import { FilterType } from "./FilterType";
import { RoutingFormFieldFilter } from "./RoutingFormFieldFilter";
import { RoutingFormFilterList } from "./RoutingFormFilterList";
import { TeamAndSelfList } from "./TeamAndSelfList";
import { UserListInTeam } from "./UsersListInTeam";
import { UserListInTeam } from "./UserListInTeam";
const ClearFilters = () => {
const { t } = useLocale();
@@ -97,7 +97,7 @@ export const Filters = ({ showRoutingFilters = false }: { showRoutingFilters?: b
</ButtonGroup> */}
<div className="flex flex-col-reverse sm:flex-row sm:flex-nowrap sm:justify-between">
{showRoutingFilters ? <RoutingDownload /> : <Download />}
<DateSelect />
<DateSelect className="me-2 ms-2" />
</div>
</div>
);
@@ -1,77 +0,0 @@
import { DataTableFilters } from "@calcom/features/data-table";
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 { BookingStatusFilter } from "../BookingStatusFilter";
import { DateSelect } from "../DateSelect";
import { RoutingDownload } from "../Download";
import { FilterType } from "../FilterType";
import { RoutingFormFieldFilter } from "../RoutingFormFieldFilter";
import { RoutingFormFilterList } from "../RoutingFormFilterList";
import { TeamAndSelfList } from "../TeamAndSelfList";
import { UserListInTeam } from "../UsersListInTeam";
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 RoutingInsightsFilters = ({ table }: { table: RoutingFormTableType }) => {
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="mt-6 flex w-full flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-between">
<div className="flex flex-wrap gap-2 sm:flex-row sm:justify-start">
<TeamAndSelfList omitOrg={true} />
<UserListInTeam />
<RoutingFormFilterList />
<BookingStatusFilter />
{routingFormFieldIds.map((fieldId) => {
if (fieldId) return <RoutingFormFieldFilter key={fieldId} fieldId={fieldId} />;
})}
<FilterType showRoutingFilters={true} />
<ClearFilters />
</div>
<div className="flex flex-col-reverse sm:flex-row sm:flex-nowrap sm:justify-between">
<RoutingDownload />
<DateSelect />
<DataTableFilters.ColumnVisibilityButton table={table} />
</div>
</div>
);
};
@@ -5,6 +5,8 @@ import {
routingFormResponseInDbSchema,
} from "@calcom/app-store/routing-forms/zod";
import dayjs from "@calcom/dayjs";
import type { ColumnFilter, TypedColumnFilter } from "@calcom/features/data-table";
import { makeWhereClause } from "@calcom/features/data-table/lib/server";
import { readonlyPrisma as prisma } from "@calcom/prisma";
import type { BookingStatus } from "@calcom/prisma/enums";
@@ -25,6 +27,7 @@ type RoutingFormInsightsFilter = RoutingFormInsightsTeamFilter & {
fieldId: string;
optionId: string;
} | null;
columnFilters: ColumnFilter[];
};
class RoutingEventsInsights {
@@ -83,7 +86,7 @@ class RoutingEventsInsights {
searchQuery,
bookingStatus,
fieldFilter,
}: RoutingFormInsightsFilter) {
}: Omit<RoutingFormInsightsFilter, "columnFilters">) {
// Get team IDs based on organization if applicable
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
teamId,
@@ -197,9 +200,8 @@ class RoutingEventsInsights {
cursor,
limit,
userId,
fieldFilter,
bookingStatus,
}: RoutingFormInsightsFilter & { cursor?: number; limit?: number }) {
columnFilters,
}: Omit<RoutingFormInsightsFilter, "fieldFilter" | "bookingStatus"> & { cursor?: number; limit?: number }) {
const formsTeamWhereCondition = await this.getWhereForTeamOrAllTeams({
teamId,
isAll,
@@ -207,6 +209,43 @@ class RoutingEventsInsights {
routingFormId,
});
const bookingStatusFilter = columnFilters.find((filter) => filter.id === "bookingStatus");
const assignmentReasonFilter = columnFilters.find((filter) => filter.id === "assignmentReason") as
| TypedColumnFilter<"text">
| undefined;
const fieldFilters = columnFilters.filter(
(filter) => filter.id !== "bookingStatus" && filter.id !== "assignmentReason"
);
let bookingWhereInput: Prisma.BookingWhereInput = {};
if (userId) {
bookingWhereInput.userId = userId;
}
if (bookingStatusFilter) {
bookingWhereInput = {
...bookingWhereInput,
...makeWhereClause({ columnName: "status", filterValue: bookingStatusFilter.value }),
};
}
if (assignmentReasonFilter) {
const operator = assignmentReasonFilter.value.data.operator;
if (operator === "isEmpty") {
bookingWhereInput.assignmentReason = { none: {} };
} else if (operator === "isNotEmpty") {
bookingWhereInput.assignmentReason = {
some: {
reasonString: {
not: "",
},
},
};
} else {
bookingWhereInput.assignmentReason = {
some: makeWhereClause({ columnName: "reasonString", filterValue: assignmentReasonFilter.value }),
};
}
}
const responsesWhereCondition: Prisma.App_RoutingForms_FormResponseWhereInput = {
...(startDate &&
endDate && {
@@ -215,27 +254,22 @@ class RoutingEventsInsights {
lte: dayjs(endDate).endOf("day").toDate(),
},
}),
...(userId || bookingStatus
? {
...(bookingStatus === "NO_BOOKING"
? { routedToBooking: null }
: {
routedToBooking: {
...(userId && { userId }),
...(bookingStatus && { status: bookingStatus }),
},
}),
}
: {}),
...(fieldFilter && {
response: {
path: [fieldFilter.fieldId, "value"],
array_contains: [fieldFilter.optionId],
},
}),
...(Object.keys(bookingWhereInput).length > 0 ? { routedToBooking: bookingWhereInput } : {}),
form: formsTeamWhereCondition,
};
if (fieldFilters.length > 0) {
responsesWhereCondition.AND = fieldFilters.map((fieldFilter) => {
// NOTE: We cannot perform case-insensitive search on `response` column,
// until we normalize this table, use raw sql, or filter at the application level.
return makeWhereClause({
columnName: "response",
filterValue: fieldFilter.value,
json: { path: [fieldFilter.id, "value"] },
});
});
}
const totalResponsePromise = prisma.app_RoutingForms_FormResponse.count({
where: responsesWhereCondition,
});
@@ -478,6 +512,7 @@ class RoutingEventsInsights {
return {
id: f.id,
label: f.label,
type: f.type,
options: f.options,
};
});
@@ -497,7 +532,7 @@ class RoutingEventsInsights {
fieldFilter,
take,
skip,
}: RoutingFormInsightsFilter & { take?: number; skip?: number }) {
}: Omit<RoutingFormInsightsFilter, "columnFilters"> & { take?: number; skip?: number }) {
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
teamId,
isAll,
@@ -3,6 +3,7 @@ import md5 from "md5";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { ZColumnFilter } from "@calcom/features/data-table";
import { rawDataInputSchema } from "@calcom/features/insights/server/raw-data.schema";
import { randomString } from "@calcom/lib/random";
import type { readonlyPrisma } from "@calcom/prisma";
@@ -1596,13 +1597,7 @@ export const insightsRouter = router({
routingFormId: z.string().optional(),
cursor: z.number().optional(),
limit: z.number().optional(),
bookingStatus: bookingStatusSchema,
fieldFilter: z
.object({
fieldId: z.string(),
optionId: z.string(),
})
.optional(),
columnFilters: z.array(ZColumnFilter),
})
)
.query(async ({ ctx, input }) => {
@@ -1617,8 +1612,7 @@ export const insightsRouter = router({
cursor: input.cursor,
userId: input.userId ?? null,
limit: input.limit,
bookingStatus: input.bookingStatus ?? null,
fieldFilter: input.fieldFilter ?? null,
columnFilters: input.columnFilters,
});
}),
getRoutingFormFieldOptions: userBelongsToTeamProcedure
@@ -1651,12 +1645,14 @@ export const insightsRouter = router({
})
)
.query(async ({ ctx, input }) => {
return await RoutingEventsInsights.getRoutingFormHeaders({
const headers = await RoutingEventsInsights.getRoutingFormHeaders({
teamId: input.teamId ?? null,
isAll: input.isAll,
organizationId: ctx.user.organizationId ?? null,
routingFormId: input.routingFormId ?? null,
});
return headers || [];
}),
rawRoutingData: userBelongsToTeamProcedure
.input(
@@ -7,6 +7,7 @@ import { useQueryState, parseAsBoolean } from "nuqs";
import { useMemo, useReducer, useRef, useState } from "react";
import {
DataTableProvider,
DataTable,
DataTableToolbar,
DataTableFilters,
@@ -16,6 +17,8 @@ import {
useFetchMoreOnBottomReached,
textFilter,
isTextFilterValue,
isSelectFilterValue,
selectFilter,
} from "@calcom/features/data-table";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import classNames from "@calcom/lib/classNames";
@@ -97,6 +100,14 @@ function reducer(state: UserTableState, action: UserTableAction): UserTableState
}
export function UserListTable() {
return (
<DataTableProvider>
<UserListTableContent />
</DataTableProvider>
);
}
function UserListTableContent() {
const [dynamicLinkVisible, setDynamicLinkVisible] = useQueryState("dynamicLink", parseAsBoolean);
const orgBranding = useOrgBranding();
const domain = orgBranding?.fullDomain ?? WEBAPP_URL;
@@ -164,62 +175,75 @@ export function UserListTable() {
return [];
}
return (
(attributes?.map((attribute) => ({
id: attribute.id,
header: attribute.name,
meta: {
filterType: attribute.type.toLowerCase() === "text" ? "text" : "select",
},
size: 120,
accessorFn: (data) => data.attributes.find((attr) => attr.attributeId === attribute.id)?.value,
cell: ({ row }) => {
const attributeValues = row.original.attributes.filter(
(attr) => attr.attributeId === attribute.id
);
if (attributeValues.length === 0) return null;
return (
<div
className={classNames(
attribute.type === "NUMBER" ? "flex w-full justify-center" : "flex flex-wrap"
)}>
{attributeValues.map((attributeValue, index) => {
const isAGroupOption = attributeValue.contains?.length > 0;
const suffix = attribute.isWeightsEnabled ? `${attributeValue.weight || 100}%` : undefined;
return (
<div className="mr-1 inline-flex shrink-0" key={attributeValue.id}>
<Badge
variant={isAGroupOption ? "orange" : "gray"}
className={classNames(suffix && "rounded-r-none")}>
{attributeValue.value}
</Badge>
(attributes?.map((attribute) => {
// TODO: We need to normalize AttributeOption table first
// so that we can have `number_value` column for numeric operations.
// Currently, `value` column is used for both text and number attributes.
//
// const isNumber = attribute.type === "NUMBER";
const isNumber = false;
const isText = attribute.type === "TEXT";
const filterType = isNumber ? "number" : isText ? "text" : "select";
{suffix ? (
return {
id: attribute.id,
header: attribute.name,
meta: {
filter: { type: filterType },
},
size: 120,
accessorFn: (data) => data.attributes.find((attr) => attr.attributeId === attribute.id)?.value,
cell: ({ row }) => {
const attributeValues = row.original.attributes.filter(
(attr) => attr.attributeId === attribute.id
);
if (attributeValues.length === 0) return null;
return (
<div className={classNames(isNumber ? "flex w-full justify-center" : "flex flex-wrap")}>
{attributeValues.map((attributeValue) => {
const isAGroupOption = attributeValue.contains?.length > 0;
const suffix = attribute.isWeightsEnabled
? `${attributeValue.weight || 100}%`
: undefined;
return (
<div className="mr-1 inline-flex shrink-0" key={attributeValue.id}>
<Badge
variant={isAGroupOption ? "orange" : "gray"}
style={{
backgroundColor: "color-mix(in hsl, var(--cal-bg-emphasis), black 5%)",
}}
className="rounded-l-none">
{suffix}
className={classNames(suffix && "rounded-r-none")}>
{attributeValue.value}
</Badge>
) : null}
</div>
);
})}
</div>
);
},
filterFn: (row, id, filterValue) => {
const attributeValues = row.original.attributes.filter((attr) => attr.attributeId === id);
if (isTextFilterValue(filterValue)) {
return attributeValues.some((attr) => textFilter(attr.value, filterValue));
}
{suffix ? (
<Badge
variant={isAGroupOption ? "orange" : "gray"}
style={{
backgroundColor: "color-mix(in hsl, var(--cal-bg-emphasis), black 5%)",
}}
className="rounded-l-none">
{suffix}
</Badge>
) : null}
</div>
);
})}
</div>
);
},
filterFn: (row, id, filterValue) => {
const attributeValues = row.original.attributes.filter((attr) => attr.attributeId === id);
if (attributeValues.length === 0) return false;
return attributeValues.some((attr) => filterValue.includes(attr.value));
},
})) as ColumnDef<UserTableUser>[]) ?? []
if (isTextFilterValue(filterValue)) {
return attributeValues.some((attr) => textFilter(attr.value, filterValue));
} else if (isSelectFilterValue(filterValue)) {
return selectFilter(
attributeValues.map((attr) => attr.value),
filterValue
);
}
return false;
},
};
}) as ColumnDef<UserTableUser>[]) ?? []
);
};
const cols: ColumnDef<UserTableUser>[] = [
@@ -512,7 +536,7 @@ export function UserListTable() {
table={table}
tableContainerRef={tableContainerRef}
isPending={isPending}
enableColumnResizing={{ name: "UserListTable" }}
enableColumnResizing={true}
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
<DataTableToolbar.Root className="lg:max-w-screen-2xl">
<div className="flex w-full flex-col gap-2 sm:flex-row">
@@ -534,7 +558,7 @@ export function UserListTable() {
{t("download")}
</DataTableToolbar.CTA>
{/* We have to omit member because we don't want the filter to show but we can't disable filtering as we need that for the search bar */}
<DataTableFilters.FilterButton table={table} omit={["member"]} />
<DataTableFilters.AddFilterButton table={table} omit={["member"]} />
<DataTableFilters.ColumnVisibilityButton table={table} />
{adminOrOwner && (
<DataTableToolbar.CTA
+1
View File
@@ -21,6 +21,7 @@
"seed-insights": "ts-node --transpile-only ./seed-insights.ts"
},
"devDependencies": {
"@faker-js/faker": "9.2.0",
"npm-run-all": "^4.1.5"
},
"dependencies": {
+20
View File
@@ -1,3 +1,4 @@
import { faker } from "@faker-js/faker";
import type { Prisma } from "@prisma/client";
import { PrismaClient } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
@@ -85,6 +86,23 @@ const shuffle = (
return booking;
};
async function createAttendees(bookings: any[]) {
for (const booking of bookings) {
await prisma.attendee.createMany({
data: Array(Math.floor(Math.random() * 4))
.fill(null)
.map(() => {
return {
bookingId: booking.id,
timeZone: faker.location.timeZone(),
email: faker.internet.email(),
name: faker.person.fullName(),
};
}),
});
}
}
const prisma = new PrismaClient();
async function main() {
// First find the organization we want to add insights to
@@ -268,6 +286,8 @@ async function main() {
],
});
await createAttendees(await prisma.booking.findMany());
// Find owner of the organization
const owner = orgMembers.find((m) => m.role === "OWNER" || m.role === "ADMIN");
+66 -5
View File
@@ -1,3 +1,4 @@
import { faker } from "@faker-js/faker";
import type { Prisma, UserPermissionRole } from "@prisma/client";
import { randomUUID } from "crypto";
import { uuid } from "short-uuid";
@@ -440,9 +441,18 @@ export async function seedRoutingForms(
value: "team/insights-team/team-sales",
},
],
formFieldFilled: {
formFieldSkills: {
id: "83316968-45bf-4c9d-b5d4-5368a8d2d2a8",
},
formFieldEmail: {
id: "dd28ffcf-7029-401e-bddb-ce2e7496a1c1",
},
formFieldManager: {
id: "57734f65-8bbb-4065-9e71-fb7f0b7485f8",
},
formFieldRating: {
id: "f4e9fa6c-5c5d-4d8e-b15c-7f37e9d0c729",
},
};
const form = await prisma.app_RoutingForms_Form.findUnique({
@@ -538,7 +548,7 @@ export async function seedRoutingForms(
],
fields: [
{
id: seededForm.formFieldFilled.id,
id: seededForm.formFieldSkills.id,
type: "multiselect",
label: "skills",
options: attributeRaw[2].options.map((opt) => ({
@@ -547,6 +557,24 @@ export async function seedRoutingForms(
})),
required: true,
},
{
id: seededForm.formFieldEmail.id,
type: "email",
label: "Email",
required: true,
},
{
id: seededForm.formFieldManager.id,
type: "text",
label: "Manager",
required: true,
},
{
id: seededForm.formFieldRating.id,
type: "number",
label: "Rating",
required: true,
},
],
team: {
connect: {
@@ -571,7 +599,16 @@ type SeededForm = {
id: string;
value: string;
}[];
formFieldFilled: {
formFieldSkills: {
id: string;
};
formFieldEmail: {
id: string;
};
formFieldManager: {
id: string;
};
formFieldRating: {
id: string;
};
};
@@ -623,10 +660,22 @@ export async function seedRoutingFormResponses(
formFillerId: randomUUID(),
createdAt: randomDate.toDate(),
response: {
[seededForm.formFieldFilled.id]: {
[seededForm.formFieldSkills.id]: {
label: "skills",
value: selectedSkills.map((opt) => opt.id),
},
[seededForm.formFieldEmail.id]: {
label: "Email",
value: faker.internet.email(),
},
[seededForm.formFieldManager.id]: {
label: "Manager",
value: faker.person.fullName(),
},
[seededForm.formFieldRating.id]: {
label: "Rating",
value: Math.floor(Math.random() * 5) + 1,
},
},
},
});
@@ -650,10 +699,22 @@ export async function seedRoutingFormResponses(
formFillerId: randomUUID(),
createdAt: randomDate.subtract(2, "hour").toDate(),
response: {
[seededForm.formFieldFilled.id]: {
[seededForm.formFieldSkills.id]: {
label: "skills",
value: selectedSkills.map((opt) => opt.id),
},
[seededForm.formFieldEmail.id]: {
label: "Email",
value: faker.internet.email(),
},
[seededForm.formFieldManager.id]: {
label: "Manager",
value: faker.person.fullName(),
},
[seededForm.formFieldRating.id]: {
label: "Rating",
value: Math.floor(Math.random() * 5) + 1,
},
},
},
});
@@ -117,10 +117,10 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
attribute: {
id: filter.id,
},
...makeWhereClause(
"value",
attributeOptionValues.length > 0 ? attributeOptionValues : filter.value
),
...makeWhereClause({
columnName: "value",
filterValue: attributeOptionValues.length > 0 ? attributeOptionValues : filter.value,
}),
},
},
};
+3 -1
View File
@@ -1,12 +1,14 @@
import "@tanstack/react-table";
import type { ColumnFilterMeta } from "@calcom/features/data-table";
declare module "@tanstack/table-core" {
interface ColumnMeta<TData extends RowData, TValue> {
sticky?: {
position: "left" | "right";
gap?: number;
};
filterType?: "select" | "text";
filter?: ColumnFilterMeta;
// `autoWidth` can make the column size dynamic,
// allowing each row to have a different width based on its content.
@@ -13,6 +13,7 @@ export const lucideIconList = new Set([
"badge-check",
"ban",
"bell",
"binary",
"blocks",
"bold",
"book-open-check",
@@ -13,6 +13,7 @@ export type IconName =
| "badge-check"
| "ban"
| "bell"
| "binary"
| "blocks"
| "bold"
| "book-open-check"