feat: refactor filters on insights response table (#17796)

* feat: improve text filters (WIP)

* move function to bottom

* apply some styles

* fix selection of TextFilterOptions

* rename value to operand

* remove unused file

* merge filters/filters into filters/utils

* fix regression of not putting url params correctly

* move makeWhereClause to filters/utils

* fix negative, empty, and not empty operators

* fix initial filtering from search state (url)

* fix type errors

* do not send an empty array to query

* update yarn.lock

* i18n for text filter operators

* extract logic as useColumnFilters()

* add missing import

* fix type error

* revert yarn.lock

* use i18n

* insensitive text match

* move data-table to @calcom/features

* fix type errors

* fix type errors

* fix type errors

* feat: support DataTable filters for Insights Routing WIP

* remove unused filters

* remove additionalFilters and fix types

* clean up filter components

* support icons for ActiveFilters

* support filters on json

* fix filter ui

* fix type error and clean up

* revert changes

* revert change

* clean up

* revert change

* fix compatibility with insights booking page

* remove unused params

* fix type errors

* update yarn.lock

* fix field filter and adjust ui

* chore: update yarn.lock

* fix text filter

* add Clear Filters button

* add more test data

---------

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