diff --git a/apps/web/modules/insights/insights-routing-view.tsx b/apps/web/modules/insights/insights-routing-view.tsx index 87f1c4be5f5ece..95d826f36d07d2 100644 --- a/apps/web/modules/insights/insights-routing-view.tsx +++ b/apps/web/modules/insights/insights-routing-view.tsx @@ -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 (
- - {/* 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) => ( -
-
- -
- -
- )} -
+ diff --git a/apps/web/public/icons/sprite.svg b/apps/web/public/icons/sprite.svg index 949437c333e718..598060d2134507 100644 --- a/apps/web/public/icons/sprite.svg +++ b/apps/web/public/icons/sprite.svg @@ -51,6 +51,14 @@ + + + + + + + + diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index 0c2a49ea1199a8..4f900c2c70a127 100644 --- a/packages/core/EventManager.ts +++ b/packages/core/EventManager.ts @@ -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); }); diff --git a/packages/features/apps/AdminAppsList.tsx b/packages/features/apps/AdminAppsList.tsx index 211d15e9854c69..2a9b7f282a4ffa 100644 --- a/packages/features/apps/AdminAppsList.tsx +++ b/packages/features/apps/AdminAppsList.tsx @@ -158,7 +158,7 @@ const AdminAppsList = ({
{ e.preventDefault(); diff --git a/packages/features/data-table/components/DataTable.tsx b/packages/features/data-table/components/DataTable.tsx index e0381d061bf957..a58f3d47843265 100644 --- a/packages/features/data-table/components/DataTable.tsx +++ b/packages/features/data-table/components/DataTable.tsx @@ -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 { variant?: "default" | "compact"; "data-testid"?: string; children?: React.ReactNode; - enableColumnResizing?: { name: string }; + identifier?: string; + enableColumnResizing?: boolean; } + export function DataTable({ table, tableContainerRef, @@ -33,9 +36,13 @@ export function DataTable({ onRowMouseclick, onScroll, children, + identifier: _identifier, enableColumnResizing, ...rest }: DataTableProps & 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({ usePersistentColumnResizing({ enabled: Boolean(enableColumnResizing), table, - name: enableColumnResizing?.name, + identifier, }); return ( diff --git a/packages/features/data-table/components/DataTableToolbar.tsx b/packages/features/data-table/components/DataTableToolbar.tsx index 1147dcd3c8d740..c6080a85f9bd06 100644 --- a/packages/features/data-table/components/DataTableToolbar.tsx +++ b/packages/features/data-table/components/DataTableToolbar.tsx @@ -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( ref: React.Ref ) { const { t } = useLocale(); - const isFiltered = table.getState().columnFilters.length > 0; + const columnFilters = useColumnFilters(); + const isFiltered = columnFilters.length > 0; if (!isFiltered) return null; return ( + +
+ + + + ); +} diff --git a/packages/features/data-table/components/filters/TextFilterOptions.tsx b/packages/features/data-table/components/filters/TextFilterOptions.tsx index 6031b832076e58..0a3394a23d89ee 100644 --- a/packages/features/data-table/components/filters/TextFilterOptions.tsx +++ b/packages/features/data-table/components/filters/TextFilterOptions.tsx @@ -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; }; -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, diff --git a/packages/features/data-table/components/filters/index.tsx b/packages/features/data-table/components/filters/index.tsx index bc876cdb950e3e..2eb6bc39901ad8 100644 --- a/packages/features/data-table/components/filters/index.tsx +++ b/packages/features/data-table/components/filters/index.tsx @@ -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 { @@ -118,37 +118,30 @@ const ColumnVisibilityButton = forwardRef(ColumnVisibilityButtonComponent) as ReturnType; // Filters -interface FilterButtonProps { +interface AddFilterButtonProps { table: Table; omit?: string[]; + externalFilters?: ExternalFilter[]; } -function FilterButtonComponent( - { table, omit }: FilterButtonProps, +function AddFilterButtonComponent( + { table, omit, externalFilters }: AddFilterButtonProps, ref: React.Ref ) { const { t } = useLocale(); - const [_state, _setState] = useFiltersSearchState(); - - const activeFilters = _state.activeFilters; - const columns = table - .getAllColumns() - .filter((column) => column.getCanFilter()) - .filter((column) => !omit?.includes(column.id)); - - 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 { activeFilters, setActiveFilters, displayedExternalFilters, setDisplayedExternalFilters } = + useDataTable(); + + const filterableColumns = useFilterableColumns(table, omit); + + const handleAddFilter = useCallback( + (columnId: string) => { + if (!activeFilters?.some((filter) => filter.f === columnId)) { + setActiveFilters([...activeFilters, { f: columnId, v: undefined }]); + } + }, + [activeFilters, setActiveFilters] + ); return (
@@ -167,11 +160,24 @@ function FilterButtonComponent( {filterableColumns.map((column) => { if (activeFilters?.some((filter) => filter.f === column.id)) return null; return ( - handleAddFilter(column.id)}> - {column.title} + handleAddFilter(column.id)} + className="px-4 py-2"> + {convertToTitleCase(column.title)} ); })} + {(externalFilters || []) + .filter((filter) => !displayedExternalFilters.includes(filter.key)) + .map((filter, index) => ( + setDisplayedExternalFilters((prev) => [...prev, filter.key])} + className="px-4 py-2"> + {t(filter.titleKey)} + + ))} @@ -180,59 +186,94 @@ function FilterButtonComponent( ); } -const FilterButton = forwardRef(FilterButtonComponent) as ( - props: FilterButtonProps & { ref?: React.Ref; omit?: string[] } -) => ReturnType; +function useFilterableColumns(table: Table, omit?: string[]) { + const columns = useMemo( + () => + table + .getAllColumns() + .filter((column) => column.getCanFilter()) + .filter((column) => !omit?.includes(column.id)), + [table.getAllColumns(), omit] + ); + + const filterableColumns = useMemo( + () => + 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 ( + props: AddFilterButtonProps & { ref?: React.Ref; omit?: string[] } +) => ReturnType; // Add the new ActiveFilters component interface ActiveFiltersProps { table: Table; + externalFilters?: ExternalFilter[]; } -function ActiveFilters({ table }: ActiveFiltersProps) { - const [_state, _setState] = useFiltersSearchState(); - - const columns = table.getAllColumns().filter((column) => column.getCanFilter()); +const filterIcons = { + text: "file-text", + number: "binary", + select: "layers", +} as const; - const filterableColumns = useMemo(() => { - 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({ table, externalFilters }: ActiveFiltersProps) { + 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 ( - + ); })} + {(displayedExternalFilters || []).map((key) => { + const filter = externalFilters?.find((filter) => filter.key === key); + if (!filter) return null; + return {filter.component()}; + })} ); } // Update the export to include ActiveFilters -export const DataTableFilters = { ColumnVisibilityButton, FilterButton, ActiveFilters }; +export const DataTableFilters = { ColumnVisibilityButton, AddFilterButton, ActiveFilters }; diff --git a/packages/features/data-table/index.ts b/packages/features/data-table/index.ts index 0f732c504f0554..7ab099a4f8dba2 100644 --- a/packages/features/data-table/index.ts +++ b/packages/features/data-table/index.ts @@ -1,4 +1,5 @@ export * from "./components"; export * from "./lib/types"; export * from "./lib/utils"; +export * from "./lib/context"; export * from "./lib/resizing"; diff --git a/packages/features/data-table/lib/context.tsx b/packages/features/data-table/lib/context.tsx new file mode 100644 index 00000000000000..68b47629c89c53 --- /dev/null +++ b/packages/features/data-table/lib/context.tsx @@ -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; + +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>; + removeDisplayedExternalFilter: (key: string) => void; +}; + +export const DataTableContext = createContext(null); + +export function DataTableProvider({ children }: { children: React.ReactNode }) { + const [activeFilters, setActiveFilters] = useQueryState( + "activeFilters", + parseAsArrayOf(parseAsJson(dataTableFiltersSchema.parse)).withDefault([]) + ); + + const [displayedExternalFilters, setDisplayedExternalFilters] = useState([]); + + 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 ( + + {children} + + ); +} diff --git a/packages/features/data-table/lib/resizing.ts b/packages/features/data-table/lib/resizing.ts index b56f1a3c5d7bfe..50906cdbbba609 100644 --- a/packages/features/data-table/lib/resizing.ts +++ b/packages/features/data-table/lib/resizing.ts @@ -6,24 +6,24 @@ import { useState, useCallback, useEffect } from "react"; type UsePersistentColumnResizingProps = { enabled: boolean; table: Table; - 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({ enabled, table, - name, + identifier, }: UsePersistentColumnResizingProps) { const [_, setColumnSizing] = useState({}); 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({ }; }); }, - [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({ columnResizeMode: "onChange", onColumnSizingChange, })); - }, [enabled, name, table, onColumnSizingChange]); + }, [enabled, identifier, table, onColumnSizingChange]); } diff --git a/packages/features/data-table/lib/server.ts b/packages/features/data-table/lib/server.ts index acf22e0615ff31..775cf84546f61c 100644 --- a/packages/features/data-table/lib/server.ts +++ b/packages/features/data-table/lib/server.ts @@ -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 {}; diff --git a/packages/features/data-table/lib/types.ts b/packages/features/data-table/lib/types.ts index 9beb0d40264cd0..460d9598065510 100644 --- a/packages/features/data-table/lib/types.ts +++ b/packages/features/data-table/lib/types.ts @@ -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; -export const ZFilterValue = z.union([ZSelectFilterValue, ZTextFilterValue]); +export const ZNumberFilterOperator = z.enum([ + "eq", // = + "neq", // != + "gt", // > + "gte", // >= + "lt", // < + "lte", // <= +]); + +export type NumberFilterOperator = z.infer; + +export const ZNumberFilterValue = z.object({ + type: z.literal("number"), + data: z.object({ + operator: ZNumberFilterOperator, + operand: z.number(), + }), +}); + +export type NumberFilterValue = z.infer; + +export const ZFilterValue = z.union([ZSelectFilterValue, ZTextFilterValue, ZNumberFilterValue]); export type FilterValue = z.infer; +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; +} & ( + | { + type: "select"; + icon?: IconName; + options: Map; + } + | { + type: "text"; + icon?: IconName; + } + | { + type: "number"; + icon?: IconName; + } +); + +export const ZColumnFilter = z.object({ + id: z.string(), + value: ZFilterValue, +}); + +export type ColumnFilter = z.infer; + +export type TypedColumnFilter = { + 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; }; diff --git a/packages/features/data-table/lib/utils.ts b/packages/features/data-table/lib/utils.ts index f5a4e191052e60..b963022b7a397f 100644 --- a/packages/features/data-table/lib/utils.ts +++ b/packages/features/data-table/lib/utils.ts @@ -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 useDataTable() { + const context = useContext(DataTableContext); + if (!context) { + throw new Error("useDataTable must be used within a DataTableProvider"); + } + return context; +} -export function useFiltersSearchState() { - return useQueryStates(filtersSearchParams); +export function useFilterValue(columnId: string, schema: z.ZodType) { + 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() { - const [filtersSearchState] = useFiltersSearchState(); - const columnFilters = useMemo(() => { - return (filtersSearchState.activeFilters || []) - .map((filter) => ({ - id: filter.f, - value: filter.v, - })) +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[0]; -export type SetFiltersSearchState = ReturnType[1]; -export type ActiveFilter = NonNullable[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()); }; diff --git a/packages/features/ee/teams/components/MemberList.tsx b/packages/features/ee/teams/components/MemberList.tsx index 9af2657967757d..510bb825b769b4 100644 --- a/packages/features/ee/teams/components/MemberList.tsx +++ b/packages/features/ee/teams/components/MemberList.tsx @@ -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 ( + + + + ); +} + +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([]); + 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) {
setDebouncedSearchTerm(value)} /> - + {isAdminOrOwner && ( @@ -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
; + if (values.length === 0) return
; return ( - {value.length > 2 ? ( + {values.length > 2 ? ( <> - {value.slice(0, 2).map((v: string, i: number) => ( + {values.slice(0, 2).map((value, i: number) => ( - {v} + {value.label} ))} - +{value.length - 2} + +{values.length - 2}
- {value.slice(2).map((v: string, i: number) => ( + {values.slice(2).map((value, i: number) => ( - {v} + {value.label} ))}
@@ -127,9 +160,9 @@ function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number })
) : ( - value.map((v: string, i: number) => ( + values.map((value, i: number) => ( - {v} + {value.label} )) )} @@ -215,7 +248,15 @@ function BookingAtCell({ export type RoutingFormTableType = ReturnType>; -export function RoutingFormResponsesTable({ +export function RoutingFormResponsesTable() { + return ( + + + + ); +} + +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(null); - const { copyToClipboard, isCopied } = useCopy(); - - const { - dateRange, - selectedTeamId, - isAll, - initialConfig, - selectedRoutingFormId, - selectedMemberUserId, - selectedBookingStatus, - selectedRoutingFormFilter, - } = filter; + const { copyToClipboard } = useCopy(); + + 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 ; }, }), - ...(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 ( -
- + const values = info.getValue(); + const result = isSelect ? ZResponseValues.safeParse(values) : null; + return isSelect && result?.success ? ( + + ) : ( +
+ {values}
); }, + 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({
), + 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) => (
{ const assignmentReason = info.getValue()?.assignmentReason; return ( @@ -396,11 +481,15 @@ export function RoutingFormResponsesTable({
); }, + 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) => (
{dayjs(info.getValue()).format("MMM D, YYYY HH:mm")} @@ -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( + () => [ + { + key: "memberUserId", + titleKey: "people", + component: () => ( + removeDisplayedExternalFilter("memberUserId")} + /> + ), + }, + ], + [removeDisplayedExternalFilter] + ); + if (isHeadersLoading || ((isFetching || isLoading) && !data)) { return ; } @@ -443,8 +571,22 @@ export function RoutingFormResponsesTable({ fetchMoreOnBottomReached(e.target as HTMLDivElement); } }} + enableColumnResizing={true} isPending={isFetching && !data}> - {typeof children === "function" ? children(table) : children} +
+
+ + + + + +
+ + + +
+ +
); diff --git a/packages/features/insights/filters/ClearFilters.tsx b/packages/features/insights/filters/ClearFilters.tsx new file mode 100644 index 00000000000000..03db580efa48f9 --- /dev/null +++ b/packages/features/insights/filters/ClearFilters.tsx @@ -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 ( + + + + ); +}; diff --git a/packages/features/insights/filters/DateSelect.tsx b/packages/features/insights/filters/DateSelect.tsx index 2432754b8da58b..1f3169c2967ead 100644 --- a/packages/features/insights/filters/DateSelect.tsx +++ b/packages/features/insights/filters/DateSelect.tsx @@ -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 ( -
+
{ - 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"), - } - ); - - if (!selectedFilter?.includes("routing_forms")) return null; - - const filterOptions = buildFilterOptions(allForms || []); - - return ( - setConfigFilters({ selectedRoutingFormId: value as string })} - buttonIcon={} - /> - ); -}); +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"), + } + ); + + if (showOnlyWhenSelectedInContext && !selectedFilter?.includes("routing_forms")) return null; + + const filterOptions = buildFilterOptions(allForms || []); + + return ( + setConfigFilters({ selectedRoutingFormId: value as string })} + buttonIcon={} + /> + ); + } +); RoutingFormFilterList.displayName = "RoutingFormFilterList"; diff --git a/packages/features/insights/filters/TeamAndSelfList.tsx b/packages/features/insights/filters/TeamAndSelfList.tsx index 064d38ddec345a..241a33c33b4c9d 100644 --- a/packages/features/insights/filters/TeamAndSelfList.tsx +++ b/packages/features/insights/filters/TeamAndSelfList.tsx @@ -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 ( - + {isOrgDataAvailable && ( ({ icon: , }); -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={} placeholder={t("search")} testId="people-filter" diff --git a/packages/features/insights/filters/index.tsx b/packages/features/insights/filters/index.tsx index 62b0d8ed40e257..9d277e5b03edc5 100644 --- a/packages/features/insights/filters/index.tsx +++ b/packages/features/insights/filters/index.tsx @@ -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 */}
{showRoutingFilters ? : } - +
); diff --git a/packages/features/insights/filters/routing/FilterBar.tsx b/packages/features/insights/filters/routing/FilterBar.tsx deleted file mode 100644 index 88575018922845..00000000000000 --- a/packages/features/insights/filters/routing/FilterBar.tsx +++ /dev/null @@ -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 ( - - - - ); -}; - -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 ( -
-
- - - - - - - {routingFormFieldIds.map((fieldId) => { - if (fieldId) return ; - })} - - - - -
- -
- - - -
-
- ); -}; diff --git a/packages/features/insights/server/routing-events.ts b/packages/features/insights/server/routing-events.ts index 7afd71ce364908..b2c0bbb0b5ed71 100644 --- a/packages/features/insights/server/routing-events.ts +++ b/packages/features/insights/server/routing-events.ts @@ -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) { // 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 & { 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 & { take?: number; skip?: number }) { const formsWhereCondition = await this.getWhereForTeamOrAllTeams({ teamId, isAll, diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index 640ba9525b165e..f36dd56425911a 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -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( diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index 963ea49a05d028..1a472a3925738a 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -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 ( + + + + ); +} + +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 ( -
- {attributeValues.map((attributeValue, index) => { - const isAGroupOption = attributeValue.contains?.length > 0; - const suffix = attribute.isWeightsEnabled ? `${attributeValue.weight || 100}%` : undefined; - return ( -
- - {attributeValue.value} - - - {suffix ? ( + (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"; + + 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 ( +
+ {attributeValues.map((attributeValue) => { + const isAGroupOption = attributeValue.contains?.length > 0; + const suffix = attribute.isWeightsEnabled + ? `${attributeValue.weight || 100}%` + : undefined; + return ( +
- {suffix} + className={classNames(suffix && "rounded-r-none")}> + {attributeValue.value} - ) : null} -
- ); - })} -
- ); - }, - 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)); - } - if (attributeValues.length === 0) return false; - return attributeValues.some((attr) => filterValue.includes(attr.value)); - }, - })) as ColumnDef[]) ?? [] + {suffix ? ( + + {suffix} + + ) : null} +
+ ); + })} +
+ ); + }, + 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)); + } else if (isSelectFilterValue(filterValue)) { + return selectFilter( + attributeValues.map((attr) => attr.value), + filterValue + ); + } + return false; + }, + }; + }) as ColumnDef[]) ?? [] ); }; const cols: ColumnDef[] = [ @@ -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)}>
@@ -534,7 +558,7 @@ export function UserListTable() { {t("download")} {/* 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 */} - + {adminOrOwner && ( { + 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"); diff --git a/packages/prisma/seed-utils.ts b/packages/prisma/seed-utils.ts index b6c0049d664d0b..42c9f86bfe22bb 100644 --- a/packages/prisma/seed-utils.ts +++ b/packages/prisma/seed-utils.ts @@ -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, + }, }, }, }); diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts index 1f94304062847a..13898580ebaacc 100644 --- a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts @@ -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, + }), }, }, }; diff --git a/packages/types/tanstack-table.d.ts b/packages/types/tanstack-table.d.ts index 59fbebc289e980..ffe75143b1b8cd 100644 --- a/packages/types/tanstack-table.d.ts +++ b/packages/types/tanstack-table.d.ts @@ -1,12 +1,14 @@ import "@tanstack/react-table"; +import type { ColumnFilterMeta } from "@calcom/features/data-table"; + declare module "@tanstack/table-core" { interface ColumnMeta { 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. diff --git a/packages/ui/components/icon/icon-list.mjs b/packages/ui/components/icon/icon-list.mjs index 5d15657c5ffea1..9936aba073d094 100644 --- a/packages/ui/components/icon/icon-list.mjs +++ b/packages/ui/components/icon/icon-list.mjs @@ -13,6 +13,7 @@ export const lucideIconList = new Set([ "badge-check", "ban", "bell", + "binary", "blocks", "bold", "book-open-check", diff --git a/packages/ui/components/icon/icon-names.ts b/packages/ui/components/icon/icon-names.ts index a08e8f90414b24..91460ca59e49ed 100644 --- a/packages/ui/components/icon/icon-names.ts +++ b/packages/ui/components/icon/icon-names.ts @@ -13,6 +13,7 @@ export type IconName = | "badge-check" | "ban" | "bell" + | "binary" | "blocks" | "bold" | "book-open-check"