feat: refactor filters on insights response table (#17796)
* 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 * feat: support DataTable filters for Insights Routing WIP * remove unused filters * remove additionalFilters and fix types * clean up filter components * support icons for ActiveFilters * support filters on json * fix filter ui * fix type error and clean up * revert changes * revert change * clean up * revert change * fix compatibility with insights booking page * remove unused params * fix type errors * update yarn.lock * fix field filter and adjust ui * chore: update yarn.lock * fix text filter * add Clear Filters button * add more test data --------- Co-authored-by: Udit Takkar <udit222001@gmail.com>
This commit is contained in:
co-authored by
Udit Takkar
parent
82a500aeaa
commit
5d88858995
@@ -1,34 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
FailedBookingsByField,
|
||||
RoutingFormResponsesTable,
|
||||
RoutingKPICards,
|
||||
} from "@calcom/features/insights/components";
|
||||
import { FailedBookingsByField, RoutingFormResponsesTable } from "@calcom/features/insights/components";
|
||||
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
|
||||
import { RoutingInsightsFilters } from "@calcom/features/insights/filters/routing/FilterBar";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import InsightsLayout from "./layout";
|
||||
|
||||
export default function InsightsPage() {
|
||||
export default function InsightsRoutingFormResponsesPage() {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<InsightsLayout>
|
||||
<FiltersProvider>
|
||||
<div className="mb-4 space-y-4">
|
||||
<RoutingFormResponsesTable>
|
||||
{/* We now render the "filters and KPI as a children of the table but we still need to pass the table instance to it so we can access column status in the toolbar.*/}
|
||||
{(table) => (
|
||||
<div className="header mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<RoutingInsightsFilters table={table} />
|
||||
</div>
|
||||
<RoutingKPICards />
|
||||
</div>
|
||||
)}
|
||||
</RoutingFormResponsesTable>
|
||||
<RoutingFormResponsesTable />
|
||||
|
||||
<FailedBookingsByField />
|
||||
|
||||
|
||||
@@ -2822,6 +2822,8 @@
|
||||
"rr_distribution_method_balanced_description": "We will monitor how many bookings have been made with each host and compare this with others, disabling some hosts that are too far ahead so bookings are evenly distributed.",
|
||||
"exclude_emails_that_contain": "Exclude emails that contain ...",
|
||||
"exclude_emails_match_found_error_message": "Please enter a valid work email address",
|
||||
"search_columns": "Search columns",
|
||||
"search_options": "Search options",
|
||||
"disable_org_url_label": "Disable public organization profile and redirect",
|
||||
"disable_org_url_description": "Redirects {{orgSlug}}.cal.com to {{destination}}",
|
||||
"single_select": "Single Select",
|
||||
|
||||
@@ -158,7 +158,7 @@ const AdminAppsList = ({
|
||||
<form
|
||||
{...rest}
|
||||
className={
|
||||
classNames?.form ?? "max-w-80 bg-default mb-4 rounded-md px-0 pt-0 md:max-w-full md:px-8 md:pt-10"
|
||||
classNames?.form ?? "bg-default mb-4 max-w-80 rounded-md px-0 pt-0 md:max-w-full md:px-8 md:pt-10"
|
||||
}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -10,6 +10,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { ButtonProps } from "@calcom/ui";
|
||||
import { Button, Input } from "@calcom/ui";
|
||||
|
||||
import { useColumnFilters } from "../lib/utils";
|
||||
|
||||
interface DataTableToolbarProps extends ComponentPropsWithoutRef<"div"> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -89,7 +91,8 @@ function ClearFiltersButtonComponent<TData>(
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) {
|
||||
const { t } = useLocale();
|
||||
const isFiltered = table.getState().columnFilters.length > 0;
|
||||
const columnFilters = useColumnFilters();
|
||||
const isFiltered = columnFilters.length > 0;
|
||||
if (!isFiltered) return null;
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -35,7 +35,7 @@ export function FilterOptions<TData>({ column, filter, state, setState, table }:
|
||||
table.getColumn(columnId)?.setFilterValue(undefined);
|
||||
};
|
||||
|
||||
if (column.filterType === "text") {
|
||||
if (column.type === "text") {
|
||||
return (
|
||||
<TextFilterOptions
|
||||
column={column}
|
||||
@@ -44,7 +44,7 @@ export function FilterOptions<TData>({ column, filter, state, setState, table }:
|
||||
removeFilter={removeFilter}
|
||||
/>
|
||||
);
|
||||
} else if (column.filterType === "select") {
|
||||
} else if (column.type === "select") {
|
||||
return (
|
||||
<MultiSelectFilterOptions
|
||||
column={column}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import type { FilterableColumn, SelectFilterValue } from "../../lib/types";
|
||||
|
||||
export type MultiSelectFilterOptionsProps = {
|
||||
column: FilterableColumn;
|
||||
column: Extract<FilterableColumn, { type: "select" }>;
|
||||
filterValue?: SelectFilterValue;
|
||||
setFilterValue: (value: SelectFilterValue) => void;
|
||||
removeFilter: (columnId: string) => void;
|
||||
@@ -36,27 +36,30 @@ export function MultiSelectFilterOptions({
|
||||
<CommandEmpty>{t("no_options_found")}</CommandEmpty>
|
||||
{Array.from(column.options.keys()).map((option) => {
|
||||
if (!option) return null;
|
||||
const { label: optionLabel, value: optionValue } =
|
||||
typeof option === "string" ? { label: option, value: option } : option;
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={option}
|
||||
key={optionValue}
|
||||
onSelect={() => {
|
||||
const newFilterValue = filterValue?.includes(option)
|
||||
? filterValue?.filter((value) => value !== option)
|
||||
: [...(filterValue || []), option];
|
||||
const newFilterValue = filterValue?.includes(optionValue)
|
||||
? filterValue?.filter((value) => value !== optionValue)
|
||||
: [...(filterValue || []), optionValue];
|
||||
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)
|
||||
Array.isArray(filterValue) && (filterValue as string[])?.includes(optionValue)
|
||||
? "bg-primary"
|
||||
: "opacity-50"
|
||||
)}>
|
||||
{Array.isArray(filterValue) && (filterValue as string[])?.includes(option) && (
|
||||
{Array.isArray(filterValue) && (filterValue as string[])?.includes(optionValue) && (
|
||||
<Icon name="check" className="text-primary-foreground h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
{option}
|
||||
{optionLabel}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -26,7 +26,7 @@ const useTextFilterOperatorOptions = (): TextFilterOperatorOption[] => {
|
||||
};
|
||||
|
||||
export type TextFilterOptionsProps = {
|
||||
column: FilterableColumn;
|
||||
column: Extract<FilterableColumn, { type: "text" }>;
|
||||
filterValue?: TextFilterValue;
|
||||
setFilterValue: (value: TextFilterValue) => void;
|
||||
removeFilter: (columnId: string) => void;
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from "@calcom/ui";
|
||||
|
||||
import type { FilterableColumn } from "../../lib/types";
|
||||
import { useFiltersSearchState } from "../../lib/utils";
|
||||
import { convertToTitleCase, useFiltersState } from "../../lib/utils";
|
||||
import { FilterOptions } from "./FilterOptions";
|
||||
|
||||
interface ColumnVisiblityProps<TData> {
|
||||
@@ -118,35 +118,24 @@ const ColumnVisibilityButton = forwardRef(ColumnVisibilityButtonComponent) as <T
|
||||
) => ReturnType<typeof ColumnVisibilityButtonComponent>;
|
||||
|
||||
// Filters
|
||||
interface FilterButtonProps<TData> {
|
||||
interface AddFilterButtonProps<TData> {
|
||||
table: Table<TData>;
|
||||
omit?: string[];
|
||||
}
|
||||
|
||||
function FilterButtonComponent<TData>(
|
||||
{ table, omit }: FilterButtonProps<TData>,
|
||||
function AddFilterButtonComponent<TData>(
|
||||
{ table, omit }: AddFilterButtonProps<TData>,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) {
|
||||
const { t } = useLocale();
|
||||
const [_state, _setState] = useFiltersSearchState();
|
||||
const { state, setState } = useFiltersState();
|
||||
|
||||
const activeFilters = _state.activeFilters;
|
||||
const columns = table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanFilter())
|
||||
.filter((column) => !omit?.includes(column.id));
|
||||
|
||||
const filterableColumns = useMemo(() => {
|
||||
return columns.map((column) => ({
|
||||
id: column.id,
|
||||
title: typeof column.columnDef.header === "string" ? column.columnDef.header : column.id,
|
||||
options: column.getFacetedUniqueValues(),
|
||||
}));
|
||||
}, [columns]);
|
||||
const activeFilters = state.activeFilters;
|
||||
const filterableColumns = useFilterableColumns(table, omit);
|
||||
|
||||
const handleAddFilter = (columnId: string) => {
|
||||
if (!activeFilters?.some((filter) => filter.f === columnId)) {
|
||||
_setState({ activeFilters: [...activeFilters, { f: columnId, v: [] }] });
|
||||
setState({ activeFilters: [...activeFilters, { f: columnId, v: [] }] });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -167,8 +156,11 @@ function FilterButtonComponent<TData>(
|
||||
{filterableColumns.map((column) => {
|
||||
if (activeFilters?.some((filter) => filter.f === column.id)) return null;
|
||||
return (
|
||||
<CommandItem key={column.id} onSelect={() => handleAddFilter(column.id)}>
|
||||
{column.title}
|
||||
<CommandItem
|
||||
key={column.id}
|
||||
onSelect={() => handleAddFilter(column.id)}
|
||||
className="px-4 py-2">
|
||||
{convertToTitleCase(column.title)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
@@ -180,9 +172,48 @@ function FilterButtonComponent<TData>(
|
||||
);
|
||||
}
|
||||
|
||||
const FilterButton = forwardRef(FilterButtonComponent) as <TData>(
|
||||
props: FilterButtonProps<TData> & { ref?: React.Ref<HTMLButtonElement>; omit?: string[] }
|
||||
) => ReturnType<typeof FilterButtonComponent>;
|
||||
function useFilterableColumns<TData>(table: Table<TData>, omit?: string[]) {
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanFilter())
|
||||
.filter((column) => !omit?.includes(column.id)),
|
||||
[table.getAllColumns(), omit]
|
||||
);
|
||||
|
||||
const filterableColumns = useMemo<FilterableColumn[]>(
|
||||
() =>
|
||||
columns
|
||||
.map((column) => {
|
||||
const type = column.columnDef.meta?.filter?.type || "select";
|
||||
const base = {
|
||||
id: column.id,
|
||||
title: typeof column.columnDef.header === "string" ? column.columnDef.header : column.id,
|
||||
...(column.columnDef.meta?.filter || {}),
|
||||
type,
|
||||
};
|
||||
if (type === "select") {
|
||||
return {
|
||||
...base,
|
||||
options: column.getFacetedUniqueValues(),
|
||||
};
|
||||
} else if (type === "text") {
|
||||
return {
|
||||
...base,
|
||||
};
|
||||
}
|
||||
})
|
||||
.filter((column): column is FilterableColumn => Boolean(column)),
|
||||
[columns]
|
||||
);
|
||||
|
||||
return filterableColumns;
|
||||
}
|
||||
|
||||
const AddFilterButton = forwardRef(AddFilterButtonComponent) as <TData>(
|
||||
props: AddFilterButtonProps<TData> & { ref?: React.Ref<HTMLButtonElement>; omit?: string[] }
|
||||
) => ReturnType<typeof AddFilterButtonComponent>;
|
||||
|
||||
// Add the new ActiveFilters component
|
||||
interface ActiveFiltersProps<TData> {
|
||||
@@ -190,31 +221,22 @@ interface ActiveFiltersProps<TData> {
|
||||
}
|
||||
|
||||
function ActiveFilters<TData>({ table }: ActiveFiltersProps<TData>) {
|
||||
const [_state, _setState] = useFiltersSearchState();
|
||||
const { state, setState } = useFiltersState();
|
||||
|
||||
const columns = table.getAllColumns().filter((column) => column.getCanFilter());
|
||||
|
||||
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 filterableColumns = useFilterableColumns(table);
|
||||
|
||||
return (
|
||||
<>
|
||||
{(_state.activeFilters || []).map((filter) => {
|
||||
{state.activeFilters.map((filter) => {
|
||||
const column = filterableColumns.find((col) => col.id === filter.f);
|
||||
if (!column) return null;
|
||||
const icon = column.icon || (column.type === "text" ? "file-text" : "layers");
|
||||
return (
|
||||
<Popover key={column.id}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button color="secondary">
|
||||
{column.title}
|
||||
<Icon name={icon} className="mr-2 h-4 w-4" />
|
||||
{convertToTitleCase(column.title)}
|
||||
<Icon name="chevron-down" className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -222,8 +244,8 @@ function ActiveFilters<TData>({ table }: ActiveFiltersProps<TData>) {
|
||||
<FilterOptions
|
||||
column={column}
|
||||
filter={filter}
|
||||
state={_state}
|
||||
setState={_setState}
|
||||
state={state}
|
||||
setState={setState}
|
||||
table={table}
|
||||
/>
|
||||
</PopoverContent>
|
||||
@@ -235,4 +257,4 @@ function ActiveFilters<TData>({ table }: ActiveFiltersProps<TData>) {
|
||||
}
|
||||
|
||||
// Update the export to include ActiveFilters
|
||||
export const DataTableFilters = { ColumnVisibilityButton, FilterButton, ActiveFilters };
|
||||
export const DataTableFilters = { ColumnVisibilityButton, AddFilterButton, ActiveFilters };
|
||||
|
||||
@@ -1,62 +1,76 @@
|
||||
import type { FilterValue } from "./types";
|
||||
import { isSelectFilterValue, isTextFilterValue } from "./utils";
|
||||
|
||||
export function makeWhereClause(columnName: string, filterValue: FilterValue) {
|
||||
type makeWhereClauseProps = {
|
||||
columnName: string;
|
||||
filterValue: FilterValue;
|
||||
json?: true | { path: string[] };
|
||||
};
|
||||
|
||||
export function makeWhereClause(props: makeWhereClauseProps) {
|
||||
const { columnName, filterValue } = props;
|
||||
const isJson = props.json === true || (typeof props.json === "object" && props.json.path?.length > 0);
|
||||
const jsonPath = isJson && typeof props.json === "object" ? props.json.path : undefined;
|
||||
|
||||
const jsonPathObj = isJson && jsonPath ? { path: jsonPath } : {};
|
||||
if (isSelectFilterValue(filterValue)) {
|
||||
return {
|
||||
[columnName]: {
|
||||
in: filterValue,
|
||||
...jsonPathObj,
|
||||
...(isJson ? { array_contains: filterValue } : { in: filterValue }),
|
||||
},
|
||||
};
|
||||
} else if (isTextFilterValue(filterValue)) {
|
||||
const { operator, operand } = filterValue.data;
|
||||
|
||||
switch (operator) {
|
||||
case "equals":
|
||||
return {
|
||||
[columnName]: {
|
||||
...jsonPathObj,
|
||||
equals: operand,
|
||||
},
|
||||
};
|
||||
case "notEquals":
|
||||
return {
|
||||
[columnName]: {
|
||||
...jsonPathObj,
|
||||
not: operand,
|
||||
},
|
||||
};
|
||||
case "contains":
|
||||
return {
|
||||
[columnName]: {
|
||||
contains: operand,
|
||||
mode: "insensitive",
|
||||
...jsonPathObj,
|
||||
...(isJson ? { string_contains: operand } : { contains: operand, mode: "insensitive" }),
|
||||
},
|
||||
};
|
||||
case "notContains":
|
||||
return {
|
||||
NOT: {
|
||||
[columnName]: {
|
||||
contains: operand,
|
||||
mode: "insensitive",
|
||||
...jsonPathObj,
|
||||
...(isJson ? { string_contains: operand } : { contains: operand, mode: "insensitive" }),
|
||||
},
|
||||
},
|
||||
};
|
||||
case "startsWith":
|
||||
return {
|
||||
[columnName]: {
|
||||
startsWith: operand,
|
||||
mode: "insensitive",
|
||||
...jsonPathObj,
|
||||
...(isJson ? { string_starts_with: operand } : { startsWith: operand, mode: "insensitive" }),
|
||||
},
|
||||
};
|
||||
case "endsWith":
|
||||
return {
|
||||
[columnName]: {
|
||||
endsWith: operand,
|
||||
mode: "insensitive",
|
||||
...jsonPathObj,
|
||||
...(isJson ? { string_ends_with: operand } : { endsWith: operand, mode: "insensitive" }),
|
||||
},
|
||||
};
|
||||
case "isEmpty":
|
||||
return {
|
||||
[columnName]: {
|
||||
...jsonPathObj,
|
||||
equals: "",
|
||||
},
|
||||
};
|
||||
@@ -64,6 +78,7 @@ export function makeWhereClause(columnName: string, filterValue: FilterValue) {
|
||||
return {
|
||||
NOT: {
|
||||
[columnName]: {
|
||||
...jsonPathObj,
|
||||
equals: "",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { IconName } from "@calcom/ui/components/icon/icon-names";
|
||||
|
||||
export const ZTextFilterOperator = z.enum([
|
||||
"equals",
|
||||
"notEquals",
|
||||
@@ -31,9 +33,29 @@ export const ZFilterValue = z.union([ZSelectFilterValue, ZTextFilterValue]);
|
||||
|
||||
export type FilterValue = z.infer<typeof ZFilterValue>;
|
||||
|
||||
export type ColumnFilterMeta = {
|
||||
type?: "select" | "text";
|
||||
icon?: IconName;
|
||||
};
|
||||
|
||||
export type FilterableColumn = {
|
||||
id: string;
|
||||
title: string;
|
||||
filterType: "text" | "select";
|
||||
options: Map<string, number>;
|
||||
};
|
||||
} & (
|
||||
| {
|
||||
type: "select";
|
||||
icon?: IconName;
|
||||
options: Map<string | { label: string; value: string }, number>;
|
||||
}
|
||||
| {
|
||||
type: "text";
|
||||
icon?: IconName;
|
||||
}
|
||||
);
|
||||
|
||||
export const ZColumnFilter = z.object({
|
||||
id: z.string(),
|
||||
value: ZFilterValue,
|
||||
});
|
||||
|
||||
export type ColumnFilter = z.infer<typeof ZColumnFilter>;
|
||||
|
||||
@@ -1,51 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { parseAsArrayOf, parseAsJson, useQueryStates } from "nuqs";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { SelectFilterValue, TextFilterValue } from "./types";
|
||||
import { ZSelectFilterValue, ZTextFilterValue } from "./types";
|
||||
import type { SelectFilterValue, TextFilterValue, FilterValue } from "./types";
|
||||
import { ZFilterValue } from "./types";
|
||||
|
||||
const filterSchema = z.object({
|
||||
export const dataTableFiltersSchema = z.object({
|
||||
f: z.string(),
|
||||
v: z.union([ZSelectFilterValue, ZTextFilterValue]),
|
||||
v: ZFilterValue,
|
||||
});
|
||||
|
||||
export const filtersSearchParams = {
|
||||
activeFilters: parseAsArrayOf(parseAsJson(filterSchema.parse)).withDefault([]),
|
||||
};
|
||||
export function useFiltersState() {
|
||||
const [state, setState] = useQueryStates({
|
||||
activeFilters: parseAsArrayOf(parseAsJson(dataTableFiltersSchema.parse)).withDefault([]),
|
||||
});
|
||||
const clear = useCallback(() => {
|
||||
setState({ activeFilters: [] });
|
||||
}, [setState]);
|
||||
|
||||
export function useFiltersSearchState() {
|
||||
return useQueryStates(filtersSearchParams);
|
||||
return { state, setState, clear };
|
||||
}
|
||||
|
||||
export type FiltersSearchState = ReturnType<typeof useFiltersState>["state"];
|
||||
export type SetFiltersSearchState = ReturnType<typeof useFiltersState>["setState"];
|
||||
export type ActiveFilter = z.infer<typeof dataTableFiltersSchema>;
|
||||
|
||||
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;
|
||||
const { state } = useFiltersState();
|
||||
return useMemo(
|
||||
() =>
|
||||
(state.activeFilters || [])
|
||||
.filter((filter) => typeof filter === "object" && filter && "f" in filter && "v" in filter)
|
||||
.map((filter) => ({
|
||||
id: filter.f,
|
||||
value: filter.v,
|
||||
}))
|
||||
.filter((filter) => {
|
||||
// The empty arrays in `filtersSearchState` keep the filter UI component,
|
||||
// but we do not send them to the actual query.
|
||||
// Otherwise, `{ my_column_name: { in: []} }` would result in nothing being returned.
|
||||
if (Array.isArray(filter.value) && filter.value.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
[state]
|
||||
);
|
||||
}
|
||||
|
||||
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: unknown, filterValue: TextFilterValue) => {
|
||||
if (typeof cellValue !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
export const textFilter = (cellValue: string, filterValue: TextFilterValue) => {
|
||||
switch (filterValue.data.operator) {
|
||||
case "equals":
|
||||
return cellValue.toLowerCase() === (filterValue.data.operand || "").toLowerCase();
|
||||
@@ -77,6 +86,28 @@ export const isTextFilterValue = (filterValue: unknown): filterValue is TextFilt
|
||||
);
|
||||
};
|
||||
|
||||
export const selectFilter = (cellValue: unknown | undefined, filterValue: SelectFilterValue) => {
|
||||
const cellValueArray = Array.isArray(cellValue) ? cellValue : [cellValue];
|
||||
if (!cellValueArray.every((value) => typeof value === "string")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return filterValue.length === 0 ? true : cellValueArray.some((v) => filterValue.includes(v));
|
||||
};
|
||||
|
||||
export const isSelectFilterValue = (filterValue: unknown): filterValue is SelectFilterValue => {
|
||||
return Array.isArray(filterValue) && filterValue.every((item) => typeof item === "string");
|
||||
};
|
||||
|
||||
export const dataTableFilter = (cellValue: unknown, filterValue: FilterValue) => {
|
||||
if (isSelectFilterValue(filterValue)) {
|
||||
return selectFilter(cellValue, filterValue);
|
||||
} else if (isTextFilterValue(filterValue)) {
|
||||
return textFilter(cellValue, filterValue);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const convertToTitleCase = (str: string) => {
|
||||
return str.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
};
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
Icon,
|
||||
showToast,
|
||||
useMeta,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { DeleteAttributeModal } from "./DeleteAttributeModal";
|
||||
|
||||
@@ -654,7 +654,7 @@ export default function MemberList(props: Props) {
|
||||
<DataTableToolbar.Root>
|
||||
<div className="flex w-full gap-2">
|
||||
<DataTableToolbar.SearchBar table={table} onSearch={(value) => setDebouncedSearchTerm(value)} />
|
||||
<DataTableFilters.FilterButton table={table} />
|
||||
<DataTableFilters.AddFilterButton table={table} />
|
||||
<DataTableFilters.ColumnVisibilityButton table={table} />
|
||||
{isAdminOrOwner && (
|
||||
<DataTableToolbar.CTA
|
||||
|
||||
@@ -12,7 +12,16 @@ 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 type { FilterValue } from "@calcom/features/data-table";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFilters,
|
||||
useFetchMoreOnBottomReached,
|
||||
useColumnFilters,
|
||||
selectFilter,
|
||||
dataTableFilter,
|
||||
convertToTitleCase,
|
||||
} 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";
|
||||
@@ -36,6 +45,13 @@ import {
|
||||
import type { BadgeProps } from "@calcom/ui/components/badge/Badge";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { ClearFilters } from "../filters/ClearFilters";
|
||||
import { DateSelect } from "../filters/DateSelect";
|
||||
import { RoutingDownload } from "../filters/Download";
|
||||
import { RoutingFormFilterList } from "../filters/RoutingFormFilterList";
|
||||
import { TeamAndSelfList } from "../filters/TeamAndSelfList";
|
||||
import { UserListInTeam } from "../filters/UserListInTeam";
|
||||
import { RoutingKPICards } from "./RoutingKPICards";
|
||||
|
||||
type RoutingFormResponse = RouterOutputs["viewer"]["insights"]["routingFormResponses"]["data"][number];
|
||||
|
||||
@@ -47,6 +63,8 @@ type RoutingFormTableRow = {
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
type FieldCellValue = { label: string; value: string };
|
||||
|
||||
function CellWithOverflowX({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={classNames("group relative max-w-[200px]", className)}>
|
||||
@@ -102,29 +120,31 @@ function BookedByCell({
|
||||
);
|
||||
}
|
||||
|
||||
function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number }) {
|
||||
type ResponseValue = { label: string; value: string };
|
||||
|
||||
function ResponseValueCell({ values, rowId }: { values: ResponseValue[]; rowId: number }) {
|
||||
const cellId = useId();
|
||||
if (value.length === 0) return <div className="h-6 w-[200px]" />;
|
||||
if (values.length === 0) return <div className="h-6 w-[200px]" />;
|
||||
|
||||
return (
|
||||
<CellWithOverflowX className="flex w-[200px] gap-1">
|
||||
{value.length > 2 ? (
|
||||
{values.length > 2 ? (
|
||||
<>
|
||||
{value.slice(0, 2).map((v: string, i: number) => (
|
||||
{values.slice(0, 2).map((value: ResponseValue, i: number) => (
|
||||
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
|
||||
{v}
|
||||
{value.label}
|
||||
</Badge>
|
||||
))}
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<Badge variant="gray">+{value.length - 2}</Badge>
|
||||
<Badge variant="gray">+{values.length - 2}</Badge>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side="bottom" align="start" className="w-fit">
|
||||
<div className="flex flex-col gap-1">
|
||||
{value.slice(2).map((v: string, i: number) => (
|
||||
{values.slice(2).map((value: ResponseValue, i: number) => (
|
||||
<span key={`${cellId}-overflow-${i}-${rowId}`} className="text-default text-sm">
|
||||
{v}
|
||||
{value.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -133,9 +153,9 @@ function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number })
|
||||
</HoverCard>
|
||||
</>
|
||||
) : (
|
||||
value.map((v: string, i: number) => (
|
||||
values.map((value: ResponseValue, i: number) => (
|
||||
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
|
||||
{v}
|
||||
{value.label}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
@@ -229,21 +249,15 @@ export function RoutingFormResponsesTable({
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const { copyToClipboard, isCopied } = useCopy();
|
||||
const { copyToClipboard } = useCopy();
|
||||
|
||||
const {
|
||||
dateRange,
|
||||
selectedTeamId,
|
||||
isAll,
|
||||
initialConfig,
|
||||
selectedRoutingFormId,
|
||||
selectedMemberUserId,
|
||||
selectedBookingStatus,
|
||||
selectedRoutingFormFilter,
|
||||
} = filter;
|
||||
const { dateRange, selectedTeamId, isAll, initialConfig, selectedRoutingFormId, selectedMemberUserId } =
|
||||
filter;
|
||||
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
|
||||
const [startDate, endDate] = dateRange;
|
||||
|
||||
const columnFilters = useColumnFilters();
|
||||
|
||||
const { data: headers, isLoading: isHeadersLoading } =
|
||||
trpc.viewer.insights.routingFormResponsesHeaders.useQuery(
|
||||
{
|
||||
@@ -265,8 +279,7 @@ export function RoutingFormResponsesTable({
|
||||
userId: selectedMemberUserId ?? undefined,
|
||||
isAll: isAll ?? false,
|
||||
routingFormId: selectedRoutingFormId ?? undefined,
|
||||
bookingStatus: selectedBookingStatus ?? undefined,
|
||||
fieldFilter: selectedRoutingFormFilter ?? undefined,
|
||||
columnFilters,
|
||||
limit: 30,
|
||||
},
|
||||
{
|
||||
@@ -295,20 +308,27 @@ export function RoutingFormResponsesTable({
|
||||
};
|
||||
|
||||
Object.entries(response.response).forEach(([fieldId, field]) => {
|
||||
const header = headers?.find((h) => h.id === fieldId);
|
||||
const fieldHeader = headers?.fields.find((h) => h.id === fieldId);
|
||||
|
||||
if (header?.options) {
|
||||
if (fieldHeader?.options) {
|
||||
if (Array.isArray(field.value)) {
|
||||
// Map the IDs to their corresponding labels for array values
|
||||
const labels = field.value.map((id) => {
|
||||
const option = header.options?.find((opt) => opt.id === id);
|
||||
return option?.label ?? id;
|
||||
});
|
||||
const labels = field.value
|
||||
.map((id) => {
|
||||
const option = fieldHeader.options?.find((opt) => opt.id === id);
|
||||
if (!option) {
|
||||
return undefined;
|
||||
}
|
||||
return { label: option.label, value: option.id };
|
||||
})
|
||||
.filter(Boolean);
|
||||
row[fieldId] = labels;
|
||||
} else {
|
||||
// Handle single value case
|
||||
const option = header.options?.find((opt) => opt.id === field.value);
|
||||
row[fieldId] = option?.label ?? field.value;
|
||||
const option = fieldHeader.options?.find((opt) => opt.id === field.value);
|
||||
if (option) {
|
||||
row[fieldId] = { label: option.label, value: option.id };
|
||||
}
|
||||
}
|
||||
} else {
|
||||
row[fieldId] = field.value;
|
||||
@@ -327,26 +347,50 @@ export function RoutingFormResponsesTable({
|
||||
id: "bookedBy",
|
||||
header: t("routing_form_insights_booked_by"),
|
||||
size: 200,
|
||||
enableColumnFilter: false,
|
||||
cell: (info) => {
|
||||
const row = info.row.original;
|
||||
return <BookedByCell attendees={row.routedToBooking?.attendees || []} rowId={row.id} />;
|
||||
},
|
||||
}),
|
||||
|
||||
...(headers?.map((header) => {
|
||||
return columnHelper.accessor(header.id, {
|
||||
id: header.id,
|
||||
header: header.label,
|
||||
...((headers?.fields || []).map((fieldHeader) => {
|
||||
const isTextOrEmail = fieldHeader.type === "text" || fieldHeader.type === "email";
|
||||
return columnHelper.accessor(fieldHeader.id, {
|
||||
id: fieldHeader.id,
|
||||
header: convertToTitleCase(fieldHeader.label),
|
||||
size: 200,
|
||||
cell: (info) => {
|
||||
let value = info.getValue();
|
||||
value = Array.isArray(value) ? value : [value];
|
||||
const values = info.getValue();
|
||||
return (
|
||||
<div className="max-w-[200px]">
|
||||
<ResponseValueCell value={value} rowId={info.row.original.id} />
|
||||
{isTextOrEmail ? (
|
||||
<span>{values}</span>
|
||||
) : (
|
||||
<ResponseValueCell
|
||||
values={Array.isArray(values) ? values : [values]}
|
||||
rowId={info.row.original.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
filter: isTextOrEmail ? { type: "text" } : { type: "select" },
|
||||
},
|
||||
filterFn: (row, id, filterValue: FilterValue) => {
|
||||
let cellValue: unknown;
|
||||
|
||||
if (Array.isArray(fieldHeader.options)) {
|
||||
cellValue = Array.isArray(row.original[id])
|
||||
? row.original[id].map((item: FieldCellValue) => item.value)
|
||||
: row.original[id].value;
|
||||
} else {
|
||||
cellValue = row.original[id];
|
||||
}
|
||||
|
||||
return dataTableFilter(cellValue, filterValue);
|
||||
},
|
||||
});
|
||||
}) ?? []),
|
||||
columnHelper.accessor("routedToBooking", {
|
||||
@@ -358,11 +402,18 @@ export function RoutingFormResponsesTable({
|
||||
<BookingStatusBadge booking={info.getValue()} />
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
filter: { type: "select", icon: "circle" },
|
||||
},
|
||||
filterFn: (row, id, filterValue) => {
|
||||
return selectFilter(row.original.routedToBooking?.status, filterValue);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("routedToBooking", {
|
||||
id: "bookingAt",
|
||||
header: t("routing_form_insights_booking_at"),
|
||||
size: 250,
|
||||
enableColumnFilter: false,
|
||||
cell: (info) => (
|
||||
<div className="max-w-[250px]">
|
||||
<BookingAtCell
|
||||
@@ -391,6 +442,7 @@ export function RoutingFormResponsesTable({
|
||||
id: "submittedAt",
|
||||
header: t("routing_form_insights_submitted_at"),
|
||||
size: 250,
|
||||
enableColumnFilter: false,
|
||||
cell: (info) => (
|
||||
<div className="whitespace-nowrap">
|
||||
<Badge variant="gray">{dayjs(info.getValue()).format("MMM D, YYYY HH:mm")}</Badge>
|
||||
@@ -410,6 +462,27 @@ export function RoutingFormResponsesTable({
|
||||
defaultColumn: {
|
||||
size: 200,
|
||||
},
|
||||
state: {
|
||||
columnFilters,
|
||||
},
|
||||
getFacetedUniqueValues: (_, columnId) => () => {
|
||||
if (!headers) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const fieldHeader = headers.fields.find((h) => h.id === columnId);
|
||||
if (fieldHeader?.options) {
|
||||
return new Map(fieldHeader.options.map((option) => [{ label: option.label, value: option.id }, 1]));
|
||||
} else if (columnId === "bookingStatus") {
|
||||
return new Map(
|
||||
Object.keys(BookingStatus).map((status) => [
|
||||
{ value: status, label: bookingStatusToText(status as BookingStatus) },
|
||||
1,
|
||||
])
|
||||
);
|
||||
}
|
||||
return new Map();
|
||||
},
|
||||
});
|
||||
|
||||
const fetchMoreOnBottomReached = useFetchMoreOnBottomReached(
|
||||
@@ -477,7 +550,20 @@ export function RoutingFormResponsesTable({
|
||||
}
|
||||
}}
|
||||
isPending={isFetching && !data}>
|
||||
{typeof children === "function" ? children(table) : children}
|
||||
<div className="header mb-4">
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<TeamAndSelfList omitOrg={true} className="mb-0" />
|
||||
<DataTableFilters.AddFilterButton table={table} />
|
||||
<UserListInTeam showOnlyWhenSelectedInContext={false} />
|
||||
<RoutingFormFilterList showOnlyWhenSelectedInContext={false} />
|
||||
<DataTableFilters.ActiveFilters table={table} />
|
||||
<ClearFilters />
|
||||
<DateSelect />
|
||||
<RoutingDownload />
|
||||
<DataTableFilters.ColumnVisibilityButton table={table} />
|
||||
</div>
|
||||
<RoutingKPICards />
|
||||
</div>
|
||||
</DataTable>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useFiltersState } from "@calcom/features/data-table";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon, Button, Tooltip } from "@calcom/ui";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
|
||||
// This component clears filters from both:
|
||||
// - the filter from FilterContext
|
||||
// - the data table filters state
|
||||
export const ClearFilters = () => {
|
||||
const { t } = useLocale();
|
||||
const { clearFilters, filter } = useFilterContext();
|
||||
const { state, clear: clearFiltersState } = useFiltersState();
|
||||
|
||||
const clear = () => {
|
||||
// clear filters from the filter context
|
||||
clearFilters();
|
||||
|
||||
// clear filters from the data table state
|
||||
clearFiltersState();
|
||||
};
|
||||
|
||||
const { initialConfig, selectedTeamId, selectedMemberUserId } = filter;
|
||||
|
||||
const isFilterSelected =
|
||||
initialConfig?.teamId !== selectedTeamId ||
|
||||
initialConfig?.userId !== selectedMemberUserId ||
|
||||
state.activeFilters?.length > 0;
|
||||
|
||||
if (!isFilterSelected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={t("clear_filters")}>
|
||||
<Button
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="min-w-24 h-[38px] border-0"
|
||||
onClick={clear}>
|
||||
<Icon name="x" className="mr-1 h-4 w-4" />
|
||||
{t("clear")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { DateRangePicker } from "@calcom/ui";
|
||||
import { Select } from "@calcom/ui";
|
||||
@@ -9,7 +10,7 @@ import { Select } from "@calcom/ui";
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import "./DateSelect.css";
|
||||
|
||||
export const DateSelect = () => {
|
||||
export const DateSelect = ({ className }: { className?: string }) => {
|
||||
const { t } = useLocale();
|
||||
const presetOptions = [
|
||||
{ label: t("today"), value: "tdy" },
|
||||
@@ -68,7 +69,7 @@ export const DateSelect = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ml me-2 ms-2 inline-flex space-x-2 rtl:space-x-reverse">
|
||||
<div className={classNames("ml inline-flex space-x-2 rtl:space-x-reverse", className)}>
|
||||
<DateRangePicker
|
||||
dates={{
|
||||
startDate: startValue,
|
||||
|
||||
@@ -16,35 +16,37 @@ function buildFilterOptions(forms: Form[]) {
|
||||
}));
|
||||
}
|
||||
|
||||
export const RoutingFormFilterList = memo(() => {
|
||||
const { t } = useLocale();
|
||||
const { filter, setConfigFilters } = useFilterContext();
|
||||
const { selectedTeamId, selectedRoutingFormId, isAll } = filter;
|
||||
const { selectedFilter } = filter;
|
||||
export const RoutingFormFilterList = memo(
|
||||
({ showOnlyWhenSelectedInContext = true }: { showOnlyWhenSelectedInContext?: boolean }) => {
|
||||
const { t } = useLocale();
|
||||
const { filter, setConfigFilters } = useFilterContext();
|
||||
const { selectedTeamId, selectedRoutingFormId, isAll } = filter;
|
||||
const { selectedFilter } = filter;
|
||||
|
||||
const { data: allForms } = trpc.viewer.insights.getRoutingFormsForFilters.useQuery(
|
||||
{
|
||||
teamId: selectedTeamId ?? undefined,
|
||||
isAll: isAll ?? false,
|
||||
},
|
||||
{
|
||||
enabled: selectedFilter?.includes("routing_forms"),
|
||||
}
|
||||
);
|
||||
const { data: allForms } = trpc.viewer.insights.getRoutingFormsForFilters.useQuery(
|
||||
{
|
||||
teamId: selectedTeamId ?? undefined,
|
||||
isAll: isAll ?? false,
|
||||
},
|
||||
{
|
||||
enabled: selectedFilter?.includes("routing_forms"),
|
||||
}
|
||||
);
|
||||
|
||||
if (!selectedFilter?.includes("routing_forms")) return null;
|
||||
if (showOnlyWhenSelectedInContext && !selectedFilter?.includes("routing_forms")) return null;
|
||||
|
||||
const filterOptions = buildFilterOptions(allForms || []);
|
||||
const filterOptions = buildFilterOptions(allForms || []);
|
||||
|
||||
return (
|
||||
<FilterSelect
|
||||
title={t("routing_forms")}
|
||||
options={filterOptions}
|
||||
selectedValue={selectedRoutingFormId}
|
||||
onChange={(value) => setConfigFilters({ selectedRoutingFormId: value as string })}
|
||||
buttonIcon={<Icon name="filter" className="mr-2 h-4 w-4" />}
|
||||
/>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<FilterSelect
|
||||
title={t("routing_forms")}
|
||||
options={filterOptions}
|
||||
selectedValue={selectedRoutingFormId}
|
||||
onChange={(value) => setConfigFilters({ selectedRoutingFormId: value as string })}
|
||||
buttonIcon={<Icon name="filter" className="mr-2 h-4 w-4" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
RoutingFormFilterList.displayName = "RoutingFormFilterList";
|
||||
|
||||
@@ -12,7 +12,13 @@ import { AnimatedPopover, Avatar, Divider, Icon } from "@calcom/ui";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
|
||||
export const TeamAndSelfList = ({ omitOrg = false }: { omitOrg?: boolean }) => {
|
||||
export const TeamAndSelfList = ({
|
||||
omitOrg = false,
|
||||
className = "",
|
||||
}: {
|
||||
omitOrg?: boolean;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const currentOrgId = session.data?.user.org?.id;
|
||||
@@ -81,7 +87,7 @@ export const TeamAndSelfList = ({ omitOrg = false }: { omitOrg?: boolean }) => {
|
||||
const text = getTextPopover();
|
||||
|
||||
return (
|
||||
<AnimatedPopover text={text}>
|
||||
<AnimatedPopover text={text} popoverTriggerClassNames={className}>
|
||||
<FilterCheckboxFieldsContainer>
|
||||
{isSuccess && data?.length > 0 && data[0].isOrg && (
|
||||
<FilterCheckboxField
|
||||
|
||||
+11
-3
@@ -13,7 +13,11 @@ const mapUserToOption = (user: User) => ({
|
||||
icon: <Avatar alt={`${user.name} avatar`} imageSrc={user.avatarUrl} size="sm" className="mr-2" />,
|
||||
});
|
||||
|
||||
export const UserListInTeam = () => {
|
||||
export const UserListInTeam = ({
|
||||
showOnlyWhenSelectedInContext = true,
|
||||
}: {
|
||||
showOnlyWhenSelectedInContext?: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { filter, setConfigFilters } = useFilterContext();
|
||||
const { selectedFilter, selectedTeamId, selectedMemberUserId, isAll } = filter;
|
||||
@@ -23,7 +27,11 @@ export const UserListInTeam = () => {
|
||||
isAll: !!isAll,
|
||||
});
|
||||
|
||||
if (!selectedFilter?.includes("user") || !selectedTeamId || !isSuccess || data?.length === 0) {
|
||||
if (showOnlyWhenSelectedInContext && !selectedFilter?.includes("user")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!selectedTeamId || !isSuccess || data?.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -34,7 +42,7 @@ export const UserListInTeam = () => {
|
||||
title={t("people")}
|
||||
options={userListOptions}
|
||||
selectedValue={selectedMemberUserId}
|
||||
onChange={(value) => setConfigFilters({ selectedMemberUserId: Number(value) })}
|
||||
onChange={(value) => setConfigFilters({ selectedMemberUserId: value === null ? null : Number(value) })}
|
||||
buttonIcon={<Icon name="users" className="mr-2 h-4 w-4" />}
|
||||
placeholder={t("search")}
|
||||
testId="people-filter"
|
||||
@@ -10,7 +10,7 @@ import { FilterType } from "./FilterType";
|
||||
import { RoutingFormFieldFilter } from "./RoutingFormFieldFilter";
|
||||
import { RoutingFormFilterList } from "./RoutingFormFilterList";
|
||||
import { TeamAndSelfList } from "./TeamAndSelfList";
|
||||
import { UserListInTeam } from "./UsersListInTeam";
|
||||
import { UserListInTeam } from "./UserListInTeam";
|
||||
|
||||
const ClearFilters = () => {
|
||||
const { t } = useLocale();
|
||||
@@ -97,7 +97,7 @@ export const Filters = ({ showRoutingFilters = false }: { showRoutingFilters?: b
|
||||
</ButtonGroup> */}
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:flex-nowrap sm:justify-between">
|
||||
{showRoutingFilters ? <RoutingDownload /> : <Download />}
|
||||
<DateSelect />
|
||||
<DateSelect className="me-2 ms-2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { DataTableFilters } from "@calcom/features/data-table";
|
||||
import type { RoutingFormTableType } from "@calcom/features/insights/components/RoutingFormResponsesTable";
|
||||
import { useFilterContext } from "@calcom/features/insights/context/provider";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Icon, Tooltip } from "@calcom/ui";
|
||||
|
||||
import { BookingStatusFilter } from "../BookingStatusFilter";
|
||||
import { DateSelect } from "../DateSelect";
|
||||
import { RoutingDownload } from "../Download";
|
||||
import { FilterType } from "../FilterType";
|
||||
import { RoutingFormFieldFilter } from "../RoutingFormFieldFilter";
|
||||
import { RoutingFormFilterList } from "../RoutingFormFilterList";
|
||||
import { TeamAndSelfList } from "../TeamAndSelfList";
|
||||
import { UserListInTeam } from "../UsersListInTeam";
|
||||
|
||||
const ClearFilters = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter, clearFilters } = useFilterContext();
|
||||
const { selectedFilter } = filter;
|
||||
|
||||
if (!selectedFilter || selectedFilter?.length < 1) return null;
|
||||
|
||||
return (
|
||||
<Tooltip content={t("clear_filters")}>
|
||||
<Button
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="min-w-24 h-[38px] border-0"
|
||||
onClick={() => {
|
||||
clearFilters();
|
||||
}}>
|
||||
<Icon name="x" className="mr-1 h-4 w-4" />
|
||||
{t("clear")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const RoutingInsightsFilters = ({ table }: { table: RoutingFormTableType }) => {
|
||||
const { filter } = useFilterContext();
|
||||
const { selectedFilter } = filter;
|
||||
|
||||
// Get all filters that relate to the routing form
|
||||
const routingFormFieldIds = selectedFilter
|
||||
? selectedFilter.map((filter) => {
|
||||
if (filter.startsWith("rf_")) return filter.substring(3);
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex w-full flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-between">
|
||||
<div className="flex flex-wrap gap-2 sm:flex-row sm:justify-start">
|
||||
<TeamAndSelfList omitOrg={true} />
|
||||
|
||||
<UserListInTeam />
|
||||
|
||||
<RoutingFormFilterList />
|
||||
<BookingStatusFilter />
|
||||
{routingFormFieldIds.map((fieldId) => {
|
||||
if (fieldId) return <RoutingFormFieldFilter key={fieldId} fieldId={fieldId} />;
|
||||
})}
|
||||
|
||||
<FilterType showRoutingFilters={true} />
|
||||
|
||||
<ClearFilters />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:flex-nowrap sm:justify-between">
|
||||
<RoutingDownload />
|
||||
<DateSelect />
|
||||
<DataTableFilters.ColumnVisibilityButton table={table} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
routingFormResponseInDbSchema,
|
||||
} from "@calcom/app-store/routing-forms/zod";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { type ColumnFilter } from "@calcom/features/data-table";
|
||||
import { makeWhereClause } from "@calcom/features/data-table/lib/server";
|
||||
import { readonlyPrisma as prisma } from "@calcom/prisma";
|
||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -24,6 +26,7 @@ type RoutingFormInsightsFilter = RoutingFormInsightsTeamFilter & {
|
||||
fieldId: string;
|
||||
optionId: string;
|
||||
} | null;
|
||||
columnFilters: ColumnFilter[];
|
||||
};
|
||||
|
||||
class RoutingEventsInsights {
|
||||
@@ -81,7 +84,7 @@ class RoutingEventsInsights {
|
||||
userId,
|
||||
bookingStatus,
|
||||
fieldFilter,
|
||||
}: RoutingFormInsightsFilter) {
|
||||
}: Omit<RoutingFormInsightsFilter, "columnFilters">) {
|
||||
// Get team IDs based on organization if applicable
|
||||
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
|
||||
teamId,
|
||||
@@ -187,9 +190,8 @@ class RoutingEventsInsights {
|
||||
cursor,
|
||||
limit,
|
||||
userId,
|
||||
fieldFilter,
|
||||
bookingStatus,
|
||||
}: RoutingFormInsightsFilter & { cursor?: number; limit?: number }) {
|
||||
columnFilters,
|
||||
}: Omit<RoutingFormInsightsFilter, "fieldFilter" | "bookingStatus"> & { cursor?: number; limit?: number }) {
|
||||
const formsTeamWhereCondition = await this.getWhereForTeamOrAllTeams({
|
||||
teamId,
|
||||
isAll,
|
||||
@@ -197,6 +199,9 @@ class RoutingEventsInsights {
|
||||
routingFormId,
|
||||
});
|
||||
|
||||
const bookingStatusFilter = columnFilters.find((filter) => filter.id === "bookingStatus");
|
||||
const fieldFilters = columnFilters.filter((filter) => filter.id !== "bookingStatus");
|
||||
|
||||
const responsesWhereCondition: Prisma.App_RoutingForms_FormResponseWhereInput = {
|
||||
...(startDate &&
|
||||
endDate && {
|
||||
@@ -205,27 +210,31 @@ class RoutingEventsInsights {
|
||||
lte: dayjs(endDate).endOf("day").toDate(),
|
||||
},
|
||||
}),
|
||||
...(userId || bookingStatus
|
||||
...(userId || bookingStatusFilter
|
||||
? {
|
||||
...(bookingStatus === "NO_BOOKING"
|
||||
? { routedToBooking: null }
|
||||
: {
|
||||
routedToBooking: {
|
||||
...(userId && { userId }),
|
||||
...(bookingStatus && { status: bookingStatus }),
|
||||
},
|
||||
}),
|
||||
...{
|
||||
routedToBooking: {
|
||||
...(userId && { userId }),
|
||||
...(bookingStatusFilter &&
|
||||
makeWhereClause({ columnName: "status", filterValue: bookingStatusFilter.value })),
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(fieldFilter && {
|
||||
response: {
|
||||
path: [fieldFilter.fieldId, "value"],
|
||||
array_contains: [fieldFilter.optionId],
|
||||
},
|
||||
}),
|
||||
|
||||
form: formsTeamWhereCondition,
|
||||
};
|
||||
|
||||
if (fieldFilters.length > 0) {
|
||||
responsesWhereCondition.AND = fieldFilters.map((fieldFilter) => {
|
||||
return makeWhereClause({
|
||||
columnName: "response",
|
||||
filterValue: fieldFilter.value,
|
||||
json: { path: [fieldFilter.id, "value"] },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const totalResponsePromise = prisma.app_RoutingForms_FormResponse.count({
|
||||
where: responsesWhereCondition,
|
||||
});
|
||||
@@ -468,6 +477,7 @@ class RoutingEventsInsights {
|
||||
return {
|
||||
id: f.id,
|
||||
label: f.label,
|
||||
type: f.type,
|
||||
options: f.options,
|
||||
};
|
||||
});
|
||||
@@ -487,7 +497,7 @@ class RoutingEventsInsights {
|
||||
fieldFilter,
|
||||
take,
|
||||
skip,
|
||||
}: RoutingFormInsightsFilter & { take?: number; skip?: number }) {
|
||||
}: Omit<RoutingFormInsightsFilter, "columnFilters"> & { take?: number; skip?: number }) {
|
||||
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
|
||||
teamId,
|
||||
isAll,
|
||||
|
||||
@@ -3,6 +3,7 @@ import md5 from "md5";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { ZColumnFilter } from "@calcom/features/data-table";
|
||||
import { rawDataInputSchema } from "@calcom/features/insights/server/raw-data.schema";
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
import type { readonlyPrisma } from "@calcom/prisma";
|
||||
@@ -1596,13 +1597,7 @@ export const insightsRouter = router({
|
||||
routingFormId: z.string().optional(),
|
||||
cursor: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
bookingStatus: bookingStatusSchema,
|
||||
fieldFilter: z
|
||||
.object({
|
||||
fieldId: z.string(),
|
||||
optionId: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
columnFilters: z.array(ZColumnFilter),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
@@ -1617,8 +1612,7 @@ export const insightsRouter = router({
|
||||
cursor: input.cursor,
|
||||
userId: input.userId ?? null,
|
||||
limit: input.limit,
|
||||
bookingStatus: input.bookingStatus ?? null,
|
||||
fieldFilter: input.fieldFilter ?? null,
|
||||
columnFilters: input.columnFilters,
|
||||
});
|
||||
}),
|
||||
getRoutingFormFieldOptions: userBelongsToTeamProcedure
|
||||
@@ -1651,12 +1645,16 @@ export const insightsRouter = router({
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await RoutingEventsInsights.getRoutingFormHeaders({
|
||||
const fields = await RoutingEventsInsights.getRoutingFormHeaders({
|
||||
teamId: input.teamId ?? null,
|
||||
isAll: input.isAll,
|
||||
organizationId: ctx.user.organizationId ?? null,
|
||||
routingFormId: input.routingFormId ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
fields: fields || [],
|
||||
};
|
||||
}),
|
||||
rawRoutingData: userBelongsToTeamProcedure
|
||||
.input(
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
useFetchMoreOnBottomReached,
|
||||
textFilter,
|
||||
isTextFilterValue,
|
||||
isSelectFilterValue,
|
||||
selectFilter,
|
||||
} from "@calcom/features/data-table";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
@@ -174,7 +176,7 @@ export function UserListTable() {
|
||||
id: attribute.id,
|
||||
header: attribute.name,
|
||||
meta: {
|
||||
filterType: attribute.type.toLowerCase() === "text" ? "text" : "select",
|
||||
filter: { type: attribute.type.toLowerCase() === "text" ? "text" : "select" },
|
||||
},
|
||||
size: 120,
|
||||
accessorFn: (data) => data.attributes.find((attr) => attr.attributeId === attribute.id)?.value,
|
||||
@@ -198,10 +200,13 @@ export function UserListTable() {
|
||||
|
||||
if (isTextFilterValue(filterValue)) {
|
||||
return attributeValues.some((attr) => textFilter(attr.value, filterValue));
|
||||
} else if (isSelectFilterValue(filterValue)) {
|
||||
return selectFilter(
|
||||
attributeValues.map((attr) => attr.value),
|
||||
filterValue
|
||||
);
|
||||
}
|
||||
|
||||
if (attributeValues.length === 0) return false;
|
||||
return attributeValues.some((attr) => filterValue.includes(attr.value));
|
||||
return false;
|
||||
},
|
||||
})) as ColumnDef<UserTableUser>[]) ?? []
|
||||
);
|
||||
@@ -516,7 +521,7 @@ export function UserListTable() {
|
||||
{t("download")}
|
||||
</DataTableToolbar.CTA>
|
||||
{/* We have to omit member because we don't want the filter to show but we can't disable filtering as we need that for the search bar */}
|
||||
<DataTableFilters.FilterButton table={table} omit={["member"]} />
|
||||
<DataTableFilters.AddFilterButton table={table} omit={["member"]} />
|
||||
<DataTableFilters.ColumnVisibilityButton table={table} />
|
||||
{adminOrOwner && (
|
||||
<DataTableToolbar.CTA
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"seed-insights": "ts-node --transpile-only ./seed-insights.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "9.2.0",
|
||||
"npm-run-all": "^4.1.5"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import type { Prisma, UserPermissionRole } from "@prisma/client";
|
||||
import { randomUUID } from "crypto";
|
||||
import { uuid } from "short-uuid";
|
||||
@@ -440,9 +441,18 @@ export async function seedRoutingForms(
|
||||
value: "team/insights-team/team-sales",
|
||||
},
|
||||
],
|
||||
formFieldFilled: {
|
||||
formFieldSkills: {
|
||||
id: "83316968-45bf-4c9d-b5d4-5368a8d2d2a8",
|
||||
},
|
||||
formFieldEmail: {
|
||||
id: "dd28ffcf-7029-401e-bddb-ce2e7496a1c1",
|
||||
},
|
||||
formFieldManager: {
|
||||
id: "57734f65-8bbb-4065-9e71-fb7f0b7485f8",
|
||||
},
|
||||
formFieldRating: {
|
||||
id: "f4e9fa6c-5c5d-4d8e-b15c-7f37e9d0c729",
|
||||
},
|
||||
};
|
||||
|
||||
const form = await prisma.app_RoutingForms_Form.findUnique({
|
||||
@@ -538,7 +548,7 @@ export async function seedRoutingForms(
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
id: seededForm.formFieldFilled.id,
|
||||
id: seededForm.formFieldSkills.id,
|
||||
type: "multiselect",
|
||||
label: "skills",
|
||||
options: attributeRaw[2].options.map((opt) => ({
|
||||
@@ -547,6 +557,24 @@ export async function seedRoutingForms(
|
||||
})),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: seededForm.formFieldEmail.id,
|
||||
type: "email",
|
||||
label: "Email",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: seededForm.formFieldManager.id,
|
||||
type: "text",
|
||||
label: "Manager",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: seededForm.formFieldRating.id,
|
||||
type: "number",
|
||||
label: "Rating",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
team: {
|
||||
connect: {
|
||||
@@ -571,7 +599,16 @@ type SeededForm = {
|
||||
id: string;
|
||||
value: string;
|
||||
}[];
|
||||
formFieldFilled: {
|
||||
formFieldSkills: {
|
||||
id: string;
|
||||
};
|
||||
formFieldEmail: {
|
||||
id: string;
|
||||
};
|
||||
formFieldManager: {
|
||||
id: string;
|
||||
};
|
||||
formFieldRating: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
@@ -616,10 +653,22 @@ export async function seedRoutingFormResponses(
|
||||
formId: seededForm.id,
|
||||
formFillerId: randomUUID(),
|
||||
response: {
|
||||
[seededForm.formFieldFilled.id]: {
|
||||
[seededForm.formFieldSkills.id]: {
|
||||
label: "skills",
|
||||
value: selectedSkills.map((opt) => opt.id),
|
||||
},
|
||||
[seededForm.formFieldEmail.id]: {
|
||||
label: "Email",
|
||||
value: faker.internet.email(),
|
||||
},
|
||||
[seededForm.formFieldManager.id]: {
|
||||
label: "Manager",
|
||||
value: faker.person.fullName(),
|
||||
},
|
||||
[seededForm.formFieldRating.id]: {
|
||||
label: "Rating",
|
||||
value: Math.floor(Math.random() * 5) + 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -640,10 +689,22 @@ export async function seedRoutingFormResponses(
|
||||
formId: seededForm.id,
|
||||
formFillerId: randomUUID(),
|
||||
response: {
|
||||
[seededForm.formFieldFilled.id]: {
|
||||
[seededForm.formFieldSkills.id]: {
|
||||
label: "skills",
|
||||
value: selectedSkills.map((opt) => opt.id),
|
||||
},
|
||||
[seededForm.formFieldEmail.id]: {
|
||||
label: "Email",
|
||||
value: faker.internet.email(),
|
||||
},
|
||||
[seededForm.formFieldManager.id]: {
|
||||
label: "Manager",
|
||||
value: faker.person.fullName(),
|
||||
},
|
||||
[seededForm.formFieldRating.id]: {
|
||||
label: "Rating",
|
||||
value: Math.floor(Math.random() * 5) + 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -82,7 +82,7 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
|
||||
attribute: {
|
||||
id: filter.id,
|
||||
},
|
||||
...makeWhereClause("value", filter.value),
|
||||
...makeWhereClause({ columnName: "value", filterValue: filter.value }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Vendored
+3
-1
@@ -1,12 +1,14 @@
|
||||
import "@tanstack/react-table";
|
||||
|
||||
import type { ColumnFilterMeta } from "@calcom/features/data-table";
|
||||
|
||||
declare module "@tanstack/table-core" {
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
sticky?: {
|
||||
position: "left" | "right";
|
||||
gap?: number;
|
||||
};
|
||||
filterType?: "select" | "text";
|
||||
filter?: ColumnFilterMeta;
|
||||
|
||||
// `autoWidth` can make the column size dynamic,
|
||||
// allowing each row to have a different width based on its content.
|
||||
|
||||
@@ -4566,6 +4566,7 @@ __metadata:
|
||||
resolution: "@calcom/prisma@workspace:packages/prisma"
|
||||
dependencies:
|
||||
"@calcom/lib": "*"
|
||||
"@faker-js/faker": 9.2.0
|
||||
"@prisma/client": ^5.4.2
|
||||
"@prisma/extension-accelerate": ^0.6.2
|
||||
"@prisma/generator-helper": ^5.4.2
|
||||
@@ -6311,6 +6312,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@faker-js/faker@npm:9.2.0":
|
||||
version: 9.2.0
|
||||
resolution: "@faker-js/faker@npm:9.2.0"
|
||||
checksum: 7cbff7363f198c24aa1a25499f200a25506f80df577a419ae878b6bdd4aa901ecfe071ae3bbecf8afafa5542c825623d55f5884d44db7b12420c898241332cd1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@faker-js/faker@npm:^7.3.0":
|
||||
version: 7.4.0
|
||||
resolution: "@faker-js/faker@npm:7.4.0"
|
||||
|
||||
Reference in New Issue
Block a user