diff --git a/apps/web/modules/insights/insights-routing-view.tsx b/apps/web/modules/insights/insights-routing-view.tsx index 95d826f36d..87f1c4be5f 100644 --- a/apps/web/modules/insights/insights-routing-view.tsx +++ b/apps/web/modules/insights/insights-routing-view.tsx @@ -1,19 +1,35 @@ "use client"; -import { FailedBookingsByField, RoutingFormResponsesTable, RoutedToPerPeriod } from "@calcom/features/insights/components"; +import { + FailedBookingsByField, + RoutingFormResponsesTable, + RoutingKPICards, + 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 InsightsRoutingFormResponsesPage() { +export default function InsightsPage() { 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 598060d213..949437c333 100644 --- a/apps/web/public/icons/sprite.svg +++ b/apps/web/public/icons/sprite.svg @@ -51,14 +51,6 @@ - - - - - - - - diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index 4f900c2c70..0c2a49ea11 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 - const errorMsg = error instanceof Error ? error.message : JSON.stringify(error); + let 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 2a9b7f282a..211d15e985 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 a58f3d4784..e0381d061b 100644 --- a/packages/features/data-table/components/DataTable.tsx +++ b/packages/features/data-table/components/DataTable.tsx @@ -6,7 +6,6 @@ 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"; @@ -24,10 +23,8 @@ export interface DataTableProps { variant?: "default" | "compact"; "data-testid"?: string; children?: React.ReactNode; - identifier?: string; - enableColumnResizing?: boolean; + enableColumnResizing?: { name: string }; } - export function DataTable({ table, tableContainerRef, @@ -36,13 +33,9 @@ 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 @@ -89,7 +82,7 @@ export function DataTable({ usePersistentColumnResizing({ enabled: Boolean(enableColumnResizing), table, - identifier, + name: enableColumnResizing?.name, }); return ( diff --git a/packages/features/data-table/components/DataTableToolbar.tsx b/packages/features/data-table/components/DataTableToolbar.tsx index c6080a85f9..1147dcd3c8 100644 --- a/packages/features/data-table/components/DataTableToolbar.tsx +++ b/packages/features/data-table/components/DataTableToolbar.tsx @@ -10,8 +10,6 @@ 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; } @@ -91,8 +89,7 @@ function ClearFiltersButtonComponent( ref: React.Ref ) { const { t } = useLocale(); - const columnFilters = useColumnFilters(); - const isFiltered = columnFilters.length > 0; + const isFiltered = table.getState().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 0a3394a23d..6031b83207 100644 --- a/packages/features/data-table/components/filters/TextFilterOptions.tsx +++ b/packages/features/data-table/components/filters/TextFilterOptions.tsx @@ -1,13 +1,9 @@ -"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, TextFilterOperator } from "../../lib/types"; -import { ZTextFilterValue } from "../../lib/types"; -import { useFilterValue, useDataTable } from "../../lib/utils"; +import type { FilterableColumn, TextFilterValue, TextFilterOperator } from "../../lib/types"; export type TextFilterOperatorOption = { label: string; @@ -30,14 +26,20 @@ const useTextFilterOperatorOptions = (): TextFilterOperatorOption[] => { }; export type TextFilterOptionsProps = { - column: Extract; + column: FilterableColumn; + filterValue?: TextFilterValue; + setFilterValue: (value: TextFilterValue) => void; + removeFilter: (columnId: string) => void; }; -export function TextFilterOptions({ column }: TextFilterOptionsProps) { +export function TextFilterOptions({ + column, + filterValue, + setFilterValue, + removeFilter, +}: TextFilterOptionsProps) { const { t } = useLocale(); const textFilterOperatorOptions = useTextFilterOperatorOptions(); - const filterValue = useFilterValue(column.id, ZTextFilterValue); - const { updateFilter, removeFilter } = useDataTable(); const form = useForm({ defaultValues: { @@ -54,7 +56,7 @@ export function TextFilterOptions({ column }: TextFilterOptionsProps) { form={form} handleSubmit={({ operatorOption, operand }) => { if (operatorOption) { - updateFilter(column.id, { + setFilterValue({ 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 2eb6bc3990..bc876cdb95 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, useCallback, Fragment } from "react"; +import { forwardRef, useState, useMemo } 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, ExternalFilter } from "../../lib/types"; -import { convertToTitleCase, useDataTable } from "../../lib/utils"; +import type { FilterableColumn } from "../../lib/types"; +import { useFiltersSearchState } from "../../lib/utils"; import { FilterOptions } from "./FilterOptions"; interface ColumnVisiblityProps { @@ -118,30 +118,37 @@ const ColumnVisibilityButton = forwardRef(ColumnVisibilityButtonComponent) as ReturnType; // Filters -interface AddFilterButtonProps { +interface FilterButtonProps { table: Table; omit?: string[]; - externalFilters?: ExternalFilter[]; } -function AddFilterButtonComponent( - { table, omit, externalFilters }: AddFilterButtonProps, +function FilterButtonComponent( + { table, omit }: FilterButtonProps, ref: React.Ref ) { const { t } = useLocale(); - const { activeFilters, setActiveFilters, displayedExternalFilters, setDisplayedExternalFilters } = - useDataTable(); + const [_state, _setState] = useFiltersSearchState(); - const filterableColumns = useFilterableColumns(table, omit); + const activeFilters = _state.activeFilters; + const columns = table + .getAllColumns() + .filter((column) => column.getCanFilter()) + .filter((column) => !omit?.includes(column.id)); - const handleAddFilter = useCallback( - (columnId: string) => { - if (!activeFilters?.some((filter) => filter.f === columnId)) { - setActiveFilters([...activeFilters, { f: columnId, v: undefined }]); - } - }, - [activeFilters, setActiveFilters] - ); + 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: [] }] }); + } + }; return (
@@ -160,24 +167,11 @@ function AddFilterButtonComponent( {filterableColumns.map((column) => { if (activeFilters?.some((filter) => filter.f === column.id)) return null; return ( - handleAddFilter(column.id)} - className="px-4 py-2"> - {convertToTitleCase(column.title)} + handleAddFilter(column.id)}> + {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)} - - ))} @@ -186,94 +180,59 @@ function AddFilterButtonComponent( ); } -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; +const FilterButton = forwardRef(FilterButtonComponent) as ( + props: FilterButtonProps & { ref?: React.Ref; omit?: string[] } +) => ReturnType; // Add the new ActiveFilters component interface ActiveFiltersProps { table: Table; - externalFilters?: ExternalFilter[]; } -const filterIcons = { - text: "file-text", - number: "binary", - select: "layers", -} as const; +function ActiveFilters({ table }: ActiveFiltersProps) { + const [_state, _setState] = useFiltersSearchState(); -function ActiveFilters({ table, externalFilters }: ActiveFiltersProps) { - const { activeFilters, displayedExternalFilters } = useDataTable(); - const filterableColumns = useFilterableColumns(table); + const columns = table.getAllColumns().filter((column) => column.getCanFilter()); + + 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]); return ( <> - {activeFilters.map((filter) => { + {(_state.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, AddFilterButton, ActiveFilters }; +export const DataTableFilters = { ColumnVisibilityButton, FilterButton, ActiveFilters }; diff --git a/packages/features/data-table/index.ts b/packages/features/data-table/index.ts index 7ab099a4f8..0f732c504f 100644 --- a/packages/features/data-table/index.ts +++ b/packages/features/data-table/index.ts @@ -1,5 +1,4 @@ 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 deleted file mode 100644 index 68b47629c8..0000000000 --- a/packages/features/data-table/lib/context.tsx +++ /dev/null @@ -1,80 +0,0 @@ -"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 50906cdbbb..b56f1a3c5d 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; - identifier?: string; + name?: string; }; -function getLocalStorageKey(identifier: string) { - return `data-table-column-sizing-${identifier}`; +function getLocalStorageKey(name: string) { + return `data-table-column-sizing-${name}`; } -function loadColumnSizing(identifier: string) { +function loadColumnSizing(name: string) { try { - return JSON.parse(localStorage.getItem(getLocalStorageKey(identifier)) || "{}"); + return JSON.parse(localStorage.getItem(getLocalStorageKey(name)) || "{}"); } catch (error) { return {}; } return {}; } -function saveColumnSizing(identifier: string, columnSizing: ColumnSizingState) { - localStorage.setItem(getLocalStorageKey(identifier), JSON.stringify(columnSizing)); +function saveColumnSizing(name: string, columnSizing: ColumnSizingState) { + localStorage.setItem(getLocalStorageKey(name), JSON.stringify(columnSizing)); } const debouncedSaveColumnSizing = debounce(saveColumnSizing, 1000); @@ -31,19 +31,19 @@ const debouncedSaveColumnSizing = debounce(saveColumnSizing, 1000); export function usePersistentColumnResizing({ enabled, table, - identifier, + name, }: UsePersistentColumnResizingProps) { const [_, setColumnSizing] = useState({}); const onColumnSizingChange = useCallback( (updater: ColumnSizingState | ((old: ColumnSizingState) => ColumnSizingState)) => { - // `!identifier` is checked already in the `useEffect` hook, + // `!name` is checked already in the `useEffect` hook, // but TS doesn't know that, and this won't happen. - if (!identifier) return; + if (!name) return; table.setState((oldTableState) => { const newColumnSizing = typeof updater === "function" ? updater(oldTableState.columnSizing) : updater; - debouncedSaveColumnSizing(identifier, newColumnSizing); + debouncedSaveColumnSizing(name, newColumnSizing); setColumnSizing(newColumnSizing); return { @@ -52,13 +52,13 @@ export function usePersistentColumnResizing({ }; }); }, - [identifier, table] + [name, table] ); useEffect(() => { - if (!enabled || !identifier) return; + if (!enabled || !name) return; - const newColumnSizing = loadColumnSizing(identifier); + const newColumnSizing = loadColumnSizing(name); setColumnSizing(newColumnSizing); table.setState((old) => ({ ...old, @@ -69,5 +69,5 @@ export function usePersistentColumnResizing({ columnResizeMode: "onChange", onColumnSizingChange, })); - }, [enabled, identifier, table, onColumnSizingChange]); + }, [enabled, name, table, onColumnSizingChange]); } diff --git a/packages/features/data-table/lib/server.ts b/packages/features/data-table/lib/server.ts index 775cf84546..acf22e0615 100644 --- a/packages/features/data-table/lib/server.ts +++ b/packages/features/data-table/lib/server.ts @@ -1,76 +1,62 @@ import type { FilterValue } from "./types"; -import { isSelectFilterValue, isTextFilterValue, isNumberFilterValue } from "./utils"; +import { isSelectFilterValue, isTextFilterValue } from "./utils"; -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 } : {}; +export function makeWhereClause(columnName: string, filterValue: FilterValue) { if (isSelectFilterValue(filterValue)) { return { [columnName]: { - ...jsonPathObj, - ...(isJson ? { array_contains: filterValue } : { in: 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]: { - ...jsonPathObj, - ...(isJson ? { string_contains: operand } : { contains: operand, mode: "insensitive" }), + contains: operand, + mode: "insensitive", }, }; case "notContains": return { NOT: { [columnName]: { - ...jsonPathObj, - ...(isJson ? { string_contains: operand } : { contains: operand, mode: "insensitive" }), + contains: operand, + mode: "insensitive", }, }, }; case "startsWith": return { [columnName]: { - ...jsonPathObj, - ...(isJson ? { string_starts_with: operand } : { startsWith: operand, mode: "insensitive" }), + startsWith: operand, + mode: "insensitive", }, }; case "endsWith": return { [columnName]: { - ...jsonPathObj, - ...(isJson ? { string_ends_with: operand } : { endsWith: operand, mode: "insensitive" }), + endsWith: operand, + mode: "insensitive", }, }; case "isEmpty": return { [columnName]: { - ...jsonPathObj, equals: "", }, }; @@ -78,61 +64,10 @@ export function makeWhereClause(props: makeWhereClauseProps) { 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 460d959806..9beb0d4026 100644 --- a/packages/features/data-table/lib/types.ts +++ b/packages/features/data-table/lib/types.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -import type { IconName } from "@calcom/ui"; - export const ZTextFilterOperator = z.enum([ "equals", "notEquals", @@ -29,77 +27,13 @@ export const ZTextFilterValue = z.object({ export type TextFilterValue = z.infer; -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 const ZFilterValue = z.union([ZSelectFilterValue, ZTextFilterValue]); 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; -} & ( - | { - 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; + filterType: "text" | "select"; + options: Map; }; diff --git a/packages/features/data-table/lib/utils.ts b/packages/features/data-table/lib/utils.ts index b963022b7a..f5a4e19105 100644 --- a/packages/features/data-table/lib/utils.ts +++ b/packages/features/data-table/lib/utils.ts @@ -1,78 +1,51 @@ "use client"; -import { useMemo, useContext } from "react"; -import type { z } from "zod"; +import { parseAsArrayOf, parseAsJson, useQueryStates } from "nuqs"; +import { useMemo } from "react"; +import { z } from "zod"; -import { DataTableContext } from "./context"; -import type { - SelectFilterValue, - TextFilterValue, - FilterValue, - NumberFilterValue, - ColumnFilter, -} from "./types"; -import { ZFilterValue, ZNumberFilterValue, ZSelectFilterValue, ZTextFilterValue } from "./types"; +import type { SelectFilterValue, TextFilterValue } from "./types"; +import { ZSelectFilterValue, ZTextFilterValue } from "./types"; -export function useDataTable() { - const context = useContext(DataTableContext); - if (!context) { - throw new Error("useDataTable must be used within a DataTableProvider"); - } - return context; +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 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(): 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) +export function useColumnFilters() { + const [filtersSearchState] = useFiltersSearchState(); + const columnFilters = useMemo(() => { + return (filtersSearchState.activeFilters || []) + .map((filter) => ({ + id: filter.f, + value: filter.v, + })) .filter((filter) => { // The empty arrays in `filtersSearchState` keep the filter UI component, // but we do not send them to the actual query. - // Otherwise, `{ my_column_name: { in: []} }` would result in nothing being returned. - if (isSelectFilterValue(filter.value) && filter.value.length === 0) { + // Otherwise, { value: [] } would result in nothing being returned. + if (Array.isArray(filter.value) && filter.value.length === 0) { return false; } return true; }); - }, [activeFilters]); + }, [filtersSearchState.activeFilters]); + return columnFilters; } -export const textFilter = (cellValue: unknown, filterValue: TextFilterValue) => { - if (filterValue.data.operator === "isEmpty" && cellValue === undefined) { - return true; - } - - if (typeof cellValue !== "string") { - return false; - } +export type FiltersSearchState = ReturnType[0]; +export type SetFiltersSearchState = ReturnType[1]; +export type ActiveFilter = NonNullable[number]; +export const textFilter = (cellValue: string, filterValue: TextFilterValue) => { switch (filterValue.data.operator) { case "equals": return cellValue.toLowerCase() === (filterValue.data.operand || "").toLowerCase(); @@ -96,60 +69,14 @@ export const textFilter = (cellValue: unknown, filterValue: TextFilterValue) => }; export const isTextFilterValue = (filterValue: unknown): filterValue is TextFilterValue => { - 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)); + return ( + typeof filterValue === "object" && + filterValue !== null && + "type" in filterValue && + filterValue.type === "text" + ); }; export const isSelectFilterValue = (filterValue: unknown): filterValue is SelectFilterValue => { - 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()); + return Array.isArray(filterValue) && filterValue.every((item) => typeof item === "string"); }; diff --git a/packages/features/ee/teams/components/MemberList.tsx b/packages/features/ee/teams/components/MemberList.tsx index 510bb825b7..9af2657967 100644 --- a/packages/features/ee/teams/components/MemberList.tsx +++ b/packages/features/ee/teams/components/MemberList.tsx @@ -1,6 +1,7 @@ "use client"; import { keepPreviousData } from "@tanstack/react-query"; +import type { ColumnFiltersState } from "@tanstack/react-table"; import { getCoreRowModel, getFilteredRowModel, @@ -18,12 +19,10 @@ 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"; @@ -151,14 +150,6 @@ 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(); @@ -177,8 +168,6 @@ function MemberListContent(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, @@ -190,7 +179,8 @@ function MemberListContent(props: Props) { } ); - const columnFilters = useColumnFilters(); + // 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 [rowSelection, setRowSelection] = useState({}); const removeMemberFromCache = ({ @@ -635,6 +625,7 @@ function MemberListContent(props: Props) { columnFilters, rowSelection, }, + onColumnFiltersChange: setColumnFilters, onRowSelectionChange: setRowSelection, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), @@ -663,7 +654,7 @@ function MemberListContent(props: Props) {
setDebouncedSearchTerm(value)} /> - + {isAdminOrOwner && ( @@ -129,29 +96,29 @@ function BookedByCell({ ); } -function ResponseValueCell({ values, rowId }: { values: ResponseValues; rowId: number }) { +function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number }) { const cellId = useId(); - if (values.length === 0) return
; + if (value.length === 0) return
; return ( - {values.length > 2 ? ( + {value.length > 2 ? ( <> - {values.slice(0, 2).map((value, i: number) => ( + {value.slice(0, 2).map((v: string, i: number) => ( - {value.label} + {v} ))} - +{values.length - 2} + +{value.length - 2}
- {values.slice(2).map((value, i: number) => ( + {value.slice(2).map((v: string, i: number) => ( - {value.label} + {v} ))}
@@ -160,9 +127,9 @@ function ResponseValueCell({ values, rowId }: { values: ResponseValues; rowId: n
) : ( - values.map((value, i: number) => ( + value.map((v: string, i: number) => ( - {value.label} + {v} )) )} @@ -248,15 +215,7 @@ function BookingAtCell({ export type RoutingFormTableType = ReturnType>; -export function RoutingFormResponsesTable() { - return ( - - - - ); -} - -export function RoutingFormResponsesTableContent({ +export function RoutingFormResponsesTable({ children, }: { children?: React.ReactNode | ((table: RoutingFormTableType) => React.ReactNode); @@ -264,15 +223,21 @@ export function RoutingFormResponsesTableContent({ const { t } = useLocale(); const { filter } = useFilterContext(); const tableContainerRef = useRef(null); - const { copyToClipboard } = useCopy(); + const { copyToClipboard, isCopied } = useCopy(); - const { dateRange, selectedTeamId, isAll, initialConfig, selectedRoutingFormId, selectedMemberUserId } = - filter; + const { + dateRange, + selectedTeamId, + isAll, + initialConfig, + selectedRoutingFormId, + selectedMemberUserId, + selectedBookingStatus, + selectedRoutingFormFilter, + } = 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( { @@ -294,7 +259,8 @@ export function RoutingFormResponsesTableContent({ userId: selectedMemberUserId ?? undefined, isAll: isAll ?? false, routingFormId: selectedRoutingFormId ?? undefined, - columnFilters, + bookingStatus: selectedBookingStatus ?? undefined, + fieldFilter: selectedRoutingFormFilter ?? undefined, limit: 30, }, { @@ -323,27 +289,20 @@ export function RoutingFormResponsesTableContent({ }; Object.entries(response.response).forEach(([fieldId, field]) => { - const fieldHeader = (headers || []).find((h) => h.id === fieldId); + const header = headers?.find((h) => h.id === fieldId); - if (fieldHeader?.options) { + if (header?.options) { if (Array.isArray(field.value)) { // Map the IDs to their corresponding labels for array values - 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); + const labels = field.value.map((id) => { + const option = header.options?.find((opt) => opt.id === id); + return option?.label ?? id; + }); row[fieldId] = labels; } else { // Handle single value case - const option = fieldHeader.options?.find((opt) => opt.id === field.value); - if (option) { - row[fieldId] = { label: option.label, value: option.id }; - } + const option = header.options?.find((opt) => opt.id === field.value); + row[fieldId] = option?.label ?? field.value; } } else { row[fieldId] = field.value; @@ -366,64 +325,30 @@ export function RoutingFormResponsesTableContent({ const columns = useMemo( () => [ - columnHelper.accessor("routedToBooking", { + columnHelper.accessor("bookedAttendees", { id: "bookedBy", header: t("routing_form_insights_booked_by"), size: 200, - enableColumnFilter: false, cell: (info) => { const row = info.row.original; return ; }, }), - ...((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), + ...(headers?.map((header) => { + return columnHelper.accessor(header.id, { + id: header.id, + header: header.label, size: 200, cell: (info) => { - const values = info.getValue(); - const result = isSelect ? ZResponseValues.safeParse(values) : null; - return isSelect && result?.success ? ( - - ) : ( -
- {values} + let value = info.getValue(); + value = Array.isArray(value) ? value : [value]; + return ( +
+
); }, - 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", { @@ -435,12 +360,6 @@ export function RoutingFormResponsesTableContent({
), - 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; @@ -454,7 +373,6 @@ export function RoutingFormResponsesTableContent({ id: "bookingAt", header: t("routing_form_insights_booking_at"), size: 250, - enableColumnFilter: false, cell: (info) => (
{ const assignmentReason = info.getValue()?.assignmentReason; return ( @@ -481,15 +396,11 @@ export function RoutingFormResponsesTableContent({
); }, - 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")} @@ -509,27 +420,6 @@ export function RoutingFormResponsesTableContent({ 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({ @@ -539,24 +429,6 @@ export function RoutingFormResponsesTableContent({ isFetching, }); - const { removeDisplayedExternalFilter } = useDataTable(); - - const externalFilters = useMemo( - () => [ - { - key: "memberUserId", - titleKey: "people", - component: () => ( - removeDisplayedExternalFilter("memberUserId")} - /> - ), - }, - ], - [removeDisplayedExternalFilter] - ); - if (isHeadersLoading || ((isFetching || isLoading) && !data)) { return ; } @@ -571,22 +443,8 @@ export function RoutingFormResponsesTableContent({ 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 deleted file mode 100644 index 03db580efa..0000000000 --- a/packages/features/insights/filters/ClearFilters.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useDataTable } from "@calcom/features/data-table"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { Icon, Button, Tooltip } from "@calcom/ui"; - -import { useFilterContext } from "../context/provider"; - -// This component clears filters from both: -// - the filter from FilterContext -// - the data table filters state -export const ClearFilters = () => { - const { t } = useLocale(); - const { clearFilters, filter } = useFilterContext(); - const { activeFilters, clearAll } = useDataTable(); - - const clear = () => { - // clear filters from the filter context - clearFilters(); - - // clear filters from the data table state - clearAll(); - }; - - const { initialConfig, selectedTeamId, selectedMemberUserId } = filter; - - const isFilterSelected = - initialConfig?.teamId !== selectedTeamId || - initialConfig?.userId !== selectedMemberUserId || - activeFilters?.length > 0; - - if (!isFilterSelected) { - return null; - } - - return ( - - - - ); -}; diff --git a/packages/features/insights/filters/DateSelect.tsx b/packages/features/insights/filters/DateSelect.tsx index 1f3169c296..2432754b8d 100644 --- a/packages/features/insights/filters/DateSelect.tsx +++ b/packages/features/insights/filters/DateSelect.tsx @@ -2,7 +2,6 @@ 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"; @@ -10,7 +9,7 @@ import { Select } from "@calcom/ui"; import { useFilterContext } from "../context/provider"; import "./DateSelect.css"; -export const DateSelect = ({ className }: { className?: string }) => { +export const DateSelect = () => { const { t } = useLocale(); const presetOptions = [ { label: t("today"), value: "tdy" }, @@ -69,7 +68,7 @@ export const DateSelect = ({ className }: { className?: string }) => { }; return ( -
+
{ - const { t } = useLocale(); - const { filter, setConfigFilters } = useFilterContext(); - const { selectedTeamId, selectedRoutingFormId, isAll } = filter; - const { selectedFilter } = filter; +export const RoutingFormFilterList = memo(() => { + 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 (showOnlyWhenSelectedInContext && !selectedFilter?.includes("routing_forms")) return null; + if (!selectedFilter?.includes("routing_forms")) return null; - const filterOptions = buildFilterOptions(allForms || []); + const filterOptions = buildFilterOptions(allForms || []); - return ( - setConfigFilters({ selectedRoutingFormId: value as string })} - buttonIcon={} - /> - ); - } -); + 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 241a33c33b..064d38ddec 100644 --- a/packages/features/insights/filters/TeamAndSelfList.tsx +++ b/packages/features/insights/filters/TeamAndSelfList.tsx @@ -12,13 +12,7 @@ import { AnimatedPopover, Avatar, Divider, Icon } from "@calcom/ui"; import { useFilterContext } from "../context/provider"; -export const TeamAndSelfList = ({ - omitOrg = false, - className = "", -}: { - omitOrg?: boolean; - className?: string; -}) => { +export const TeamAndSelfList = ({ omitOrg = false }: { omitOrg?: boolean }) => { const { t } = useLocale(); const session = useSession(); const currentOrgId = session.data?.user.org?.id; @@ -88,7 +82,7 @@ export const TeamAndSelfList = ({ const isOrgDataAvailable = !!data && data.length > 0 && !!data[0].isOrg; return ( - + {isOrgDataAvailable && ( ({ icon: , }); -export const UserListInTeam = ({ - showOnlyWhenSelectedInContext = true, - onClear, -}: { - showOnlyWhenSelectedInContext?: boolean; - onClear?: () => void; -}) => { +export const UserListInTeam = () => { const { t } = useLocale(); const { filter, setConfigFilters } = useFilterContext(); const { selectedFilter, selectedTeamId, selectedMemberUserId, isAll } = filter; @@ -29,11 +23,7 @@ export const UserListInTeam = ({ isAll: !!isAll, }); - if (showOnlyWhenSelectedInContext && !selectedFilter?.includes("user")) { - return null; - } - - if (!selectedTeamId || !isSuccess || data?.length === 0) { + if (!selectedFilter?.includes("user") || !selectedTeamId || !isSuccess || data?.length === 0) { return null; } @@ -44,12 +34,7 @@ export const UserListInTeam = ({ title={t("people")} options={userListOptions} selectedValue={selectedMemberUserId} - onChange={(value) => { - setConfigFilters({ selectedMemberUserId: value === null ? null : Number(value) }); - if (value === null) { - onClear?.(); - } - }} + onChange={(value) => setConfigFilters({ selectedMemberUserId: Number(value) })} buttonIcon={} placeholder={t("search")} testId="people-filter" diff --git a/packages/features/insights/filters/index.tsx b/packages/features/insights/filters/index.tsx index 9d277e5b03..62b0d8ed40 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 "./UserListInTeam"; +import { UserListInTeam } from "./UsersListInTeam"; 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 new file mode 100644 index 0000000000..8857501892 --- /dev/null +++ b/packages/features/insights/filters/routing/FilterBar.tsx @@ -0,0 +1,77 @@ +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 b2c0bbb0b5..7afd71ce36 100644 --- a/packages/features/insights/server/routing-events.ts +++ b/packages/features/insights/server/routing-events.ts @@ -5,8 +5,6 @@ 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"; @@ -27,7 +25,6 @@ type RoutingFormInsightsFilter = RoutingFormInsightsTeamFilter & { fieldId: string; optionId: string; } | null; - columnFilters: ColumnFilter[]; }; class RoutingEventsInsights { @@ -86,7 +83,7 @@ class RoutingEventsInsights { searchQuery, bookingStatus, fieldFilter, - }: Omit) { + }: RoutingFormInsightsFilter) { // Get team IDs based on organization if applicable const formsWhereCondition = await this.getWhereForTeamOrAllTeams({ teamId, @@ -200,8 +197,9 @@ class RoutingEventsInsights { cursor, limit, userId, - columnFilters, - }: Omit & { cursor?: number; limit?: number }) { + fieldFilter, + bookingStatus, + }: RoutingFormInsightsFilter & { cursor?: number; limit?: number }) { const formsTeamWhereCondition = await this.getWhereForTeamOrAllTeams({ teamId, isAll, @@ -209,43 +207,6 @@ 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 && { @@ -254,22 +215,27 @@ class RoutingEventsInsights { lte: dayjs(endDate).endOf("day").toDate(), }, }), - ...(Object.keys(bookingWhereInput).length > 0 ? { routedToBooking: bookingWhereInput } : {}), + ...(userId || bookingStatus + ? { + ...(bookingStatus === "NO_BOOKING" + ? { routedToBooking: null } + : { + routedToBooking: { + ...(userId && { userId }), + ...(bookingStatus && { status: bookingStatus }), + }, + }), + } + : {}), + ...(fieldFilter && { + response: { + path: [fieldFilter.fieldId, "value"], + array_contains: [fieldFilter.optionId], + }, + }), 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, }); @@ -512,7 +478,6 @@ class RoutingEventsInsights { return { id: f.id, label: f.label, - type: f.type, options: f.options, }; }); @@ -532,7 +497,7 @@ class RoutingEventsInsights { fieldFilter, take, skip, - }: Omit & { take?: number; skip?: number }) { + }: RoutingFormInsightsFilter & { 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 f36dd56425..640ba9525b 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -3,7 +3,6 @@ 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"; @@ -1597,7 +1596,13 @@ export const insightsRouter = router({ routingFormId: z.string().optional(), cursor: z.number().optional(), limit: z.number().optional(), - columnFilters: z.array(ZColumnFilter), + bookingStatus: bookingStatusSchema, + fieldFilter: z + .object({ + fieldId: z.string(), + optionId: z.string(), + }) + .optional(), }) ) .query(async ({ ctx, input }) => { @@ -1612,7 +1617,8 @@ export const insightsRouter = router({ cursor: input.cursor, userId: input.userId ?? null, limit: input.limit, - columnFilters: input.columnFilters, + bookingStatus: input.bookingStatus ?? null, + fieldFilter: input.fieldFilter ?? null, }); }), getRoutingFormFieldOptions: userBelongsToTeamProcedure @@ -1645,14 +1651,12 @@ export const insightsRouter = router({ }) ) .query(async ({ ctx, input }) => { - const headers = await RoutingEventsInsights.getRoutingFormHeaders({ + return 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 1a472a3925..963ea49a05 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -7,7 +7,6 @@ import { useQueryState, parseAsBoolean } from "nuqs"; import { useMemo, useReducer, useRef, useState } from "react"; import { - DataTableProvider, DataTable, DataTableToolbar, DataTableFilters, @@ -17,8 +16,6 @@ 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"; @@ -100,14 +97,6 @@ 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; @@ -175,75 +164,62 @@ function UserListTableContent() { return []; } return ( - (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"; + (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} + - 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 ? ( - {attributeValue.value} + style={{ + backgroundColor: "color-mix(in hsl, var(--cal-bg-emphasis), black 5%)", + }} + className="rounded-l-none"> + {suffix} + ) : null} +
+ ); + })} +
+ ); + }, + filterFn: (row, id, filterValue) => { + const attributeValues = row.original.attributes.filter((attr) => attr.attributeId === id); - {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)); + } - 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[]) ?? [] + if (attributeValues.length === 0) return false; + return attributeValues.some((attr) => filterValue.includes(attr.value)); + }, + })) as ColumnDef[]) ?? [] ); }; const cols: ColumnDef[] = [ @@ -536,7 +512,7 @@ function UserListTableContent() { table={table} tableContainerRef={tableContainerRef} isPending={isPending} - enableColumnResizing={true} + enableColumnResizing={{ name: "UserListTable" }} onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
@@ -558,7 +534,7 @@ function UserListTableContent() { {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 @@ -286,8 +268,6 @@ 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 42c9f86bfe..b6c0049d66 100644 --- a/packages/prisma/seed-utils.ts +++ b/packages/prisma/seed-utils.ts @@ -1,4 +1,3 @@ -import { faker } from "@faker-js/faker"; import type { Prisma, UserPermissionRole } from "@prisma/client"; import { randomUUID } from "crypto"; import { uuid } from "short-uuid"; @@ -441,18 +440,9 @@ export async function seedRoutingForms( value: "team/insights-team/team-sales", }, ], - formFieldSkills: { + formFieldFilled: { 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({ @@ -548,7 +538,7 @@ export async function seedRoutingForms( ], fields: [ { - id: seededForm.formFieldSkills.id, + id: seededForm.formFieldFilled.id, type: "multiselect", label: "skills", options: attributeRaw[2].options.map((opt) => ({ @@ -557,24 +547,6 @@ 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: { @@ -599,16 +571,7 @@ type SeededForm = { id: string; value: string; }[]; - formFieldSkills: { - id: string; - }; - formFieldEmail: { - id: string; - }; - formFieldManager: { - id: string; - }; - formFieldRating: { + formFieldFilled: { id: string; }; }; @@ -660,22 +623,10 @@ export async function seedRoutingFormResponses( formFillerId: randomUUID(), createdAt: randomDate.toDate(), response: { - [seededForm.formFieldSkills.id]: { + [seededForm.formFieldFilled.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, - }, }, }, }); @@ -699,22 +650,10 @@ export async function seedRoutingFormResponses( formFillerId: randomUUID(), createdAt: randomDate.subtract(2, "hour").toDate(), response: { - [seededForm.formFieldSkills.id]: { + [seededForm.formFieldFilled.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 13898580eb..1f94304062 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({ - columnName: "value", - filterValue: attributeOptionValues.length > 0 ? attributeOptionValues : filter.value, - }), + ...makeWhereClause( + "value", + attributeOptionValues.length > 0 ? attributeOptionValues : filter.value + ), }, }, }; diff --git a/packages/types/tanstack-table.d.ts b/packages/types/tanstack-table.d.ts index ffe75143b1..59fbebc289 100644 --- a/packages/types/tanstack-table.d.ts +++ b/packages/types/tanstack-table.d.ts @@ -1,14 +1,12 @@ 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; }; - filter?: ColumnFilterMeta; + filterType?: "select" | "text"; // `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 9936aba073..5d15657c5f 100644 --- a/packages/ui/components/icon/icon-list.mjs +++ b/packages/ui/components/icon/icon-list.mjs @@ -13,7 +13,6 @@ 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 91460ca59e..a08e8f9041 100644 --- a/packages/ui/components/icon/icon-names.ts +++ b/packages/ui/components/icon/icon-names.ts @@ -13,7 +13,6 @@ export type IconName = | "badge-check" | "ban" | "bell" - | "binary" | "blocks" | "bold" | "book-open-check"