feat: add text & numeric filters to insights routing responses (#18016)
* 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 * feat: support numeric filter WIP * feat: support numeric filter * feat: move People to an external filter * fixing query states * rename state to activeFilters * add useDataTable * fix ResponseCellValue bug and enable resizing for RoutingFormResponsesTable * fix response values * truncate attribute text * seed booking attendees * fix accessor (no actual change though) * add a gap * rename * use pathname as default identifier for DataTable * fix type error * support text filter for assignment reason * Apply suggestions from code review Co-authored-by: Alex van Andel <me@alexvanandel.com> * rename fields to headers * use safeParse * fix type error * push yarn.lock with faker * fix MemberList --------- Co-authored-by: Udit Takkar <udit222001@gmail.com> Co-authored-by: Alex van Andel <me@alexvanandel.com> Co-authored-by: sean-brydon <sean@cal.com>
This commit is contained in:
co-authored by
Alex van Andel
Udit Takkar
sean-brydon
parent
e366c10521
commit
a7e7561efa
@@ -1,35 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
FailedBookingsByField,
|
||||
RoutingFormResponsesTable,
|
||||
RoutingKPICards,
|
||||
RoutedToPerPeriod,
|
||||
} from "@calcom/features/insights/components";
|
||||
import { FailedBookingsByField, RoutingFormResponsesTable, RoutedToPerPeriod } from "@calcom/features/insights/components";
|
||||
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
|
||||
import { RoutingInsightsFilters } from "@calcom/features/insights/filters/routing/FilterBar";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import InsightsLayout from "./layout";
|
||||
|
||||
export default function 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 />
|
||||
|
||||
<RoutedToPerPeriod />
|
||||
|
||||
|
||||
@@ -51,6 +51,14 @@
|
||||
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" ></path>
|
||||
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" ></path>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-binary" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="binary">
|
||||
<rect x="14" y="14" width="4" height="6" rx="2" ></rect>
|
||||
<rect x="6" y="4" width="4" height="6" rx="2" ></rect>
|
||||
<path d="M6 20h4" ></path>
|
||||
<path d="M14 10h4" ></path>
|
||||
<path d="M6 14h2v6" ></path>
|
||||
<path d="M14 4h2v6" ></path>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-blocks" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="blocks">
|
||||
<rect width="7" height="7" x="14" y="3" rx="1" ></rect>
|
||||
<path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3" ></path>
|
||||
|
||||
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
@@ -980,7 +980,7 @@ export default class EventManager {
|
||||
const createdEvent = await crm.createEvent(event, currentAppOption).catch((error) => {
|
||||
success = false;
|
||||
// We don't know the type of the error here, so for an Error instance we can read message but otherwise we stringify the error
|
||||
let errorMsg = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
const errorMsg = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
log.warn(`Error creating crm event for ${credential.type}`, errorMsg);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Table as ReactTableType } from "@tanstack/react-table";
|
||||
import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import kebabCase from "lodash/kebabCase";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo, useEffect, memo } from "react";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
@@ -23,8 +24,10 @@ export interface DataTableProps<TData, TValue> {
|
||||
variant?: "default" | "compact";
|
||||
"data-testid"?: string;
|
||||
children?: React.ReactNode;
|
||||
enableColumnResizing?: { name: string };
|
||||
identifier?: string;
|
||||
enableColumnResizing?: boolean;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
table,
|
||||
tableContainerRef,
|
||||
@@ -33,9 +36,13 @@ export function DataTable<TData, TValue>({
|
||||
onRowMouseclick,
|
||||
onScroll,
|
||||
children,
|
||||
identifier: _identifier,
|
||||
enableColumnResizing,
|
||||
...rest
|
||||
}: DataTableProps<TData, TValue> & React.ComponentPropsWithoutRef<"div">) {
|
||||
const pathname = usePathname();
|
||||
const identifier = _identifier ?? pathname;
|
||||
|
||||
const { rows } = table.getRowModel();
|
||||
|
||||
// https://stackblitz.com/github/tanstack/table/tree/main/examples/react/virtualized-infinite-scrolling
|
||||
@@ -82,7 +89,7 @@ export function DataTable<TData, TValue>({
|
||||
usePersistentColumnResizing({
|
||||
enabled: Boolean(enableColumnResizing),
|
||||
table,
|
||||
name: enableColumnResizing?.name,
|
||||
identifier,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,58 +1,21 @@
|
||||
import { type Table } from "@tanstack/react-table";
|
||||
"use client";
|
||||
|
||||
import type { FilterableColumn, FilterValue, SelectFilterValue, TextFilterValue } from "../../lib/types";
|
||||
import type { ActiveFilter, FiltersSearchState, SetFiltersSearchState } from "../../lib/utils";
|
||||
import type { FilterableColumn } from "../../lib/types";
|
||||
import { MultiSelectFilterOptions } from "./MultiSelectFilterOptions";
|
||||
import { NumberFilterOptions } from "./NumberFilterOptions";
|
||||
import { TextFilterOptions } from "./TextFilterOptions";
|
||||
|
||||
export type FilterOptionsProps<TData> = {
|
||||
export type FilterOptionsProps = {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
export function FilterOptions({ column }: FilterOptionsProps) {
|
||||
if (column.type === "text") {
|
||||
return <TextFilterOptions column={column} />;
|
||||
} else if (column.type === "select") {
|
||||
return <MultiSelectFilterOptions column={column} />;
|
||||
} else if (column.type === "number") {
|
||||
return <NumberFilterOptions column={column} />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import {
|
||||
@@ -12,22 +14,18 @@ import {
|
||||
Icon,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import type { FilterableColumn, SelectFilterValue } from "../../lib/types";
|
||||
import type { FilterableColumn } from "../../lib/types";
|
||||
import { ZSelectFilterValue } from "../../lib/types";
|
||||
import { useDataTable, useFilterValue } from "../../lib/utils";
|
||||
|
||||
export type MultiSelectFilterOptionsProps = {
|
||||
column: FilterableColumn;
|
||||
filterValue?: SelectFilterValue;
|
||||
setFilterValue: (value: SelectFilterValue) => void;
|
||||
removeFilter: (columnId: string) => void;
|
||||
column: Extract<FilterableColumn, { type: "select" }>;
|
||||
};
|
||||
|
||||
export function MultiSelectFilterOptions({
|
||||
column,
|
||||
filterValue,
|
||||
setFilterValue,
|
||||
removeFilter,
|
||||
}: MultiSelectFilterOptionsProps) {
|
||||
export function MultiSelectFilterOptions({ column }: MultiSelectFilterOptionsProps) {
|
||||
const { t } = useLocale();
|
||||
const filterValue = useFilterValue(column.id, ZSelectFilterValue);
|
||||
const { updateFilter, removeFilter } = useDataTable();
|
||||
|
||||
return (
|
||||
<Command>
|
||||
@@ -36,27 +34,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];
|
||||
setFilterValue(newFilterValue);
|
||||
const newFilterValue = filterValue?.includes(optionValue)
|
||||
? filterValue?.filter((value) => value !== optionValue)
|
||||
: [...(filterValue || []), optionValue];
|
||||
updateFilter(column.id, 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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Form, Input, Select, Button } from "@calcom/ui";
|
||||
|
||||
import type { FilterableColumn, NumberFilterOperator } from "../../lib/types";
|
||||
import { ZNumberFilterValue } from "../../lib/types";
|
||||
import { useFilterValue, useDataTable } from "../../lib/utils";
|
||||
|
||||
export type NumberFilterOperatorOption = {
|
||||
label: string;
|
||||
value: NumberFilterOperator;
|
||||
};
|
||||
|
||||
const numberFilterOperatorOptions: NumberFilterOperatorOption[] = [
|
||||
{ value: "eq", label: "=" },
|
||||
{ value: "neq", label: "≠" },
|
||||
{ value: "gt", label: ">" },
|
||||
{ value: "gte", label: "≥" },
|
||||
{ value: "lt", label: "<" },
|
||||
{ value: "lte", label: "≤" },
|
||||
];
|
||||
|
||||
export type NumberFilterOptionsProps = {
|
||||
column: Extract<FilterableColumn, { type: "number" }>;
|
||||
};
|
||||
|
||||
export function NumberFilterOptions({ column }: NumberFilterOptionsProps) {
|
||||
const { t } = useLocale();
|
||||
const filterValue = useFilterValue(column.id, ZNumberFilterValue);
|
||||
const { updateFilter, removeFilter } = useDataTable();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
operatorOption: filterValue
|
||||
? numberFilterOperatorOptions.find((o) => o.value === filterValue.data.operator)
|
||||
: numberFilterOperatorOptions[0],
|
||||
operand: filterValue?.data.operand || "",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-3 my-2">
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={({ operatorOption, operand }) => {
|
||||
if (operatorOption) {
|
||||
updateFilter(column.id, {
|
||||
type: "number",
|
||||
data: {
|
||||
operator: operatorOption.value,
|
||||
operand: Number(operand),
|
||||
},
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<div>
|
||||
<Controller
|
||||
name="operatorOption"
|
||||
control={form.control}
|
||||
render={({ field: { value } }) => (
|
||||
<div className="-mt-2 flex items-center gap-2">
|
||||
<Select
|
||||
className="basis-1/3"
|
||||
options={numberFilterOperatorOptions}
|
||||
value={value}
|
||||
isSearchable={false}
|
||||
onChange={(event) => {
|
||||
if (event) {
|
||||
form.setValue("operatorOption", { ...event }, { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Input type="number" className="mt-2 basis-2/3" {...form.register("operand")} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Form, Input, Select, Button } from "@calcom/ui";
|
||||
|
||||
import type { FilterableColumn, TextFilterValue, TextFilterOperator } from "../../lib/types";
|
||||
import type { FilterableColumn, TextFilterOperator } from "../../lib/types";
|
||||
import { ZTextFilterValue } from "../../lib/types";
|
||||
import { useFilterValue, useDataTable } from "../../lib/utils";
|
||||
|
||||
export type TextFilterOperatorOption = {
|
||||
label: string;
|
||||
@@ -26,20 +30,14 @@ const useTextFilterOperatorOptions = (): TextFilterOperatorOption[] => {
|
||||
};
|
||||
|
||||
export type TextFilterOptionsProps = {
|
||||
column: FilterableColumn;
|
||||
filterValue?: TextFilterValue;
|
||||
setFilterValue: (value: TextFilterValue) => void;
|
||||
removeFilter: (columnId: string) => void;
|
||||
column: Extract<FilterableColumn, { type: "text" }>;
|
||||
};
|
||||
|
||||
export function TextFilterOptions({
|
||||
column,
|
||||
filterValue,
|
||||
setFilterValue,
|
||||
removeFilter,
|
||||
}: TextFilterOptionsProps) {
|
||||
export function TextFilterOptions({ column }: TextFilterOptionsProps) {
|
||||
const { t } = useLocale();
|
||||
const textFilterOperatorOptions = useTextFilterOperatorOptions();
|
||||
const filterValue = useFilterValue(column.id, ZTextFilterValue);
|
||||
const { updateFilter, removeFilter } = useDataTable();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
@@ -56,7 +54,7 @@ export function TextFilterOptions({
|
||||
form={form}
|
||||
handleSubmit={({ operatorOption, operand }) => {
|
||||
if (operatorOption) {
|
||||
setFilterValue({
|
||||
updateFilter(column.id, {
|
||||
type: "text",
|
||||
data: {
|
||||
operator: operatorOption.value,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { type Table } from "@tanstack/react-table";
|
||||
import { forwardRef, useState, useMemo } from "react";
|
||||
import { forwardRef, useState, useMemo, useCallback, Fragment } from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -22,8 +22,8 @@ import {
|
||||
Icon,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import type { FilterableColumn } from "../../lib/types";
|
||||
import { useFiltersSearchState } from "../../lib/utils";
|
||||
import type { FilterableColumn, ExternalFilter } from "../../lib/types";
|
||||
import { convertToTitleCase, useDataTable } from "../../lib/utils";
|
||||
import { FilterOptions } from "./FilterOptions";
|
||||
|
||||
interface ColumnVisiblityProps<TData> {
|
||||
@@ -118,37 +118,30 @@ const ColumnVisibilityButton = forwardRef(ColumnVisibilityButtonComponent) as <T
|
||||
) => ReturnType<typeof ColumnVisibilityButtonComponent>;
|
||||
|
||||
// Filters
|
||||
interface FilterButtonProps<TData> {
|
||||
interface AddFilterButtonProps<TData> {
|
||||
table: Table<TData>;
|
||||
omit?: string[];
|
||||
externalFilters?: ExternalFilter[];
|
||||
}
|
||||
|
||||
function FilterButtonComponent<TData>(
|
||||
{ table, omit }: FilterButtonProps<TData>,
|
||||
function AddFilterButtonComponent<TData>(
|
||||
{ table, omit, externalFilters }: AddFilterButtonProps<TData>,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) {
|
||||
const { t } = useLocale();
|
||||
const [_state, _setState] = useFiltersSearchState();
|
||||
const { activeFilters, setActiveFilters, displayedExternalFilters, setDisplayedExternalFilters } =
|
||||
useDataTable();
|
||||
|
||||
const activeFilters = _state.activeFilters;
|
||||
const columns = table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanFilter())
|
||||
.filter((column) => !omit?.includes(column.id));
|
||||
const filterableColumns = useFilterableColumns(table, omit);
|
||||
|
||||
const filterableColumns = useMemo(() => {
|
||||
return columns.map((column) => ({
|
||||
id: column.id,
|
||||
title: typeof column.columnDef.header === "string" ? column.columnDef.header : column.id,
|
||||
options: column.getFacetedUniqueValues(),
|
||||
}));
|
||||
}, [columns]);
|
||||
|
||||
const handleAddFilter = (columnId: string) => {
|
||||
if (!activeFilters?.some((filter) => filter.f === columnId)) {
|
||||
_setState({ activeFilters: [...activeFilters, { f: columnId, v: [] }] });
|
||||
}
|
||||
};
|
||||
const handleAddFilter = useCallback(
|
||||
(columnId: string) => {
|
||||
if (!activeFilters?.some((filter) => filter.f === columnId)) {
|
||||
setActiveFilters([...activeFilters, { f: columnId, v: undefined }]);
|
||||
}
|
||||
},
|
||||
[activeFilters, setActiveFilters]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -167,11 +160,24 @@ 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>
|
||||
);
|
||||
})}
|
||||
{(externalFilters || [])
|
||||
.filter((filter) => !displayedExternalFilters.includes(filter.key))
|
||||
.map((filter, index) => (
|
||||
<CommandItem
|
||||
key={index}
|
||||
onSelect={() => setDisplayedExternalFilters((prev) => [...prev, filter.key])}
|
||||
className="px-4 py-2">
|
||||
{t(filter.titleKey)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
@@ -180,59 +186,94 @@ 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 {
|
||||
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> {
|
||||
table: Table<TData>;
|
||||
externalFilters?: ExternalFilter[];
|
||||
}
|
||||
|
||||
function ActiveFilters<TData>({ table }: ActiveFiltersProps<TData>) {
|
||||
const [_state, _setState] = useFiltersSearchState();
|
||||
const filterIcons = {
|
||||
text: "file-text",
|
||||
number: "binary",
|
||||
select: "layers",
|
||||
} as const;
|
||||
|
||||
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]);
|
||||
function ActiveFilters<TData>({ table, externalFilters }: ActiveFiltersProps<TData>) {
|
||||
const { activeFilters, displayedExternalFilters } = useDataTable();
|
||||
const filterableColumns = useFilterableColumns(table);
|
||||
|
||||
return (
|
||||
<>
|
||||
{(_state.activeFilters || []).map((filter) => {
|
||||
{activeFilters.map((filter) => {
|
||||
const column = filterableColumns.find((col) => col.id === filter.f);
|
||||
if (!column) return null;
|
||||
const icon = column.icon || filterIcons[column.type];
|
||||
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>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<FilterOptions
|
||||
column={column}
|
||||
filter={filter}
|
||||
state={_state}
|
||||
setState={_setState}
|
||||
table={table}
|
||||
/>
|
||||
<FilterOptions column={column} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
})}
|
||||
{(displayedExternalFilters || []).map((key) => {
|
||||
const filter = externalFilters?.find((filter) => filter.key === key);
|
||||
if (!filter) return null;
|
||||
return <Fragment key={key}>{filter.component()}</Fragment>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Update the export to include ActiveFilters
|
||||
export const DataTableFilters = { ColumnVisibilityButton, FilterButton, ActiveFilters };
|
||||
export const DataTableFilters = { ColumnVisibilityButton, AddFilterButton, ActiveFilters };
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./components";
|
||||
export * from "./lib/types";
|
||||
export * from "./lib/utils";
|
||||
export * from "./lib/context";
|
||||
export * from "./lib/resizing";
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useQueryState, parseAsArrayOf, parseAsJson } from "nuqs";
|
||||
import { createContext, useCallback, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { type FilterValue, ZFilterValue } from "./types";
|
||||
|
||||
const dataTableFiltersSchema = z.object({
|
||||
f: z.string(),
|
||||
v: ZFilterValue.optional(),
|
||||
});
|
||||
|
||||
type ActiveFilter = z.infer<typeof dataTableFiltersSchema>;
|
||||
|
||||
export type DataTableContextType = {
|
||||
activeFilters: ActiveFilter[];
|
||||
setActiveFilters: (filters: ActiveFilter[]) => void;
|
||||
clearAll: () => void;
|
||||
updateFilter: (columnId: string, value: FilterValue) => void;
|
||||
removeFilter: (columnId: string) => void;
|
||||
|
||||
displayedExternalFilters: string[];
|
||||
setDisplayedExternalFilters: Dispatch<SetStateAction<string[]>>;
|
||||
removeDisplayedExternalFilter: (key: string) => void;
|
||||
};
|
||||
|
||||
export const DataTableContext = createContext<DataTableContextType | null>(null);
|
||||
|
||||
export function DataTableProvider({ children }: { children: React.ReactNode }) {
|
||||
const [activeFilters, setActiveFilters] = useQueryState(
|
||||
"activeFilters",
|
||||
parseAsArrayOf(parseAsJson(dataTableFiltersSchema.parse)).withDefault([])
|
||||
);
|
||||
|
||||
const [displayedExternalFilters, setDisplayedExternalFilters] = useState<string[]>([]);
|
||||
|
||||
const removeDisplayedExternalFilter = useCallback(
|
||||
(key: string) => {
|
||||
setDisplayedExternalFilters((prev) => prev.filter((f) => f !== key));
|
||||
},
|
||||
[setDisplayedExternalFilters]
|
||||
);
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
setActiveFilters([]);
|
||||
}, [setActiveFilters]);
|
||||
|
||||
const updateFilter = useCallback(
|
||||
(columnId: string, value: FilterValue) => {
|
||||
setActiveFilters((prev) => {
|
||||
return prev.map((item) => (item.f === columnId ? { ...item, v: value } : item));
|
||||
});
|
||||
},
|
||||
[setActiveFilters]
|
||||
);
|
||||
|
||||
const removeFilter = useCallback(
|
||||
(columnId: string) => {
|
||||
setActiveFilters((prev) => prev.filter((filter) => filter.f !== columnId));
|
||||
},
|
||||
[setActiveFilters]
|
||||
);
|
||||
|
||||
return (
|
||||
<DataTableContext.Provider
|
||||
value={{
|
||||
activeFilters,
|
||||
setActiveFilters,
|
||||
clearAll,
|
||||
updateFilter,
|
||||
removeFilter,
|
||||
displayedExternalFilters,
|
||||
setDisplayedExternalFilters,
|
||||
removeDisplayedExternalFilter,
|
||||
}}>
|
||||
{children}
|
||||
</DataTableContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -6,24 +6,24 @@ import { useState, useCallback, useEffect } from "react";
|
||||
type UsePersistentColumnResizingProps<TData> = {
|
||||
enabled: boolean;
|
||||
table: Table<TData>;
|
||||
name?: string;
|
||||
identifier?: string;
|
||||
};
|
||||
|
||||
function getLocalStorageKey(name: string) {
|
||||
return `data-table-column-sizing-${name}`;
|
||||
function getLocalStorageKey(identifier: string) {
|
||||
return `data-table-column-sizing-${identifier}`;
|
||||
}
|
||||
|
||||
function loadColumnSizing(name: string) {
|
||||
function loadColumnSizing(identifier: string) {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(getLocalStorageKey(name)) || "{}");
|
||||
return JSON.parse(localStorage.getItem(getLocalStorageKey(identifier)) || "{}");
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function saveColumnSizing(name: string, columnSizing: ColumnSizingState) {
|
||||
localStorage.setItem(getLocalStorageKey(name), JSON.stringify(columnSizing));
|
||||
function saveColumnSizing(identifier: string, columnSizing: ColumnSizingState) {
|
||||
localStorage.setItem(getLocalStorageKey(identifier), JSON.stringify(columnSizing));
|
||||
}
|
||||
|
||||
const debouncedSaveColumnSizing = debounce(saveColumnSizing, 1000);
|
||||
@@ -31,19 +31,19 @@ const debouncedSaveColumnSizing = debounce(saveColumnSizing, 1000);
|
||||
export function usePersistentColumnResizing<TData>({
|
||||
enabled,
|
||||
table,
|
||||
name,
|
||||
identifier,
|
||||
}: UsePersistentColumnResizingProps<TData>) {
|
||||
const [_, setColumnSizing] = useState<ColumnSizingState>({});
|
||||
|
||||
const onColumnSizingChange = useCallback(
|
||||
(updater: ColumnSizingState | ((old: ColumnSizingState) => ColumnSizingState)) => {
|
||||
// `!name` is checked already in the `useEffect` hook,
|
||||
// `!identifier` is checked already in the `useEffect` hook,
|
||||
// but TS doesn't know that, and this won't happen.
|
||||
if (!name) return;
|
||||
if (!identifier) return;
|
||||
|
||||
table.setState((oldTableState) => {
|
||||
const newColumnSizing = typeof updater === "function" ? updater(oldTableState.columnSizing) : updater;
|
||||
debouncedSaveColumnSizing(name, newColumnSizing);
|
||||
debouncedSaveColumnSizing(identifier, newColumnSizing);
|
||||
setColumnSizing(newColumnSizing);
|
||||
|
||||
return {
|
||||
@@ -52,13 +52,13 @@ export function usePersistentColumnResizing<TData>({
|
||||
};
|
||||
});
|
||||
},
|
||||
[name, table]
|
||||
[identifier, table]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !name) return;
|
||||
if (!enabled || !identifier) return;
|
||||
|
||||
const newColumnSizing = loadColumnSizing(name);
|
||||
const newColumnSizing = loadColumnSizing(identifier);
|
||||
setColumnSizing(newColumnSizing);
|
||||
table.setState((old) => ({
|
||||
...old,
|
||||
@@ -69,5 +69,5 @@ export function usePersistentColumnResizing<TData>({
|
||||
columnResizeMode: "onChange",
|
||||
onColumnSizingChange,
|
||||
}));
|
||||
}, [enabled, name, table, onColumnSizingChange]);
|
||||
}, [enabled, identifier, table, onColumnSizingChange]);
|
||||
}
|
||||
|
||||
@@ -1,62 +1,76 @@
|
||||
import type { FilterValue } from "./types";
|
||||
import { isSelectFilterValue, isTextFilterValue } from "./utils";
|
||||
import { isSelectFilterValue, isTextFilterValue, isNumberFilterValue } 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,10 +78,61 @@ export function makeWhereClause(columnName: string, filterValue: FilterValue) {
|
||||
return {
|
||||
NOT: {
|
||||
[columnName]: {
|
||||
...jsonPathObj,
|
||||
equals: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
default:
|
||||
throw new Error(`Invalid operator for text filter: ${operator}`);
|
||||
}
|
||||
} else if (isNumberFilterValue(filterValue)) {
|
||||
const { operator, operand } = filterValue.data;
|
||||
switch (operator) {
|
||||
case "eq":
|
||||
return {
|
||||
[columnName]: {
|
||||
...jsonPathObj,
|
||||
equals: operand,
|
||||
},
|
||||
};
|
||||
case "neq":
|
||||
return {
|
||||
[columnName]: {
|
||||
...jsonPathObj,
|
||||
not: operand,
|
||||
},
|
||||
};
|
||||
case "gt":
|
||||
return {
|
||||
[columnName]: {
|
||||
...jsonPathObj,
|
||||
gt: operand,
|
||||
},
|
||||
};
|
||||
case "gte":
|
||||
return {
|
||||
[columnName]: {
|
||||
...jsonPathObj,
|
||||
gte: operand,
|
||||
},
|
||||
};
|
||||
case "lt":
|
||||
return {
|
||||
[columnName]: {
|
||||
...jsonPathObj,
|
||||
lt: operand,
|
||||
},
|
||||
};
|
||||
case "lte":
|
||||
return {
|
||||
[columnName]: {
|
||||
...jsonPathObj,
|
||||
lte: operand,
|
||||
},
|
||||
};
|
||||
default:
|
||||
throw new Error(`Invalid operator for number filter: ${operator}`);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { IconName } from "@calcom/ui";
|
||||
|
||||
export const ZTextFilterOperator = z.enum([
|
||||
"equals",
|
||||
"notEquals",
|
||||
@@ -27,13 +29,77 @@ export const ZTextFilterValue = z.object({
|
||||
|
||||
export type TextFilterValue = z.infer<typeof ZTextFilterValue>;
|
||||
|
||||
export const ZFilterValue = z.union([ZSelectFilterValue, ZTextFilterValue]);
|
||||
export const ZNumberFilterOperator = z.enum([
|
||||
"eq", // =
|
||||
"neq", // !=
|
||||
"gt", // >
|
||||
"gte", // >=
|
||||
"lt", // <
|
||||
"lte", // <=
|
||||
]);
|
||||
|
||||
export type NumberFilterOperator = z.infer<typeof ZNumberFilterOperator>;
|
||||
|
||||
export const ZNumberFilterValue = z.object({
|
||||
type: z.literal("number"),
|
||||
data: z.object({
|
||||
operator: ZNumberFilterOperator,
|
||||
operand: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type NumberFilterValue = z.infer<typeof ZNumberFilterValue>;
|
||||
|
||||
export const ZFilterValue = z.union([ZSelectFilterValue, ZTextFilterValue, ZNumberFilterValue]);
|
||||
|
||||
export type FilterValue = z.infer<typeof ZFilterValue>;
|
||||
|
||||
export type ColumnFilterType = "select" | "text" | "number";
|
||||
|
||||
export type ColumnFilterMeta = {
|
||||
type?: ColumnFilterType;
|
||||
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;
|
||||
}
|
||||
| {
|
||||
type: "number";
|
||||
icon?: IconName;
|
||||
}
|
||||
);
|
||||
|
||||
export const ZColumnFilter = z.object({
|
||||
id: z.string(),
|
||||
value: ZFilterValue,
|
||||
});
|
||||
|
||||
export type ColumnFilter = z.infer<typeof ZColumnFilter>;
|
||||
|
||||
export type TypedColumnFilter<T extends ColumnFilterType> = {
|
||||
id: string;
|
||||
value: T extends "text"
|
||||
? TextFilterValue
|
||||
: T extends "number"
|
||||
? NumberFilterValue
|
||||
: T extends "select"
|
||||
? SelectFilterValue
|
||||
: never;
|
||||
};
|
||||
|
||||
export type ExternalFilter = {
|
||||
key: string;
|
||||
titleKey: string;
|
||||
component: () => React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -1,51 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { parseAsArrayOf, parseAsJson, useQueryStates } from "nuqs";
|
||||
import { useMemo } from "react";
|
||||
import { z } from "zod";
|
||||
import { useMemo, useContext } from "react";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { SelectFilterValue, TextFilterValue } from "./types";
|
||||
import { ZSelectFilterValue, ZTextFilterValue } from "./types";
|
||||
import { DataTableContext } from "./context";
|
||||
import type {
|
||||
SelectFilterValue,
|
||||
TextFilterValue,
|
||||
FilterValue,
|
||||
NumberFilterValue,
|
||||
ColumnFilter,
|
||||
} from "./types";
|
||||
import { ZFilterValue, ZNumberFilterValue, 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 useDataTable() {
|
||||
const context = useContext(DataTableContext);
|
||||
if (!context) {
|
||||
throw new Error("useDataTable must be used within a DataTableProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useColumnFilters() {
|
||||
const [filtersSearchState] = useFiltersSearchState();
|
||||
const columnFilters = useMemo(() => {
|
||||
return (filtersSearchState.activeFilters || [])
|
||||
.map((filter) => ({
|
||||
id: filter.f,
|
||||
value: filter.v,
|
||||
}))
|
||||
export function useFilterValue<T>(columnId: string, schema: z.ZodType<T>) {
|
||||
const { activeFilters } = useDataTable();
|
||||
return useMemo(() => {
|
||||
const value = activeFilters.find((filter) => filter.f === columnId)?.v;
|
||||
if (schema && value) {
|
||||
const result = schema.safeParse(value);
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}, [activeFilters, columnId, schema]);
|
||||
}
|
||||
|
||||
export function useColumnFilters(): ColumnFilter[] {
|
||||
const { activeFilters } = useDataTable();
|
||||
return useMemo(() => {
|
||||
return (activeFilters || [])
|
||||
.filter(
|
||||
(filter) =>
|
||||
typeof filter === "object" && filter && "f" in filter && "v" in filter && filter.v !== undefined
|
||||
)
|
||||
.map((filter) => {
|
||||
const parsedValue = ZFilterValue.safeParse(filter.v);
|
||||
if (!parsedValue.success) return null;
|
||||
return {
|
||||
id: filter.f,
|
||||
value: parsedValue.data,
|
||||
};
|
||||
})
|
||||
.filter((filter): filter is ColumnFilter => filter !== null)
|
||||
.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) {
|
||||
// Otherwise, `{ my_column_name: { in: []} }` would result in nothing being returned.
|
||||
if (isSelectFilterValue(filter.value) && filter.value.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [filtersSearchState.activeFilters]);
|
||||
return columnFilters;
|
||||
}, [activeFilters]);
|
||||
}
|
||||
|
||||
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 (filterValue.data.operator === "isEmpty" && cellValue === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -69,14 +96,60 @@ export const textFilter = (cellValue: string, filterValue: TextFilterValue) => {
|
||||
};
|
||||
|
||||
export const isTextFilterValue = (filterValue: unknown): filterValue is TextFilterValue => {
|
||||
return (
|
||||
typeof filterValue === "object" &&
|
||||
filterValue !== null &&
|
||||
"type" in filterValue &&
|
||||
filterValue.type === "text"
|
||||
);
|
||||
return ZTextFilterValue.safeParse(filterValue).success;
|
||||
};
|
||||
|
||||
export const selectFilter = (cellValue: unknown | undefined, filterValue: SelectFilterValue) => {
|
||||
const cellValueArray = Array.isArray(cellValue) ? cellValue : [cellValue];
|
||||
if (!cellValueArray.every((value) => typeof value === "string")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return filterValue.length === 0 ? true : cellValueArray.some((v) => filterValue.includes(v));
|
||||
};
|
||||
|
||||
export const isSelectFilterValue = (filterValue: unknown): filterValue is SelectFilterValue => {
|
||||
return Array.isArray(filterValue) && filterValue.every((item) => typeof item === "string");
|
||||
return ZSelectFilterValue.safeParse(filterValue).success;
|
||||
};
|
||||
|
||||
export const numberFilter = (cellValue: unknown, filterValue: NumberFilterValue) => {
|
||||
if (typeof cellValue !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (filterValue.data.operator) {
|
||||
case "eq":
|
||||
return cellValue === filterValue.data.operand;
|
||||
case "neq":
|
||||
return cellValue !== filterValue.data.operand;
|
||||
case "gt":
|
||||
return cellValue > filterValue.data.operand;
|
||||
case "gte":
|
||||
return cellValue >= filterValue.data.operand;
|
||||
case "lt":
|
||||
return cellValue < filterValue.data.operand;
|
||||
case "lte":
|
||||
return cellValue <= filterValue.data.operand;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isNumberFilterValue = (filterValue: unknown): filterValue is NumberFilterValue => {
|
||||
return ZNumberFilterValue.safeParse(filterValue).success;
|
||||
};
|
||||
|
||||
export const dataTableFilter = (cellValue: unknown, filterValue: FilterValue) => {
|
||||
if (isSelectFilterValue(filterValue)) {
|
||||
return selectFilter(cellValue, filterValue);
|
||||
} else if (isTextFilterValue(filterValue)) {
|
||||
return textFilter(cellValue, filterValue);
|
||||
} else if (isNumberFilterValue(filterValue)) {
|
||||
return numberFilter(cellValue, filterValue);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const convertToTitleCase = (str: string) => {
|
||||
return str.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import type { ColumnFiltersState } from "@tanstack/react-table";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
@@ -19,10 +18,12 @@ import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import {
|
||||
DataTable,
|
||||
DataTableProvider,
|
||||
DataTableToolbar,
|
||||
DataTableFilters,
|
||||
DataTableSelectionBar,
|
||||
useFetchMoreOnBottomReached,
|
||||
useColumnFilters,
|
||||
} from "@calcom/features/data-table";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { DynamicLink } from "@calcom/features/users/components/UserTable/BulkActions/DynamicLink";
|
||||
@@ -150,6 +151,14 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function MemberList(props: Props) {
|
||||
return (
|
||||
<DataTableProvider>
|
||||
<MemberListContent {...props} />
|
||||
</DataTableProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberListContent(props: Props) {
|
||||
const [dynamicLinkVisible, setDynamicLinkVisible] = useQueryState("dynamicLink", parseAsBoolean);
|
||||
const { t, i18n } = useLocale();
|
||||
const { data: session } = useSession();
|
||||
@@ -168,6 +177,8 @@ export default function MemberList(props: Props) {
|
||||
limit: 10,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
teamId: props.team.id,
|
||||
// TODO: send `columnFilters` to server for server side filtering
|
||||
// filters: columnFilters,
|
||||
},
|
||||
{
|
||||
enabled: !!props.team.id,
|
||||
@@ -179,8 +190,7 @@ export default function MemberList(props: Props) {
|
||||
}
|
||||
);
|
||||
|
||||
// TODO (SEAN): Make Column filters a trpc query param so we can fetch serverside even if the data is not loaded
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const columnFilters = useColumnFilters();
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
|
||||
const removeMemberFromCache = ({
|
||||
@@ -625,7 +635,6 @@ export default function MemberList(props: Props) {
|
||||
columnFilters,
|
||||
rowSelection,
|
||||
},
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
@@ -654,7 +663,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
|
||||
|
||||
@@ -10,13 +10,28 @@ import {
|
||||
} from "@tanstack/react-table";
|
||||
import Link from "next/link";
|
||||
import { useRef, useMemo, useId } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { DataTable, DataTableSkeleton, useFetchMoreOnBottomReached } from "@calcom/features/data-table";
|
||||
import type { FilterValue, ExternalFilter } from "@calcom/features/data-table";
|
||||
import {
|
||||
DataTableProvider,
|
||||
DataTable,
|
||||
DataTableSkeleton,
|
||||
DataTableFilters,
|
||||
useFetchMoreOnBottomReached,
|
||||
useColumnFilters,
|
||||
selectFilter,
|
||||
textFilter,
|
||||
dataTableFilter,
|
||||
convertToTitleCase,
|
||||
useDataTable,
|
||||
} 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 { RoutingFormFieldType } from "@calcom/routing-forms/lib/FieldTypes";
|
||||
import { trpc, type RouterOutputs } from "@calcom/trpc";
|
||||
import {
|
||||
Badge,
|
||||
@@ -30,6 +45,13 @@ import {
|
||||
} from "@calcom/ui";
|
||||
|
||||
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];
|
||||
|
||||
@@ -41,6 +63,17 @@ type RoutingFormTableRow = {
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
type FieldCellValue = { label: string; value: string };
|
||||
|
||||
const ZResponseValues = z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
})
|
||||
);
|
||||
|
||||
type ResponseValues = FieldCellValue[];
|
||||
|
||||
function CellWithOverflowX({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={classNames("group relative max-w-[200px]", className)}>
|
||||
@@ -96,29 +129,29 @@ function BookedByCell({
|
||||
);
|
||||
}
|
||||
|
||||
function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number }) {
|
||||
function ResponseValueCell({ values, rowId }: { values: ResponseValues; 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, 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, i: number) => (
|
||||
<span key={`${cellId}-overflow-${i}-${rowId}`} className="text-default text-sm">
|
||||
{v}
|
||||
{value.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -127,9 +160,9 @@ function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number })
|
||||
</HoverCard>
|
||||
</>
|
||||
) : (
|
||||
value.map((v: string, i: number) => (
|
||||
values.map((value, i: number) => (
|
||||
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
|
||||
{v}
|
||||
{value.label}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
@@ -215,7 +248,15 @@ function BookingAtCell({
|
||||
|
||||
export type RoutingFormTableType = ReturnType<typeof useReactTable<RoutingFormTableRow>>;
|
||||
|
||||
export function RoutingFormResponsesTable({
|
||||
export function RoutingFormResponsesTable() {
|
||||
return (
|
||||
<DataTableProvider>
|
||||
<RoutingFormResponsesTableContent />
|
||||
</DataTableProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoutingFormResponsesTableContent({
|
||||
children,
|
||||
}: {
|
||||
children?: React.ReactNode | ((table: RoutingFormTableType) => React.ReactNode);
|
||||
@@ -223,21 +264,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(
|
||||
{
|
||||
@@ -259,8 +294,7 @@ export function RoutingFormResponsesTable({
|
||||
userId: selectedMemberUserId ?? undefined,
|
||||
isAll: isAll ?? false,
|
||||
routingFormId: selectedRoutingFormId ?? undefined,
|
||||
bookingStatus: selectedBookingStatus ?? undefined,
|
||||
fieldFilter: selectedRoutingFormFilter ?? undefined,
|
||||
columnFilters,
|
||||
limit: 30,
|
||||
},
|
||||
{
|
||||
@@ -289,20 +323,27 @@ export function RoutingFormResponsesTable({
|
||||
};
|
||||
|
||||
Object.entries(response.response).forEach(([fieldId, field]) => {
|
||||
const header = headers?.find((h) => h.id === fieldId);
|
||||
const fieldHeader = (headers || []).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;
|
||||
@@ -325,30 +366,64 @@ export function RoutingFormResponsesTable({
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("bookedAttendees", {
|
||||
columnHelper.accessor("routedToBooking", {
|
||||
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 || []).map((fieldHeader) => {
|
||||
const isText = [
|
||||
RoutingFormFieldType.TEXT,
|
||||
RoutingFormFieldType.EMAIL,
|
||||
RoutingFormFieldType.PHONE,
|
||||
RoutingFormFieldType.TEXTAREA,
|
||||
].includes(fieldHeader.type as RoutingFormFieldType);
|
||||
|
||||
const isNumber = fieldHeader.type === RoutingFormFieldType.NUMBER;
|
||||
|
||||
const isSelect =
|
||||
fieldHeader.type === RoutingFormFieldType.SINGLE_SELECT ||
|
||||
fieldHeader.type === RoutingFormFieldType.MULTI_SELECT;
|
||||
|
||||
const filterType = isSelect ? "select" : isNumber ? "number" : "text";
|
||||
|
||||
return columnHelper.accessor(fieldHeader.id, {
|
||||
id: fieldHeader.id,
|
||||
header: convertToTitleCase(fieldHeader.label),
|
||||
size: 200,
|
||||
cell: (info) => {
|
||||
let value = info.getValue();
|
||||
value = Array.isArray(value) ? value : [value];
|
||||
return (
|
||||
<div className="max-w-[200px]">
|
||||
<ResponseValueCell value={value} rowId={info.row.original.id} />
|
||||
const values = info.getValue();
|
||||
const result = isSelect ? ZResponseValues.safeParse(values) : null;
|
||||
return isSelect && result?.success ? (
|
||||
<ResponseValueCell values={result.data} rowId={info.row.original.id} />
|
||||
) : (
|
||||
<div className="truncate">
|
||||
<span title={values}>{values}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
filter: { type: filterType },
|
||||
},
|
||||
filterFn: (row, id, filterValue: FilterValue) => {
|
||||
let cellValue: unknown;
|
||||
|
||||
if (Array.isArray(fieldHeader.options)) {
|
||||
cellValue = Array.isArray(row.original[id])
|
||||
? row.original[id].map((item: FieldCellValue) => item.value)
|
||||
: row.original[id].value;
|
||||
} else {
|
||||
cellValue = row.original[id];
|
||||
}
|
||||
|
||||
return dataTableFilter(cellValue, filterValue);
|
||||
},
|
||||
});
|
||||
}) ?? []),
|
||||
columnHelper.accessor("routedToBooking", {
|
||||
@@ -360,6 +435,12 @@ 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);
|
||||
},
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const statusA = rowA.original.routedToBooking?.status;
|
||||
const statusB = rowB.original.routedToBooking?.status;
|
||||
@@ -373,6 +454,7 @@ export function RoutingFormResponsesTable({
|
||||
id: "bookingAt",
|
||||
header: t("routing_form_insights_booking_at"),
|
||||
size: 250,
|
||||
enableColumnFilter: false,
|
||||
cell: (info) => (
|
||||
<div className="max-w-[250px]">
|
||||
<BookingAtCell
|
||||
@@ -388,6 +470,9 @@ export function RoutingFormResponsesTable({
|
||||
id: "assignmentReason",
|
||||
header: t("routing_form_insights_assignment_reason"),
|
||||
size: 250,
|
||||
meta: {
|
||||
filter: { type: "text" },
|
||||
},
|
||||
cell: (info) => {
|
||||
const assignmentReason = info.getValue()?.assignmentReason;
|
||||
return (
|
||||
@@ -396,11 +481,15 @@ export function RoutingFormResponsesTable({
|
||||
</div>
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, filterValue) => {
|
||||
return textFilter(row.original.routedToBooking?.assignmentReason?.[0]?.reasonString, filterValue);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("createdAt", {
|
||||
id: "submittedAt",
|
||||
header: t("routing_form_insights_submitted_at"),
|
||||
size: 250,
|
||||
enableColumnFilter: false,
|
||||
cell: (info) => (
|
||||
<div className="whitespace-nowrap">
|
||||
<Badge variant="gray">{dayjs(info.getValue()).format("MMM D, YYYY HH:mm")}</Badge>
|
||||
@@ -420,6 +509,27 @@ export function RoutingFormResponsesTable({
|
||||
defaultColumn: {
|
||||
size: 200,
|
||||
},
|
||||
state: {
|
||||
columnFilters,
|
||||
},
|
||||
getFacetedUniqueValues: (_, columnId) => () => {
|
||||
if (!headers) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const fieldHeader = headers.find((h) => h.id === columnId);
|
||||
if (fieldHeader?.options) {
|
||||
return new Map(fieldHeader.options.map((option) => [{ label: option.label, value: option.id }, 1]));
|
||||
} else if (columnId === "bookingStatus") {
|
||||
return new Map(
|
||||
Object.keys(BookingStatus).map((status) => [
|
||||
{ value: status, label: bookingStatusToText(status as BookingStatus) },
|
||||
1,
|
||||
])
|
||||
);
|
||||
}
|
||||
return new Map();
|
||||
},
|
||||
});
|
||||
|
||||
const fetchMoreOnBottomReached = useFetchMoreOnBottomReached({
|
||||
@@ -429,6 +539,24 @@ export function RoutingFormResponsesTable({
|
||||
isFetching,
|
||||
});
|
||||
|
||||
const { removeDisplayedExternalFilter } = useDataTable();
|
||||
|
||||
const externalFilters = useMemo<ExternalFilter[]>(
|
||||
() => [
|
||||
{
|
||||
key: "memberUserId",
|
||||
titleKey: "people",
|
||||
component: () => (
|
||||
<UserListInTeam
|
||||
showOnlyWhenSelectedInContext={false}
|
||||
onClear={() => removeDisplayedExternalFilter("memberUserId")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[removeDisplayedExternalFilter]
|
||||
);
|
||||
|
||||
if (isHeadersLoading || ((isFetching || isLoading) && !data)) {
|
||||
return <DataTableSkeleton columns={4} columnWidths={[200, 200, 250, 250]} />;
|
||||
}
|
||||
@@ -443,8 +571,22 @@ export function RoutingFormResponsesTable({
|
||||
fetchMoreOnBottomReached(e.target as HTMLDivElement);
|
||||
}
|
||||
}}
|
||||
enableColumnResizing={true}
|
||||
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} externalFilters={externalFilters} />
|
||||
<RoutingFormFilterList showOnlyWhenSelectedInContext={false} />
|
||||
<DataTableFilters.ActiveFilters table={table} externalFilters={externalFilters} />
|
||||
<ClearFilters />
|
||||
<div className="grow" />
|
||||
<DateSelect />
|
||||
<RoutingDownload />
|
||||
<DataTableFilters.ColumnVisibilityButton table={table} />
|
||||
</div>
|
||||
<RoutingKPICards />
|
||||
</div>
|
||||
</DataTable>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useDataTable } from "@calcom/features/data-table";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon, Button, Tooltip } from "@calcom/ui";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
|
||||
// This component clears filters from both:
|
||||
// - the filter from FilterContext
|
||||
// - the data table filters state
|
||||
export const ClearFilters = () => {
|
||||
const { t } = useLocale();
|
||||
const { clearFilters, filter } = useFilterContext();
|
||||
const { activeFilters, clearAll } = useDataTable();
|
||||
|
||||
const clear = () => {
|
||||
// clear filters from the filter context
|
||||
clearFilters();
|
||||
|
||||
// clear filters from the data table state
|
||||
clearAll();
|
||||
};
|
||||
|
||||
const { initialConfig, selectedTeamId, selectedMemberUserId } = filter;
|
||||
|
||||
const isFilterSelected =
|
||||
initialConfig?.teamId !== selectedTeamId ||
|
||||
initialConfig?.userId !== selectedMemberUserId ||
|
||||
activeFilters?.length > 0;
|
||||
|
||||
if (!isFilterSelected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<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;
|
||||
@@ -82,7 +88,7 @@ export const TeamAndSelfList = ({ omitOrg = false }: { omitOrg?: boolean }) => {
|
||||
const isOrgDataAvailable = !!data && data.length > 0 && !!data[0].isOrg;
|
||||
|
||||
return (
|
||||
<AnimatedPopover text={text}>
|
||||
<AnimatedPopover text={text} popoverTriggerClassNames={className}>
|
||||
<FilterCheckboxFieldsContainer>
|
||||
{isOrgDataAvailable && (
|
||||
<FilterCheckboxField
|
||||
|
||||
+18
-3
@@ -13,7 +13,13 @@ 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,
|
||||
onClear,
|
||||
}: {
|
||||
showOnlyWhenSelectedInContext?: boolean;
|
||||
onClear?: () => void;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { filter, setConfigFilters } = useFilterContext();
|
||||
const { selectedFilter, selectedTeamId, selectedMemberUserId, isAll } = filter;
|
||||
@@ -23,7 +29,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 +44,12 @@ 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) });
|
||||
if (value === null) {
|
||||
onClear?.();
|
||||
}
|
||||
}}
|
||||
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, TypedColumnFilter } from "@calcom/features/data-table";
|
||||
import { makeWhereClause } from "@calcom/features/data-table/lib/server";
|
||||
import { readonlyPrisma as prisma } from "@calcom/prisma";
|
||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -25,6 +27,7 @@ type RoutingFormInsightsFilter = RoutingFormInsightsTeamFilter & {
|
||||
fieldId: string;
|
||||
optionId: string;
|
||||
} | null;
|
||||
columnFilters: ColumnFilter[];
|
||||
};
|
||||
|
||||
class RoutingEventsInsights {
|
||||
@@ -83,7 +86,7 @@ class RoutingEventsInsights {
|
||||
searchQuery,
|
||||
bookingStatus,
|
||||
fieldFilter,
|
||||
}: RoutingFormInsightsFilter) {
|
||||
}: Omit<RoutingFormInsightsFilter, "columnFilters">) {
|
||||
// Get team IDs based on organization if applicable
|
||||
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
|
||||
teamId,
|
||||
@@ -197,9 +200,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,
|
||||
@@ -207,6 +209,43 @@ class RoutingEventsInsights {
|
||||
routingFormId,
|
||||
});
|
||||
|
||||
const bookingStatusFilter = columnFilters.find((filter) => filter.id === "bookingStatus");
|
||||
const assignmentReasonFilter = columnFilters.find((filter) => filter.id === "assignmentReason") as
|
||||
| TypedColumnFilter<"text">
|
||||
| undefined;
|
||||
const fieldFilters = columnFilters.filter(
|
||||
(filter) => filter.id !== "bookingStatus" && filter.id !== "assignmentReason"
|
||||
);
|
||||
|
||||
let bookingWhereInput: Prisma.BookingWhereInput = {};
|
||||
if (userId) {
|
||||
bookingWhereInput.userId = userId;
|
||||
}
|
||||
if (bookingStatusFilter) {
|
||||
bookingWhereInput = {
|
||||
...bookingWhereInput,
|
||||
...makeWhereClause({ columnName: "status", filterValue: bookingStatusFilter.value }),
|
||||
};
|
||||
}
|
||||
if (assignmentReasonFilter) {
|
||||
const operator = assignmentReasonFilter.value.data.operator;
|
||||
if (operator === "isEmpty") {
|
||||
bookingWhereInput.assignmentReason = { none: {} };
|
||||
} else if (operator === "isNotEmpty") {
|
||||
bookingWhereInput.assignmentReason = {
|
||||
some: {
|
||||
reasonString: {
|
||||
not: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
bookingWhereInput.assignmentReason = {
|
||||
some: makeWhereClause({ columnName: "reasonString", filterValue: assignmentReasonFilter.value }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const responsesWhereCondition: Prisma.App_RoutingForms_FormResponseWhereInput = {
|
||||
...(startDate &&
|
||||
endDate && {
|
||||
@@ -215,27 +254,22 @@ class RoutingEventsInsights {
|
||||
lte: dayjs(endDate).endOf("day").toDate(),
|
||||
},
|
||||
}),
|
||||
...(userId || bookingStatus
|
||||
? {
|
||||
...(bookingStatus === "NO_BOOKING"
|
||||
? { routedToBooking: null }
|
||||
: {
|
||||
routedToBooking: {
|
||||
...(userId && { userId }),
|
||||
...(bookingStatus && { status: bookingStatus }),
|
||||
},
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
...(fieldFilter && {
|
||||
response: {
|
||||
path: [fieldFilter.fieldId, "value"],
|
||||
array_contains: [fieldFilter.optionId],
|
||||
},
|
||||
}),
|
||||
...(Object.keys(bookingWhereInput).length > 0 ? { routedToBooking: bookingWhereInput } : {}),
|
||||
form: formsTeamWhereCondition,
|
||||
};
|
||||
|
||||
if (fieldFilters.length > 0) {
|
||||
responsesWhereCondition.AND = fieldFilters.map((fieldFilter) => {
|
||||
// NOTE: We cannot perform case-insensitive search on `response` column,
|
||||
// until we normalize this table, use raw sql, or filter at the application level.
|
||||
return makeWhereClause({
|
||||
columnName: "response",
|
||||
filterValue: fieldFilter.value,
|
||||
json: { path: [fieldFilter.id, "value"] },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const totalResponsePromise = prisma.app_RoutingForms_FormResponse.count({
|
||||
where: responsesWhereCondition,
|
||||
});
|
||||
@@ -478,6 +512,7 @@ class RoutingEventsInsights {
|
||||
return {
|
||||
id: f.id,
|
||||
label: f.label,
|
||||
type: f.type,
|
||||
options: f.options,
|
||||
};
|
||||
});
|
||||
@@ -497,7 +532,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,14 @@ export const insightsRouter = router({
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await RoutingEventsInsights.getRoutingFormHeaders({
|
||||
const headers = await RoutingEventsInsights.getRoutingFormHeaders({
|
||||
teamId: input.teamId ?? null,
|
||||
isAll: input.isAll,
|
||||
organizationId: ctx.user.organizationId ?? null,
|
||||
routingFormId: input.routingFormId ?? null,
|
||||
});
|
||||
|
||||
return headers || [];
|
||||
}),
|
||||
rawRoutingData: userBelongsToTeamProcedure
|
||||
.input(
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useQueryState, parseAsBoolean } from "nuqs";
|
||||
import { useMemo, useReducer, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
DataTableProvider,
|
||||
DataTable,
|
||||
DataTableToolbar,
|
||||
DataTableFilters,
|
||||
@@ -16,6 +17,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";
|
||||
@@ -97,6 +100,14 @@ function reducer(state: UserTableState, action: UserTableAction): UserTableState
|
||||
}
|
||||
|
||||
export function UserListTable() {
|
||||
return (
|
||||
<DataTableProvider>
|
||||
<UserListTableContent />
|
||||
</DataTableProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function UserListTableContent() {
|
||||
const [dynamicLinkVisible, setDynamicLinkVisible] = useQueryState("dynamicLink", parseAsBoolean);
|
||||
const orgBranding = useOrgBranding();
|
||||
const domain = orgBranding?.fullDomain ?? WEBAPP_URL;
|
||||
@@ -164,62 +175,75 @@ export function UserListTable() {
|
||||
return [];
|
||||
}
|
||||
return (
|
||||
(attributes?.map((attribute) => ({
|
||||
id: attribute.id,
|
||||
header: attribute.name,
|
||||
meta: {
|
||||
filterType: attribute.type.toLowerCase() === "text" ? "text" : "select",
|
||||
},
|
||||
size: 120,
|
||||
accessorFn: (data) => data.attributes.find((attr) => attr.attributeId === attribute.id)?.value,
|
||||
cell: ({ row }) => {
|
||||
const attributeValues = row.original.attributes.filter(
|
||||
(attr) => attr.attributeId === attribute.id
|
||||
);
|
||||
if (attributeValues.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
attribute.type === "NUMBER" ? "flex w-full justify-center" : "flex flex-wrap"
|
||||
)}>
|
||||
{attributeValues.map((attributeValue, index) => {
|
||||
const isAGroupOption = attributeValue.contains?.length > 0;
|
||||
const suffix = attribute.isWeightsEnabled ? `${attributeValue.weight || 100}%` : undefined;
|
||||
return (
|
||||
<div className="mr-1 inline-flex shrink-0" key={attributeValue.id}>
|
||||
<Badge
|
||||
variant={isAGroupOption ? "orange" : "gray"}
|
||||
className={classNames(suffix && "rounded-r-none")}>
|
||||
{attributeValue.value}
|
||||
</Badge>
|
||||
(attributes?.map((attribute) => {
|
||||
// TODO: We need to normalize AttributeOption table first
|
||||
// so that we can have `number_value` column for numeric operations.
|
||||
// Currently, `value` column is used for both text and number attributes.
|
||||
//
|
||||
// const isNumber = attribute.type === "NUMBER";
|
||||
const isNumber = false;
|
||||
const isText = attribute.type === "TEXT";
|
||||
const filterType = isNumber ? "number" : isText ? "text" : "select";
|
||||
|
||||
{suffix ? (
|
||||
return {
|
||||
id: attribute.id,
|
||||
header: attribute.name,
|
||||
meta: {
|
||||
filter: { type: filterType },
|
||||
},
|
||||
size: 120,
|
||||
accessorFn: (data) => data.attributes.find((attr) => attr.attributeId === attribute.id)?.value,
|
||||
cell: ({ row }) => {
|
||||
const attributeValues = row.original.attributes.filter(
|
||||
(attr) => attr.attributeId === attribute.id
|
||||
);
|
||||
if (attributeValues.length === 0) return null;
|
||||
return (
|
||||
<div className={classNames(isNumber ? "flex w-full justify-center" : "flex flex-wrap")}>
|
||||
{attributeValues.map((attributeValue) => {
|
||||
const isAGroupOption = attributeValue.contains?.length > 0;
|
||||
const suffix = attribute.isWeightsEnabled
|
||||
? `${attributeValue.weight || 100}%`
|
||||
: undefined;
|
||||
return (
|
||||
<div className="mr-1 inline-flex shrink-0" key={attributeValue.id}>
|
||||
<Badge
|
||||
variant={isAGroupOption ? "orange" : "gray"}
|
||||
style={{
|
||||
backgroundColor: "color-mix(in hsl, var(--cal-bg-emphasis), black 5%)",
|
||||
}}
|
||||
className="rounded-l-none">
|
||||
{suffix}
|
||||
className={classNames(suffix && "rounded-r-none")}>
|
||||
{attributeValue.value}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
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));
|
||||
}
|
||||
{suffix ? (
|
||||
<Badge
|
||||
variant={isAGroupOption ? "orange" : "gray"}
|
||||
style={{
|
||||
backgroundColor: "color-mix(in hsl, var(--cal-bg-emphasis), black 5%)",
|
||||
}}
|
||||
className="rounded-l-none">
|
||||
{suffix}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, filterValue) => {
|
||||
const attributeValues = row.original.attributes.filter((attr) => attr.attributeId === id);
|
||||
|
||||
if (attributeValues.length === 0) return false;
|
||||
return attributeValues.some((attr) => filterValue.includes(attr.value));
|
||||
},
|
||||
})) as ColumnDef<UserTableUser>[]) ?? []
|
||||
if (isTextFilterValue(filterValue)) {
|
||||
return attributeValues.some((attr) => textFilter(attr.value, filterValue));
|
||||
} else if (isSelectFilterValue(filterValue)) {
|
||||
return selectFilter(
|
||||
attributeValues.map((attr) => attr.value),
|
||||
filterValue
|
||||
);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}) as ColumnDef<UserTableUser>[]) ?? []
|
||||
);
|
||||
};
|
||||
const cols: ColumnDef<UserTableUser>[] = [
|
||||
@@ -512,7 +536,7 @@ export function UserListTable() {
|
||||
table={table}
|
||||
tableContainerRef={tableContainerRef}
|
||||
isPending={isPending}
|
||||
enableColumnResizing={{ name: "UserListTable" }}
|
||||
enableColumnResizing={true}
|
||||
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
|
||||
<DataTableToolbar.Root className="lg:max-w-screen-2xl">
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row">
|
||||
@@ -534,7 +558,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 } from "@prisma/client";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
@@ -85,6 +86,23 @@ const shuffle = (
|
||||
return booking;
|
||||
};
|
||||
|
||||
async function createAttendees(bookings: any[]) {
|
||||
for (const booking of bookings) {
|
||||
await prisma.attendee.createMany({
|
||||
data: Array(Math.floor(Math.random() * 4))
|
||||
.fill(null)
|
||||
.map(() => {
|
||||
return {
|
||||
bookingId: booking.id,
|
||||
timeZone: faker.location.timeZone(),
|
||||
email: faker.internet.email(),
|
||||
name: faker.person.fullName(),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
async function main() {
|
||||
// First find the organization we want to add insights to
|
||||
@@ -268,6 +286,8 @@ async function main() {
|
||||
],
|
||||
});
|
||||
|
||||
await createAttendees(await prisma.booking.findMany());
|
||||
|
||||
// Find owner of the organization
|
||||
const owner = orgMembers.find((m) => m.role === "OWNER" || m.role === "ADMIN");
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
@@ -623,10 +660,22 @@ export async function seedRoutingFormResponses(
|
||||
formFillerId: randomUUID(),
|
||||
createdAt: randomDate.toDate(),
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -650,10 +699,22 @@ export async function seedRoutingFormResponses(
|
||||
formFillerId: randomUUID(),
|
||||
createdAt: randomDate.subtract(2, "hour").toDate(),
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -117,10 +117,10 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
|
||||
attribute: {
|
||||
id: filter.id,
|
||||
},
|
||||
...makeWhereClause(
|
||||
"value",
|
||||
attributeOptionValues.length > 0 ? attributeOptionValues : filter.value
|
||||
),
|
||||
...makeWhereClause({
|
||||
columnName: "value",
|
||||
filterValue: attributeOptionValues.length > 0 ? attributeOptionValues : 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.
|
||||
|
||||
@@ -13,6 +13,7 @@ export const lucideIconList = new Set([
|
||||
"badge-check",
|
||||
"ban",
|
||||
"bell",
|
||||
"binary",
|
||||
"blocks",
|
||||
"bold",
|
||||
"book-open-check",
|
||||
|
||||
@@ -13,6 +13,7 @@ export type IconName =
|
||||
| "badge-check"
|
||||
| "ban"
|
||||
| "bell"
|
||||
| "binary"
|
||||
| "blocks"
|
||||
| "bold"
|
||||
| "book-open-check"
|
||||
|
||||
Reference in New Issue
Block a user