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:
co-authored by
Udit Takkar
parent
ff18b9a733
commit
1404581f78
@@ -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.",
|
||||
|
||||
+3
-5
@@ -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>
|
||||
+1
-2
@@ -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> =
|
||||
| {
|
||||
+2
-4
@@ -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>
|
||||
);
|
||||
}
|
||||
+12
-62
@@ -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>
|
||||
);
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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>;
|
||||
|
||||
Vendored
+1
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user