From 1404581f78892927533dcc4d264c89535ff2d0ff Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Wed, 20 Nov 2024 10:38:10 +0100 Subject: [PATCH] feat: add text filter on org member list (#17632) * feat: improve text filters (WIP) * move function to bottom * apply some styles * fix selection of TextFilterOptions * rename value to operand * remove unused file * merge filters/filters into filters/utils * fix regression of not putting url params correctly * move makeWhereClause to filters/utils * fix negative, empty, and not empty operators * fix initial filtering from search state (url) * fix type errors * do not send an empty array to query * update yarn.lock * i18n for text filter operators * extract logic as useColumnFilters() * add missing import * fix type error * revert yarn.lock * use i18n * insensitive text match * move data-table to @calcom/features * fix type errors * fix type errors * fix type errors --------- Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> --- apps/web/public/static/locales/en/common.json | 8 + .../data-table/components}/DataTable.tsx | 8 +- .../components}/DataTablePagination.tsx | 0 .../components}/DataTableSelectionBar.tsx | 3 +- .../components}/DataTableToolbar.tsx | 6 +- .../components/filters/FilterOptions.tsx | 59 ++++++ .../filters/MultiSelectFilterOptions.tsx | 76 ++++++++ .../components/filters/TextFilterOptions.tsx | 111 ++++++++++++ .../data-table/components}/filters/index.tsx | 74 ++------ .../data-table/components}/index.ts | 0 .../useFetchMoreOnBottomReached.ts | 0 packages/features/data-table/index.ts | 3 + packages/features/data-table/lib/server.ts | 74 ++++++++ packages/features/data-table/lib/types.ts | 39 ++++ packages/features/data-table/lib/utils.ts | 82 +++++++++ .../components/GroupTeamMappingTable.tsx | 2 +- .../ee/teams/components/MemberList.tsx | 12 +- .../components/RoutingFormResponsesTable.tsx | 3 +- .../insights/filters/routing/FilterBar.tsx | 3 +- .../components/AvailabilitySliderTable.tsx | 3 +- .../components/UserTable/UserListTable.tsx | 51 +++--- .../organizations/listMembers.handler.ts | 7 +- .../organizations/listMembers.schema.ts | 16 +- packages/types/tanstack-table.d.ts | 1 + .../components/data-table/DataTableFilter.tsx | 169 ------------------ .../ui/components/data-table/filters/utils.ts | 17 -- packages/ui/index.ts | 1 - packages/ui/package.json | 1 - 28 files changed, 520 insertions(+), 309 deletions(-) rename packages/{ui/components/data-table => features/data-table/components}/DataTable.tsx (97%) rename packages/{ui/components/data-table => features/data-table/components}/DataTablePagination.tsx (100%) rename packages/{ui/components/data-table => features/data-table/components}/DataTableSelectionBar.tsx (95%) rename packages/{ui/components/data-table => features/data-table/components}/DataTableToolbar.tsx (96%) create mode 100644 packages/features/data-table/components/filters/FilterOptions.tsx create mode 100644 packages/features/data-table/components/filters/MultiSelectFilterOptions.tsx create mode 100644 packages/features/data-table/components/filters/TextFilterOptions.tsx rename packages/{ui/components/data-table => features/data-table/components}/filters/index.tsx (71%) rename packages/{ui/components/data-table => features/data-table/components}/index.ts (100%) rename packages/{ui/components/data-table => features/data-table/components}/useFetchMoreOnBottomReached.ts (100%) create mode 100644 packages/features/data-table/index.ts create mode 100644 packages/features/data-table/lib/server.ts create mode 100644 packages/features/data-table/lib/types.ts create mode 100644 packages/features/data-table/lib/utils.ts delete mode 100644 packages/ui/components/data-table/DataTableFilter.tsx delete mode 100644 packages/ui/components/data-table/filters/utils.ts diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index d6458a4c6d..f043c66a4b 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2797,6 +2797,14 @@ "salesforce_route_to_custom_lookup_field": "Route to a user that matches a lookup field on an account", "salesforce_option": "Salesforce Option", "lookup_field_name": "Lookup Field Name", + "filter_operator_is": "Is", + "filter_operator_is_not": "Is not", + "filter_operator_contains": "Contains", + "filter_operator_does_not_contain": "Does not contain", + "filter_operator_starts_with": "Starts with", + "filter_operator_ends_with": "Ends with", + "filter_operator_is_empty": "Is empty", + "filter_operator_not_empty": "Not empty", "translate_description_button": "Translate description to the visitor's browser language using AI", "rr_distribution_method": "Distribution", "rr_distribution_method_description": "Allows for optimising distribution for maximum availability or to aim for a more balanced assignment.", diff --git a/packages/ui/components/data-table/DataTable.tsx b/packages/features/data-table/components/DataTable.tsx similarity index 97% rename from packages/ui/components/data-table/DataTable.tsx rename to packages/features/data-table/components/DataTable.tsx index a013372074..fd9dda8a68 100644 --- a/packages/ui/components/data-table/DataTable.tsx +++ b/packages/features/data-table/components/DataTable.tsx @@ -7,9 +7,7 @@ import { useMemo } from "react"; import { useVirtual } from "react-virtual"; import classNames from "@calcom/lib/classNames"; - -import { Icon } from "../icon"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../table/TableNew"; +import { Icon, TableNew, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@calcom/ui"; export interface DataTableProps { table: ReactTableType; @@ -74,7 +72,7 @@ export function DataTable({ onScroll={onScroll} className="scrollbar-thin border-subtle relative h-full overflow-auto rounded-md border" style={{ gridArea: "body" }}> - + {table.getHeaderGroups().map((headerGroup) => ( @@ -168,7 +166,7 @@ export function DataTable({ )} -
+ {children} diff --git a/packages/ui/components/data-table/DataTablePagination.tsx b/packages/features/data-table/components/DataTablePagination.tsx similarity index 100% rename from packages/ui/components/data-table/DataTablePagination.tsx rename to packages/features/data-table/components/DataTablePagination.tsx diff --git a/packages/ui/components/data-table/DataTableSelectionBar.tsx b/packages/features/data-table/components/DataTableSelectionBar.tsx similarity index 95% rename from packages/ui/components/data-table/DataTableSelectionBar.tsx rename to packages/features/data-table/components/DataTableSelectionBar.tsx index df879ca6e4..eab89a52aa 100644 --- a/packages/ui/components/data-table/DataTableSelectionBar.tsx +++ b/packages/features/data-table/components/DataTableSelectionBar.tsx @@ -4,8 +4,7 @@ import type { Table } from "@tanstack/react-table"; import { forwardRef } from "react"; import { classNames } from "@calcom/lib"; - -import type { IconName } from "../icon"; +import type { IconName } from "@calcom/ui"; export type ActionItem = | { diff --git a/packages/ui/components/data-table/DataTableToolbar.tsx b/packages/features/data-table/components/DataTableToolbar.tsx similarity index 96% rename from packages/ui/components/data-table/DataTableToolbar.tsx rename to packages/features/data-table/components/DataTableToolbar.tsx index e83ab97f9f..1147dcd3c8 100644 --- a/packages/ui/components/data-table/DataTableToolbar.tsx +++ b/packages/features/data-table/components/DataTableToolbar.tsx @@ -7,10 +7,8 @@ import type { ComponentPropsWithoutRef } from "react"; import { classNames } from "@calcom/lib"; import { useDebounce } from "@calcom/lib/hooks/useDebounce"; import { useLocale } from "@calcom/lib/hooks/useLocale"; - -import type { ButtonProps } from "../button"; -import { Button } from "../button"; -import { Input } from "../form"; +import type { ButtonProps } from "@calcom/ui"; +import { Button, Input } from "@calcom/ui"; interface DataTableToolbarProps extends ComponentPropsWithoutRef<"div"> { children: React.ReactNode; diff --git a/packages/features/data-table/components/filters/FilterOptions.tsx b/packages/features/data-table/components/filters/FilterOptions.tsx new file mode 100644 index 0000000000..e1b652960b --- /dev/null +++ b/packages/features/data-table/components/filters/FilterOptions.tsx @@ -0,0 +1,59 @@ +import { type Table } from "@tanstack/react-table"; + +import type { FilterableColumn, FilterValue, SelectFilterValue, TextFilterValue } from "../../lib/types"; +import type { ActiveFilter, FiltersSearchState, SetFiltersSearchState } from "../../lib/utils"; +import { MultiSelectFilterOptions } from "./MultiSelectFilterOptions"; +import { TextFilterOptions } from "./TextFilterOptions"; + +export type FilterOptionsProps = { + column: FilterableColumn; + filter: ActiveFilter; + state: FiltersSearchState; + setState: SetFiltersSearchState; + table: Table; +}; + +export function FilterOptions({ column, filter, state, setState, table }: FilterOptionsProps) { + const filterValue = table.getColumn(column.id)?.getFilterValue() as FilterValue | undefined; + + const setMultiSelectFilterValue = (value: SelectFilterValue) => { + setState({ + activeFilters: state.activeFilters.map((item) => (item.f === filter.f ? { ...item, v: value } : item)), + }); + table.getColumn(column.id)?.setFilterValue(value); + }; + + const setTextFilterValue = (value: TextFilterValue) => { + setState({ + activeFilters: state.activeFilters.map((item) => (item.f === filter.f ? { ...item, v: value } : item)), + }); + table.getColumn(column.id)?.setFilterValue(value); + }; + + const removeFilter = (columnId: string) => { + setState({ activeFilters: (state.activeFilters || []).filter((filter) => filter.f !== columnId) }); + table.getColumn(columnId)?.setFilterValue(undefined); + }; + + if (column.filterType === "text") { + return ( + + ); + } else if (column.filterType === "select") { + return ( + + ); + } else { + return null; + } +} diff --git a/packages/features/data-table/components/filters/MultiSelectFilterOptions.tsx b/packages/features/data-table/components/filters/MultiSelectFilterOptions.tsx new file mode 100644 index 0000000000..0ca1e2f661 --- /dev/null +++ b/packages/features/data-table/components/filters/MultiSelectFilterOptions.tsx @@ -0,0 +1,76 @@ +import { classNames } from "@calcom/lib"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { + Command, + CommandInput, + CommandList, + CommandEmpty, + CommandItem, + CommandSeparator, + CommandGroup, + buttonClasses, + Icon, +} from "@calcom/ui"; + +import type { FilterableColumn, SelectFilterValue } from "../../lib/types"; + +export type MultiSelectFilterOptionsProps = { + column: FilterableColumn; + filterValue?: SelectFilterValue; + setFilterValue: (value: SelectFilterValue) => void; + removeFilter: (columnId: string) => void; +}; + +export function MultiSelectFilterOptions({ + column, + filterValue, + setFilterValue, + removeFilter, +}: MultiSelectFilterOptionsProps) { + const { t } = useLocale(); + + return ( + + + + {t("no_options_found")} + {Array.from(column.options.keys()).map((option) => { + if (!option) return null; + return ( + { + const newFilterValue = filterValue?.includes(option) + ? filterValue?.filter((value) => value !== option) + : [...(filterValue || []), option]; + setFilterValue(newFilterValue); + }}> +
+ {Array.isArray(filterValue) && (filterValue as string[])?.includes(option) && ( + + )} +
+ {option} +
+ ); + })} +
+ + + { + removeFilter(column.id); + }} + className={classNames("w-full justify-center text-center", buttonClasses({ color: "secondary" }))}> + {t("clear")} + + +
+ ); +} diff --git a/packages/features/data-table/components/filters/TextFilterOptions.tsx b/packages/features/data-table/components/filters/TextFilterOptions.tsx new file mode 100644 index 0000000000..6031b83207 --- /dev/null +++ b/packages/features/data-table/components/filters/TextFilterOptions.tsx @@ -0,0 +1,111 @@ +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"; + +export type TextFilterOperatorOption = { + label: string; + value: TextFilterOperator; + requiresOperand: boolean; +}; + +const useTextFilterOperatorOptions = (): TextFilterOperatorOption[] => { + const { t } = useLocale(); + return [ + { value: "equals", label: t("filter_operator_is"), requiresOperand: true }, + { value: "notEquals", label: t("filter_operator_is_not"), requiresOperand: true }, + { value: "contains", label: t("filter_operator_contains"), requiresOperand: true }, + { value: "notContains", label: t("filter_operator_does_not_contain"), requiresOperand: true }, + { value: "startsWith", label: t("filter_operator_starts_with"), requiresOperand: true }, + { value: "endsWith", label: t("filter_operator_ends_with"), requiresOperand: true }, + { value: "isEmpty", label: t("filter_operator_is_empty"), requiresOperand: false }, + { value: "isNotEmpty", label: t("filter_operator_not_empty"), requiresOperand: false }, + ]; +}; + +export type TextFilterOptionsProps = { + column: FilterableColumn; + filterValue?: TextFilterValue; + setFilterValue: (value: TextFilterValue) => void; + removeFilter: (columnId: string) => void; +}; + +export function TextFilterOptions({ + column, + filterValue, + setFilterValue, + removeFilter, +}: TextFilterOptionsProps) { + const { t } = useLocale(); + const textFilterOperatorOptions = useTextFilterOperatorOptions(); + + const form = useForm({ + defaultValues: { + operatorOption: filterValue + ? textFilterOperatorOptions.find((o) => o.value === filterValue.data.operator) + : textFilterOperatorOptions[0], + operand: filterValue?.data.operand || "", + }, + }); + + return ( +
+
{ + if (operatorOption) { + setFilterValue({ + type: "text", + data: { + operator: operatorOption.value, + operand: operatorOption.requiresOperand ? operand : "", + }, + }); + } + }}> +
+ ( + <> + } + + )} + /> + +
+ +
+ + +
+
+ +
+ ); +} diff --git a/packages/ui/components/data-table/filters/index.tsx b/packages/features/data-table/components/filters/index.tsx similarity index 71% rename from packages/ui/components/data-table/filters/index.tsx rename to packages/features/data-table/components/filters/index.tsx index 5a386b8276..bc876cdb95 100644 --- a/packages/ui/components/data-table/filters/index.tsx +++ b/packages/features/data-table/components/filters/index.tsx @@ -22,7 +22,9 @@ import { Icon, } from "@calcom/ui"; -import { useFiltersSearchState } from "./utils"; +import type { FilterableColumn } from "../../lib/types"; +import { useFiltersSearchState } from "../../lib/utils"; +import { FilterOptions } from "./FilterOptions"; interface ColumnVisiblityProps { table: Table; @@ -188,26 +190,21 @@ interface ActiveFiltersProps { } function ActiveFilters({ table }: ActiveFiltersProps) { - const { t } = useLocale(); const [_state, _setState] = useFiltersSearchState(); const columns = table.getAllColumns().filter((column) => column.getCanFilter()); - const filterableColumns = useMemo(() => { + 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]); - const handleRemoveFilter = (columnId: string) => { - _setState({ activeFilters: (_state.activeFilters || []).filter((filter) => filter.f !== columnId) }); - table.getColumn(columnId)?.setFilterValue(undefined); - }; - return ( <> {(_state.activeFilters || []).map((filter) => { @@ -222,60 +219,13 @@ function ActiveFilters({ table }: ActiveFiltersProps) { - - - - {t("no_options_found")} - {Array.from(column.options).map(([option]) => { - if (!option) return null; - return ( - { - const newFilterValue = filter.v?.includes(option) - ? filter.v?.filter((value) => value !== option) - : [...(filter.v || []), option]; - _setState({ - activeFilters: _state.activeFilters.map((f) => - f.f === filter.f ? { ...f, v: newFilterValue } : f - ), - }); - table - .getColumn(filter.f) - ?.setFilterValue(newFilterValue.length ? newFilterValue : undefined); - }}> -
- {Array.isArray(table.getColumn(column.id)?.getFilterValue()) && - (table.getColumn(column.id)?.getFilterValue() as string[])?.includes(option) && ( - - )} -
- {option} -
- ); - })} -
- - - { - handleRemoveFilter(column.id); - }} - className={classNames( - "w-full justify-center text-center", - buttonClasses({ color: "secondary" }) - )}> - {t("clear")} - - -
+
); diff --git a/packages/ui/components/data-table/index.ts b/packages/features/data-table/components/index.ts similarity index 100% rename from packages/ui/components/data-table/index.ts rename to packages/features/data-table/components/index.ts diff --git a/packages/ui/components/data-table/useFetchMoreOnBottomReached.ts b/packages/features/data-table/components/useFetchMoreOnBottomReached.ts similarity index 100% rename from packages/ui/components/data-table/useFetchMoreOnBottomReached.ts rename to packages/features/data-table/components/useFetchMoreOnBottomReached.ts diff --git a/packages/features/data-table/index.ts b/packages/features/data-table/index.ts new file mode 100644 index 0000000000..bc8c614cf4 --- /dev/null +++ b/packages/features/data-table/index.ts @@ -0,0 +1,3 @@ +export * from "./components"; +export * from "./lib/types"; +export * from "./lib/utils"; diff --git a/packages/features/data-table/lib/server.ts b/packages/features/data-table/lib/server.ts new file mode 100644 index 0000000000..acf22e0615 --- /dev/null +++ b/packages/features/data-table/lib/server.ts @@ -0,0 +1,74 @@ +import type { FilterValue } from "./types"; +import { isSelectFilterValue, isTextFilterValue } from "./utils"; + +export function makeWhereClause(columnName: string, filterValue: FilterValue) { + if (isSelectFilterValue(filterValue)) { + return { + [columnName]: { + in: filterValue, + }, + }; + } else if (isTextFilterValue(filterValue)) { + const { operator, operand } = filterValue.data; + + switch (operator) { + case "equals": + return { + [columnName]: { + equals: operand, + }, + }; + case "notEquals": + return { + [columnName]: { + not: operand, + }, + }; + case "contains": + return { + [columnName]: { + contains: operand, + mode: "insensitive", + }, + }; + case "notContains": + return { + NOT: { + [columnName]: { + contains: operand, + mode: "insensitive", + }, + }, + }; + case "startsWith": + return { + [columnName]: { + startsWith: operand, + mode: "insensitive", + }, + }; + case "endsWith": + return { + [columnName]: { + endsWith: operand, + mode: "insensitive", + }, + }; + case "isEmpty": + return { + [columnName]: { + equals: "", + }, + }; + case "isNotEmpty": + return { + NOT: { + [columnName]: { + equals: "", + }, + }, + }; + } + } + return {}; +} diff --git a/packages/features/data-table/lib/types.ts b/packages/features/data-table/lib/types.ts new file mode 100644 index 0000000000..9beb0d4026 --- /dev/null +++ b/packages/features/data-table/lib/types.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +export const ZTextFilterOperator = z.enum([ + "equals", + "notEquals", + "contains", + "notContains", + "startsWith", + "endsWith", + "isEmpty", + "isNotEmpty", +]); + +export type TextFilterOperator = z.infer; + +export const ZSelectFilterValue = z.array(z.string()); + +export type SelectFilterValue = z.infer; + +export const ZTextFilterValue = z.object({ + type: z.literal("text"), + data: z.object({ + operator: ZTextFilterOperator, + operand: z.string(), + }), +}); + +export type TextFilterValue = z.infer; + +export const ZFilterValue = z.union([ZSelectFilterValue, ZTextFilterValue]); + +export type FilterValue = z.infer; + +export type FilterableColumn = { + id: string; + title: string; + filterType: "text" | "select"; + options: Map; +}; diff --git a/packages/features/data-table/lib/utils.ts b/packages/features/data-table/lib/utils.ts new file mode 100644 index 0000000000..f5a4e19105 --- /dev/null +++ b/packages/features/data-table/lib/utils.ts @@ -0,0 +1,82 @@ +"use client"; + +import { parseAsArrayOf, parseAsJson, useQueryStates } from "nuqs"; +import { useMemo } from "react"; +import { z } from "zod"; + +import type { SelectFilterValue, TextFilterValue } from "./types"; +import { ZSelectFilterValue, ZTextFilterValue } from "./types"; + +const filterSchema = z.object({ + f: z.string(), + v: z.union([ZSelectFilterValue, ZTextFilterValue]), +}); + +export const filtersSearchParams = { + activeFilters: parseAsArrayOf(parseAsJson(filterSchema.parse)).withDefault([]), +}; + +export function useFiltersSearchState() { + return useQueryStates(filtersSearchParams); +} + +export function 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, { value: [] } would result in nothing being returned. + if (Array.isArray(filter.value) && filter.value.length === 0) { + return false; + } + return true; + }); + }, [filtersSearchState.activeFilters]); + return columnFilters; +} + +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(); + case "notEquals": + return cellValue.toLowerCase() !== (filterValue.data.operand || "").toLowerCase(); + case "contains": + return cellValue.toLowerCase().includes((filterValue.data.operand || "").toLowerCase()); + case "notContains": + return !cellValue.toLowerCase().includes((filterValue.data.operand || "").toLowerCase()); + case "startsWith": + return cellValue.toLowerCase().startsWith((filterValue.data.operand || "").toLowerCase()); + case "endsWith": + return cellValue.toLowerCase().endsWith((filterValue.data.operand || "").toLowerCase()); + case "isEmpty": + return cellValue.trim() === ""; + case "isNotEmpty": + return cellValue.trim() !== ""; + default: + return false; + } +}; + +export const isTextFilterValue = (filterValue: unknown): filterValue is TextFilterValue => { + return ( + typeof filterValue === "object" && + filterValue !== null && + "type" in filterValue && + filterValue.type === "text" + ); +}; + +export const isSelectFilterValue = (filterValue: unknown): filterValue is SelectFilterValue => { + return Array.isArray(filterValue) && filterValue.every((item) => typeof item === "string"); +}; diff --git a/packages/features/ee/dsync/components/GroupTeamMappingTable.tsx b/packages/features/ee/dsync/components/GroupTeamMappingTable.tsx index f97c9ed2a8..038c93a1b7 100644 --- a/packages/features/ee/dsync/components/GroupTeamMappingTable.tsx +++ b/packages/features/ee/dsync/components/GroupTeamMappingTable.tsx @@ -2,9 +2,9 @@ import type { ColumnDef } from "@tanstack/react-table"; import { useReactTable, getCoreRowModel } from "@tanstack/react-table"; import { useRef, useState } from "react"; +import { DataTable, DataTableToolbar } from "@calcom/features/data-table"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; -import { DataTable, DataTableToolbar } from "@calcom/ui"; import CreateTeamDialog from "./CreateTeamDialog"; import GroupNameCell from "./GroupNameCell"; diff --git a/packages/features/ee/teams/components/MemberList.tsx b/packages/features/ee/teams/components/MemberList.tsx index 1144b65983..b00dde4845 100644 --- a/packages/features/ee/teams/components/MemberList.tsx +++ b/packages/features/ee/teams/components/MemberList.tsx @@ -17,6 +17,13 @@ import { useQueryState, parseAsBoolean } from "nuqs"; import { useMemo, useReducer, useRef, useState } from "react"; import type { Dispatch, SetStateAction } from "react"; +import { + DataTable, + DataTableToolbar, + DataTableFilters, + DataTableSelectionBar, + useFetchMoreOnBottomReached, +} from "@calcom/features/data-table"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; import { DynamicLink } from "@calcom/features/users/components/UserTable/BulkActions/DynamicLink"; import { WEBAPP_URL } from "@calcom/lib/constants"; @@ -31,10 +38,6 @@ import { Button, ButtonGroup, Checkbox, - DataTable, - DataTableToolbar, - DataTableFilters, - DataTableSelectionBar, ConfirmationDialogContent, Dialog, DialogClose, @@ -49,7 +52,6 @@ import { showToast, Tooltip, } from "@calcom/ui"; -import { useFetchMoreOnBottomReached } from "@calcom/ui/data-table"; import DeleteBulkTeamMembers from "./DeleteBulkTeamMembers"; import { EditMemberSheet } from "./EditMemberSheet"; diff --git a/packages/features/insights/components/RoutingFormResponsesTable.tsx b/packages/features/insights/components/RoutingFormResponsesTable.tsx index 1ae3f12a09..8baccbcbfb 100644 --- a/packages/features/insights/components/RoutingFormResponsesTable.tsx +++ b/packages/features/insights/components/RoutingFormResponsesTable.tsx @@ -12,14 +12,13 @@ import Link from "next/link"; import { useRef, useMemo, useId } from "react"; import dayjs from "@calcom/dayjs"; +import { DataTable, useFetchMoreOnBottomReached } from "@calcom/features/data-table"; import classNames from "@calcom/lib/classNames"; import { useCopy } from "@calcom/lib/hooks/useCopy"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { BookingStatus } from "@calcom/prisma/enums"; import { trpc, type RouterOutputs } from "@calcom/trpc"; import { - DataTable, - useFetchMoreOnBottomReached, Badge, Avatar, Icon, diff --git a/packages/features/insights/filters/routing/FilterBar.tsx b/packages/features/insights/filters/routing/FilterBar.tsx index f0ab9a3c48..8857501892 100644 --- a/packages/features/insights/filters/routing/FilterBar.tsx +++ b/packages/features/insights/filters/routing/FilterBar.tsx @@ -1,7 +1,8 @@ +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, DataTableFilters } from "@calcom/ui"; +import { Button, Icon, Tooltip } from "@calcom/ui"; import { BookingStatusFilter } from "../BookingStatusFilter"; import { DateSelect } from "../DateSelect"; diff --git a/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx b/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx index 979e150438..4cfc68918a 100644 --- a/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx +++ b/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx @@ -6,6 +6,7 @@ import { getCoreRowModel, getFilteredRowModel, useReactTable } from "@tanstack/r import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import dayjs from "@calcom/dayjs"; +import { DataTable, DataTableToolbar } from "@calcom/features/data-table"; import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants"; import type { DateRange } from "@calcom/lib/date-ranges"; import { useDebounce } from "@calcom/lib/hooks/useDebounce"; @@ -13,7 +14,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { MembershipRole } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc"; import type { UserProfile } from "@calcom/types/UserProfile"; -import { Button, ButtonGroup, DataTable, DataTableToolbar, UserAvatar } from "@calcom/ui"; +import { Button, ButtonGroup, UserAvatar } from "@calcom/ui"; import { UpgradeTip } from "../../tips/UpgradeTip"; import { createTimezoneBuddyStore, TBContext } from "../store"; diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index d58cc00007..c84eb5a231 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -1,7 +1,6 @@ "use client"; import { keepPreviousData } from "@tanstack/react-query"; -import type { ColumnFiltersState } from "@tanstack/react-table"; import { getCoreRowModel, getFilteredRowModel, @@ -13,6 +12,17 @@ import { useSession } from "next-auth/react"; import { useQueryState, parseAsBoolean } from "nuqs"; import { useMemo, useReducer, useRef, useState } from "react"; +import { + DataTable, + DataTableToolbar, + DataTableFilters, + DataTableSelectionBar, + DataTablePagination, + useColumnFilters, + useFetchMoreOnBottomReached, + textFilter, + isTextFilterValue, +} from "@calcom/features/data-table"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { @@ -23,19 +33,7 @@ import { import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { - Avatar, - Badge, - Button, - Checkbox, - DataTable, - DataTableToolbar, - DataTableFilters, - DataTableSelectionBar, - DataTablePagination, - showToast, - useFetchMoreOnBottomReached, -} from "@calcom/ui"; +import { Avatar, Badge, Button, Checkbox, showToast } from "@calcom/ui"; import { useGetUserAttributes } from "@calcom/web/components/settings/platform/hooks/useGetUserAttributes"; import { DeleteBulkUsers } from "./BulkActions/DeleteBulkUsers"; @@ -122,7 +120,7 @@ export function UserListTable() { const [isDownloading, setIsDownloading] = useState(false); const [rowSelection, setRowSelection] = useState({}); - const [columnFilters, setColumnFilters] = useState([]); + const columnFilters = useColumnFilters(); const { data, isPending, fetchNextPage, isFetching } = trpc.viewer.organizations.listMembers.useInfiniteQuery( @@ -130,10 +128,7 @@ export function UserListTable() { limit: 10, searchTerm: debouncedSearchTerm, expand: ["attributes"], - filters: columnFilters.map((filter) => ({ - id: filter.id, - value: filter.value as string[], - })), + filters: columnFilters, }, { getNextPageParam: (lastPage) => lastPage.nextCursor, @@ -146,10 +141,7 @@ export function UserListTable() { limit: 100, // Max limit searchTerm: debouncedSearchTerm, expand: ["attributes"], - filters: columnFilters.map((filter) => ({ - id: filter.id, - value: filter.value as string[], - })), + filters: columnFilters, }, { getNextPageParam: (lastPage) => lastPage.nextCursor, @@ -180,6 +172,9 @@ export function UserListTable() { (attributes?.map((attribute) => ({ id: attribute.id, header: attribute.name, + meta: { + filterType: attribute.type.toLowerCase() === "text" ? "text" : "select", + }, accessorFn: (data) => data.attributes.find((attr) => attr.attributeId === attribute.id)?.value, cell: ({ row }) => { const attributeValues = row.original.attributes.filter( @@ -196,8 +191,13 @@ export function UserListTable() { ); }, - filterFn: (rows, id, filterValue) => { - const attributeValues = rows.original.attributes.filter((attr) => attr.attributeId === id); + 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)); }, @@ -396,7 +396,6 @@ export function UserListTable() { columnFilters, rowSelection, }, - onColumnFiltersChange: setColumnFilters, getCoreRowModel: getCoreRowModel(), // TODO(SEAN): We need to move filter state to the server so we can fetch more data when the filters change if theyre not in client cache getFilteredRowModel: getFilteredRowModel(), diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts index 4a5c7ed797..7f6e18253e 100644 --- a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts @@ -1,3 +1,4 @@ +import { makeWhereClause } from "@calcom/features/data-table/lib/server"; import { UserRepository } from "@calcom/lib/server/repository/user"; import { prisma } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; @@ -66,7 +67,7 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { some: { team: { name: { - in: filter.value, + in: filter.value as string[], }, }, }, @@ -81,9 +82,7 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { attribute: { id: filter.id, }, - value: { - in: filter.value, - }, + ...makeWhereClause("value", filter.value), }, }, }; diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.schema.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.schema.ts index 9c5a5d5827..7932dab84c 100644 --- a/packages/trpc/server/routers/viewer/organizations/listMembers.schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/listMembers.schema.ts @@ -1,20 +1,20 @@ import { z } from "zod"; +import { ZFilterValue } from "@calcom/features/data-table"; + const expandableColumns = z.enum(["attributes"]); +const ZListMembersFilter = z.object({ + id: z.string(), + value: ZFilterValue, +}); + export const ZListMembersSchema = z.object({ limit: z.number().min(1).max(100), cursor: z.number().nullish(), searchTerm: z.string().optional(), expand: z.array(expandableColumns).optional(), - filters: z - .array( - z.object({ - id: z.string(), - value: z.array(z.string()), - }) - ) - .optional(), + filters: z.array(ZListMembersFilter).optional(), }); export type TListMembersSchema = z.infer; diff --git a/packages/types/tanstack-table.d.ts b/packages/types/tanstack-table.d.ts index 7462657122..f912407451 100644 --- a/packages/types/tanstack-table.d.ts +++ b/packages/types/tanstack-table.d.ts @@ -6,5 +6,6 @@ declare module "@tanstack/table-core" { position: "left" | "right"; gap?: number; }; + filterType?: "select" | "text"; } } diff --git a/packages/ui/components/data-table/DataTableFilter.tsx b/packages/ui/components/data-table/DataTableFilter.tsx deleted file mode 100644 index 43da78d2ec..0000000000 --- a/packages/ui/components/data-table/DataTableFilter.tsx +++ /dev/null @@ -1,169 +0,0 @@ -"use client"; - -import type { Column } from "@tanstack/react-table"; -import { useState } from "react"; - -import { classNames } from "@calcom/lib"; - -import { Badge } from "../badge"; -import { Button } from "../button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, -} from "../command"; -import type { IconName } from "../icon"; -import { Icon } from "../icon"; -import { Popover, PopoverContent, PopoverTrigger } from "../popover"; - -interface FilterOption { - label: string; - value: string; - icon?: IconName; - options?: FilterOption[]; -} - -interface DataTableFilter { - column?: Column; - title?: string; - options: FilterOption[]; -} - -export function DataTableFilter({ column, title, options }: DataTableFilter) { - const facets = column?.getFacetedUniqueValues(); - const selectedValues = new Set(column?.getFilterValue() as string[]); - const [currentOptions, setCurrentOptions] = useState(options); - const [navigationPath, setNavigationPath] = useState([]); - - const flattenOptions = (opts: FilterOption[]): FilterOption[] => { - return opts.reduce((acc, option) => { - acc.push(option); - if (option.options) { - acc.push(...flattenOptions(option.options)); - } - return acc; - }, [] as FilterOption[]); - }; - - const allOptions = flattenOptions(options); - - const handleSelect = (option: FilterOption) => { - if (option.options) { - setCurrentOptions(option.options); - setNavigationPath([...navigationPath, option.label]); - } else { - if (selectedValues.has(option.value)) { - selectedValues.delete(option.value); - } else { - selectedValues.add(option.value); - } - const filterValues = Array.from(selectedValues); - column?.setFilterValue(filterValues.length ? filterValues : undefined); - } - }; - - const handleBack = () => { - if (navigationPath.length > 0) { - const newPath = [...navigationPath]; - newPath.pop(); - setNavigationPath(newPath); - - let newOptions = options; - for (const label of newPath) { - const found = newOptions.find((o) => o.label === label); - if (found && found.options) { - newOptions = found.options; - } - } - setCurrentOptions(newOptions); - } - }; - - return ( - - - - - - - - - No results found. - {navigationPath.length > 0 && ( - - - - Back - - - )} - - {currentOptions.map((option) => { - const isSelected = selectedValues.has(option.value); - return ( - handleSelect(option)}> - {!option.options && ( -
- -
- )} - {option.icon && } - {option.label} - {option.options && } - {!option.options && facets?.get(option.value) && ( - - {facets.get(option.value)} - - )} -
- ); - })} -
- {selectedValues.size > 0 && ( - <> - - - column?.setFilterValue(undefined)} - className="justify-center text-center"> - Clear filters - - - - )} -
-
-
-
- ); -} diff --git a/packages/ui/components/data-table/filters/utils.ts b/packages/ui/components/data-table/filters/utils.ts deleted file mode 100644 index 69c483c4e1..0000000000 --- a/packages/ui/components/data-table/filters/utils.ts +++ /dev/null @@ -1,17 +0,0 @@ -"use client"; - -import { parseAsArrayOf, parseAsJson, useQueryStates } from "nuqs"; -import { z } from "zod"; - -const filterSchema = z.object({ - f: z.string(), - v: z.array(z.string()).optional(), -}); - -export const filtersSearchParams = { - activeFilters: parseAsArrayOf(parseAsJson(filterSchema.parse)).withDefault([]), -}; - -export function useFiltersSearchState() { - return useQueryStates(filtersSearchParams); -} diff --git a/packages/ui/index.ts b/packages/ui/index.ts index 39ee5512aa..6c37de614a 100644 --- a/packages/ui/index.ts +++ b/packages/ui/index.ts @@ -152,7 +152,6 @@ export { CreateButton, CreateButtonWithTeamsList } from "./components/createButt export { useCalcomTheme } from "./styles/useCalcomTheme"; export { ScrollableArea } from "./components/scrollable"; export { WizardLayout } from "./layouts/WizardLayout"; -export * from "./components/data-table"; export * from "./components/sheet"; export * from "./components/table"; export * from "./components/popover"; diff --git a/packages/ui/package.json b/packages/ui/package.json index 98ec1b714a..04f6575c72 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -20,7 +20,6 @@ "./command": "./components/command/index.ts", "./createButton": "./components/createButton/index.ts", "./credits": "./components/credits/index.ts", - "./data-table": "./components/data-table/index.ts", "./dialog": "./components/dialog/index.ts", "./disconnect-calendar-integration": "./components/disconnect-calendar-integration/index.ts", "./divider": "./components/divider/index.ts",