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>
This commit is contained in:
Eunjae Lee
2024-11-20 09:38:10 +00:00
committed by GitHub
co-authored by Udit Takkar
parent ff18b9a733
commit 1404581f78
28 changed files with 520 additions and 309 deletions
@@ -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.",
@@ -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<TData, TValue> {
table: ReactTableType<TData>;
@@ -74,7 +72,7 @@ export function DataTable<TData, TValue>({
onScroll={onScroll}
className="scrollbar-thin border-subtle relative h-full overflow-auto rounded-md border"
style={{ gridArea: "body" }}>
<Table className="border-0">
<TableNew className="border-0">
<TableHeader className="bg-subtle sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
@@ -168,7 +166,7 @@ export function DataTable<TData, TValue>({
</tr>
)}
</TableBody>
</Table>
</TableNew>
</div>
{children}
</div>
@@ -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<TData> =
| {
@@ -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;
@@ -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<TData> = {
column: FilterableColumn;
filter: ActiveFilter;
state: FiltersSearchState;
setState: SetFiltersSearchState;
table: Table<TData>;
};
export function FilterOptions<TData>({ column, filter, state, setState, table }: FilterOptionsProps<TData>) {
const filterValue = table.getColumn(column.id)?.getFilterValue() as FilterValue | undefined;
const setMultiSelectFilterValue = (value: SelectFilterValue) => {
setState({
activeFilters: state.activeFilters.map((item) => (item.f === filter.f ? { ...item, v: value } : item)),
});
table.getColumn(column.id)?.setFilterValue(value);
};
const setTextFilterValue = (value: TextFilterValue) => {
setState({
activeFilters: state.activeFilters.map((item) => (item.f === filter.f ? { ...item, v: value } : item)),
});
table.getColumn(column.id)?.setFilterValue(value);
};
const removeFilter = (columnId: string) => {
setState({ activeFilters: (state.activeFilters || []).filter((filter) => filter.f !== columnId) });
table.getColumn(columnId)?.setFilterValue(undefined);
};
if (column.filterType === "text") {
return (
<TextFilterOptions
column={column}
filterValue={filterValue as TextFilterValue | undefined}
setFilterValue={setTextFilterValue}
removeFilter={removeFilter}
/>
);
} else if (column.filterType === "select") {
return (
<MultiSelectFilterOptions
column={column}
filterValue={filterValue as SelectFilterValue | undefined}
setFilterValue={setMultiSelectFilterValue}
removeFilter={removeFilter}
/>
);
} else {
return null;
}
}
@@ -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 (
<Command>
<CommandInput placeholder={t("search_options")} />
<CommandList>
<CommandEmpty>{t("no_options_found")}</CommandEmpty>
{Array.from(column.options.keys()).map((option) => {
if (!option) return null;
return (
<CommandItem
key={option}
onSelect={() => {
const newFilterValue = filterValue?.includes(option)
? filterValue?.filter((value) => value !== option)
: [...(filterValue || []), option];
setFilterValue(newFilterValue);
}}>
<div
className={classNames(
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
Array.isArray(filterValue) && (filterValue as string[])?.includes(option)
? "bg-primary"
: "opacity-50"
)}>
{Array.isArray(filterValue) && (filterValue as string[])?.includes(option) && (
<Icon name="check" className="text-primary-foreground h-4 w-4" />
)}
</div>
{option}
</CommandItem>
);
})}
</CommandList>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => {
removeFilter(column.id);
}}
className={classNames("w-full justify-center text-center", buttonClasses({ color: "secondary" }))}>
{t("clear")}
</CommandItem>
</CommandGroup>
</Command>
);
}
@@ -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 (
<div className="mx-3 my-2">
<Form
form={form}
handleSubmit={({ operatorOption, operand }) => {
if (operatorOption) {
setFilterValue({
type: "text",
data: {
operator: operatorOption.value,
operand: operatorOption.requiresOperand ? operand : "",
},
});
}
}}>
<div>
<Controller
name="operatorOption"
control={form.control}
render={({ field: { value } }) => (
<>
<Select
options={textFilterOperatorOptions}
value={value}
isSearchable={false}
onChange={(event) => {
if (event) {
form.setValue("operatorOption", { ...event }, { shouldDirty: true });
}
}}
/>
{value?.requiresOperand && <Input className="mt-2" {...form.register("operand")} />}
</>
)}
/>
<div className="bg-subtle -mx-3 mb-2 h-px" role="separator" />
<div className="flex items-center justify-between">
<Button
type="button"
color="secondary"
disabled={form.formState.isSubmitting}
onClick={() => removeFilter(column.id)}>
{t("clear")}
</Button>
<Button
type="submit"
color="primary"
loading={form.formState.isSubmitting}
disabled={form.formState.isSubmitting}>
{t("apply")}
</Button>
</div>
</div>
</Form>
</div>
);
}
@@ -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<TData> {
table: Table<TData>;
@@ -188,26 +190,21 @@ interface ActiveFiltersProps<TData> {
}
function ActiveFilters<TData>({ table }: ActiveFiltersProps<TData>) {
const { t } = useLocale();
const [_state, _setState] = useFiltersSearchState();
const columns = table.getAllColumns().filter((column) => column.getCanFilter());
const filterableColumns = useMemo(() => {
const filterableColumns = useMemo<FilterableColumn[]>(() => {
return columns.map((column) => {
return {
id: column.id,
title: typeof column.columnDef.header === "string" ? column.columnDef.header : column.id,
filterType: column.columnDef.meta?.filterType || "select",
options: column.getFacetedUniqueValues(),
};
});
}, [columns]);
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<TData>({ table }: ActiveFiltersProps<TData>) {
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput placeholder={t("search_options")} />
<CommandList>
<CommandEmpty>{t("no_options_found")}</CommandEmpty>
{Array.from(column.options).map(([option]) => {
if (!option) return null;
return (
<CommandItem
key={option}
onSelect={() => {
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);
}}>
<div
className={classNames(
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
Array.isArray(table.getColumn(column.id)?.getFilterValue()) &&
(table.getColumn(column.id)?.getFilterValue() as string[])?.includes(option)
? "bg-primary"
: "opacity-50"
)}>
{Array.isArray(table.getColumn(column.id)?.getFilterValue()) &&
(table.getColumn(column.id)?.getFilterValue() as string[])?.includes(option) && (
<Icon name="check" className="text-primary-foreground h-4 w-4" />
)}
</div>
{option}
</CommandItem>
);
})}
</CommandList>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => {
handleRemoveFilter(column.id);
}}
className={classNames(
"w-full justify-center text-center",
buttonClasses({ color: "secondary" })
)}>
{t("clear")}
</CommandItem>
</CommandGroup>
</Command>
<FilterOptions
column={column}
filter={filter}
state={_state}
setState={_setState}
table={table}
/>
</PopoverContent>
</Popover>
);
+3
View File
@@ -0,0 +1,3 @@
export * from "./components";
export * from "./lib/types";
export * from "./lib/utils";
@@ -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 {};
}
+39
View File
@@ -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<typeof ZTextFilterOperator>;
export const ZSelectFilterValue = z.array(z.string());
export type SelectFilterValue = z.infer<typeof ZSelectFilterValue>;
export const ZTextFilterValue = z.object({
type: z.literal("text"),
data: z.object({
operator: ZTextFilterOperator,
operand: z.string(),
}),
});
export type TextFilterValue = z.infer<typeof ZTextFilterValue>;
export const ZFilterValue = z.union([ZSelectFilterValue, ZTextFilterValue]);
export type FilterValue = z.infer<typeof ZFilterValue>;
export type FilterableColumn = {
id: string;
title: string;
filterType: "text" | "select";
options: Map<string, number>;
};
+82
View File
@@ -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<typeof useFiltersSearchState>[0];
export type SetFiltersSearchState = ReturnType<typeof useFiltersSearchState>[1];
export type ActiveFilter = NonNullable<FiltersSearchState["activeFilters"]>[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");
};
@@ -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";
@@ -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";
@@ -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,
@@ -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";
@@ -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";
@@ -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<ColumnFiltersState>([]);
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(),
@@ -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),
},
},
};
@@ -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<typeof ZListMembersSchema>;
+1
View File
@@ -6,5 +6,6 @@ declare module "@tanstack/table-core" {
position: "left" | "right";
gap?: number;
};
filterType?: "select" | "text";
}
}
@@ -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<TData, TValue> {
column?: Column<TData, TValue>;
title?: string;
options: FilterOption[];
}
export function DataTableFilter<TData, TValue>({ column, title, options }: DataTableFilter<TData, TValue>) {
const facets = column?.getFacetedUniqueValues();
const selectedValues = new Set(column?.getFilterValue() as string[]);
const [currentOptions, setCurrentOptions] = useState<FilterOption[]>(options);
const [navigationPath, setNavigationPath] = useState<string[]>([]);
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 (
<Popover>
<PopoverTrigger asChild>
<Button color="secondary" size="sm" className="border-subtle h-8 rounded-md">
<Icon name="filter" className="mr-2 h-4 w-4" />
{title}
{selectedValues?.size > 0 && (
<>
<div className="ml-2 hidden space-x-1 md:flex">
{selectedValues.size > 2 ? (
<Badge color="gray" className="rounded-sm px-1 font-normal">
{selectedValues.size}
</Badge>
) : (
allOptions
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge color="gray" key={option.value} className="rounded-sm px-1 font-normal">
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{navigationPath.length > 0 && (
<CommandGroup>
<CommandItem onSelect={handleBack}>
<Icon name="arrow-left" className="mr-2 h-4 w-4" />
Back
</CommandItem>
</CommandGroup>
)}
<CommandGroup>
{currentOptions.map((option) => {
const isSelected = selectedValues.has(option.value);
return (
<CommandItem key={option.value} onSelect={() => handleSelect(option)}>
{!option.options && (
<div
className={classNames(
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
isSelected ? "text-emphasis" : "opacity-50 [&_svg]:invisible"
)}>
<Icon name="check" className={classNames("h-4 w-4")} />
</div>
)}
{option.icon && <Icon name={option.icon} className="text-muted mr-2 h-4 w-4" />}
<span>{option.label}</span>
{option.options && <Icon name="chevron-right" className="ml-auto h-4 w-4" />}
{!option.options && facets?.get(option.value) && (
<span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => column?.setFilterValue(undefined)}
className="justify-center text-center">
Clear filters
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -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);
}
-1
View File
@@ -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";
-1
View File
@@ -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",