From 5d8885899562d577dace2839f6fb3d5453a2bea3 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Thu, 5 Dec 2024 16:32:52 +0100 Subject: [PATCH] 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 --- .../insights/insights-routing-view.tsx | 21 +-- apps/web/public/static/locales/en/common.json | 2 + packages/features/apps/AdminAppsList.tsx | 2 +- .../components/DataTableToolbar.tsx | 5 +- .../components/filters/FilterOptions.tsx | 4 +- .../filters/MultiSelectFilterOptions.tsx | 19 +- .../components/filters/TextFilterOptions.tsx | 2 +- .../data-table/components/filters/index.tsx | 106 ++++++----- packages/features/data-table/lib/server.ts | 37 ++-- packages/features/data-table/lib/types.ts | 28 ++- packages/features/data-table/lib/utils.ts | 95 ++++++---- .../attributes/attributes-list-view.tsx | 1 - .../ee/teams/components/MemberList.tsx | 2 +- .../components/RoutingFormResponsesTable.tsx | 166 +++++++++++++----- .../insights/filters/ClearFilters.tsx | 48 +++++ .../features/insights/filters/DateSelect.tsx | 5 +- .../filters/RoutingFormFilterList.tsx | 54 +++--- .../insights/filters/TeamAndSelfList.tsx | 10 +- ...UsersListInTeam.tsx => UserListInTeam.tsx} | 14 +- packages/features/insights/filters/index.tsx | 4 +- .../insights/filters/routing/FilterBar.tsx | 77 -------- .../insights/server/routing-events.ts | 50 +++--- .../features/insights/server/trpc-router.ts | 18 +- .../components/UserTable/UserListTable.tsx | 15 +- packages/prisma/package.json | 1 + packages/prisma/seed-utils.ts | 71 +++++++- .../organizations/listMembers.handler.ts | 2 +- packages/types/tanstack-table.d.ts | 4 +- yarn.lock | 8 + 29 files changed, 556 insertions(+), 315 deletions(-) create mode 100644 packages/features/insights/filters/ClearFilters.tsx rename packages/features/insights/filters/{UsersListInTeam.tsx => UserListInTeam.tsx} (78%) delete mode 100644 packages/features/insights/filters/routing/FilterBar.tsx diff --git a/apps/web/modules/insights/insights-routing-view.tsx b/apps/web/modules/insights/insights-routing-view.tsx index cddc6f97d9..ed38a09de2 100644 --- a/apps/web/modules/insights/insights-routing-view.tsx +++ b/apps/web/modules/insights/insights-routing-view.tsx @@ -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 (
- - {/* 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) => ( -
-
- -
- -
- )} -
+ diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index ebbcce9665..628d946e28 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/features/apps/AdminAppsList.tsx b/packages/features/apps/AdminAppsList.tsx index 211d15e985..2a9b7f282a 100644 --- a/packages/features/apps/AdminAppsList.tsx +++ b/packages/features/apps/AdminAppsList.tsx @@ -158,7 +158,7 @@ const AdminAppsList = ({
{ e.preventDefault(); diff --git a/packages/features/data-table/components/DataTableToolbar.tsx b/packages/features/data-table/components/DataTableToolbar.tsx index 1147dcd3c8..c6080a85f9 100644 --- a/packages/features/data-table/components/DataTableToolbar.tsx +++ b/packages/features/data-table/components/DataTableToolbar.tsx @@ -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( ref: React.Ref ) { const { t } = useLocale(); - const isFiltered = table.getState().columnFilters.length > 0; + const columnFilters = useColumnFilters(); + const isFiltered = columnFilters.length > 0; if (!isFiltered) return null; return ( @@ -222,8 +244,8 @@ function ActiveFilters({ table }: ActiveFiltersProps) { @@ -235,4 +257,4 @@ function ActiveFilters({ table }: ActiveFiltersProps) { } // Update the export to include ActiveFilters -export const DataTableFilters = { ColumnVisibilityButton, FilterButton, ActiveFilters }; +export const DataTableFilters = { ColumnVisibilityButton, AddFilterButton, ActiveFilters }; diff --git a/packages/features/data-table/lib/server.ts b/packages/features/data-table/lib/server.ts index acf22e0615..e5934aca51 100644 --- a/packages/features/data-table/lib/server.ts +++ b/packages/features/data-table/lib/server.ts @@ -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: "", }, }, diff --git a/packages/features/data-table/lib/types.ts b/packages/features/data-table/lib/types.ts index 9beb0d4026..205eac5158 100644 --- a/packages/features/data-table/lib/types.ts +++ b/packages/features/data-table/lib/types.ts @@ -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; +export type ColumnFilterMeta = { + type?: "select" | "text"; + icon?: IconName; +}; + export type FilterableColumn = { id: string; title: string; - filterType: "text" | "select"; - options: Map; -}; +} & ( + | { + type: "select"; + icon?: IconName; + options: Map; + } + | { + type: "text"; + icon?: IconName; + } +); + +export const ZColumnFilter = z.object({ + id: z.string(), + value: ZFilterValue, +}); + +export type ColumnFilter = z.infer; diff --git a/packages/features/data-table/lib/utils.ts b/packages/features/data-table/lib/utils.ts index f5a4e19105..e24a512476 100644 --- a/packages/features/data-table/lib/utils.ts +++ b/packages/features/data-table/lib/utils.ts @@ -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["state"]; +export type SetFiltersSearchState = ReturnType["setState"]; +export type ActiveFilter = z.infer; + 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[0]; -export type SetFiltersSearchState = ReturnType[1]; -export type ActiveFilter = NonNullable[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()); +}; diff --git a/packages/features/ee/organizations/pages/settings/attributes/attributes-list-view.tsx b/packages/features/ee/organizations/pages/settings/attributes/attributes-list-view.tsx index 9a72871cb5..d75e0dbe2a 100644 --- a/packages/features/ee/organizations/pages/settings/attributes/attributes-list-view.tsx +++ b/packages/features/ee/organizations/pages/settings/attributes/attributes-list-view.tsx @@ -17,7 +17,6 @@ import { DropdownMenuItem, Icon, showToast, - useMeta, } from "@calcom/ui"; import { DeleteAttributeModal } from "./DeleteAttributeModal"; diff --git a/packages/features/ee/teams/components/MemberList.tsx b/packages/features/ee/teams/components/MemberList.tsx index f5fdc0cca4..ca0a8a1330 100644 --- a/packages/features/ee/teams/components/MemberList.tsx +++ b/packages/features/ee/teams/components/MemberList.tsx @@ -654,7 +654,7 @@ export default function MemberList(props: Props) {
setDebouncedSearchTerm(value)} /> - + {isAdminOrOwner && ( @@ -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
; + if (values.length === 0) return
; return ( - {value.length > 2 ? ( + {values.length > 2 ? ( <> - {value.slice(0, 2).map((v: string, i: number) => ( + {values.slice(0, 2).map((value: ResponseValue, i: number) => ( - {v} + {value.label} ))} - +{value.length - 2} + +{values.length - 2}
- {value.slice(2).map((v: string, i: number) => ( + {values.slice(2).map((value: ResponseValue, i: number) => ( - {v} + {value.label} ))}
@@ -133,9 +153,9 @@ function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number })
) : ( - value.map((v: string, i: number) => ( + values.map((value: ResponseValue, i: number) => ( - {v} + {value.label} )) )} @@ -229,21 +249,15 @@ export function RoutingFormResponsesTable({ const { t } = useLocale(); const { filter } = useFilterContext(); const tableContainerRef = useRef(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 ; }, }), - ...(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 (
- + {isTextOrEmail ? ( + {values} + ) : ( + + )}
); }, + 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({
), + 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) => (
(
{dayjs(info.getValue()).format("MMM D, YYYY HH:mm")} @@ -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} +
+
+ + + + + + + + + +
+ +
); diff --git a/packages/features/insights/filters/ClearFilters.tsx b/packages/features/insights/filters/ClearFilters.tsx new file mode 100644 index 0000000000..7e14809072 --- /dev/null +++ b/packages/features/insights/filters/ClearFilters.tsx @@ -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 ( + + + + ); +}; diff --git a/packages/features/insights/filters/DateSelect.tsx b/packages/features/insights/filters/DateSelect.tsx index 2432754b8d..1f3169c296 100644 --- a/packages/features/insights/filters/DateSelect.tsx +++ b/packages/features/insights/filters/DateSelect.tsx @@ -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 ( -
+
{ - 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 ( - setConfigFilters({ selectedRoutingFormId: value as string })} - buttonIcon={} - /> - ); -}); + return ( + setConfigFilters({ selectedRoutingFormId: value as string })} + buttonIcon={} + /> + ); + } +); RoutingFormFilterList.displayName = "RoutingFormFilterList"; diff --git a/packages/features/insights/filters/TeamAndSelfList.tsx b/packages/features/insights/filters/TeamAndSelfList.tsx index ce302d2c12..f0134205ed 100644 --- a/packages/features/insights/filters/TeamAndSelfList.tsx +++ b/packages/features/insights/filters/TeamAndSelfList.tsx @@ -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 ( - + {isSuccess && data?.length > 0 && data[0].isOrg && ( ({ icon: , }); -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={} placeholder={t("search")} testId="people-filter" diff --git a/packages/features/insights/filters/index.tsx b/packages/features/insights/filters/index.tsx index 62b0d8ed40..9d277e5b03 100644 --- a/packages/features/insights/filters/index.tsx +++ b/packages/features/insights/filters/index.tsx @@ -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 */}
{showRoutingFilters ? : } - +
); diff --git a/packages/features/insights/filters/routing/FilterBar.tsx b/packages/features/insights/filters/routing/FilterBar.tsx deleted file mode 100644 index 8857501892..0000000000 --- a/packages/features/insights/filters/routing/FilterBar.tsx +++ /dev/null @@ -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 ( - - - - ); -}; - -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 ( -
-
- - - - - - - {routingFormFieldIds.map((fieldId) => { - if (fieldId) return ; - })} - - - - -
- -
- - - -
-
- ); -}; diff --git a/packages/features/insights/server/routing-events.ts b/packages/features/insights/server/routing-events.ts index c759fe60c3..abdd7873d7 100644 --- a/packages/features/insights/server/routing-events.ts +++ b/packages/features/insights/server/routing-events.ts @@ -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) { // 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 & { 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 & { take?: number; skip?: number }) { const formsWhereCondition = await this.getWhereForTeamOrAllTeams({ teamId, isAll, diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index d9f0a36602..2555dfc146 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -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( diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index 3f139745aa..eb0e7217e0 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -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[]) ?? [] ); @@ -516,7 +521,7 @@ export function UserListTable() { {t("download")} {/* 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 */} - + {adminOrOwner && ( ({ @@ -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, + }, }, }, }); diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts index 7f6e18253e..860ec95c70 100644 --- a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts @@ -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 }), }, }, }; diff --git a/packages/types/tanstack-table.d.ts b/packages/types/tanstack-table.d.ts index 59fbebc289..ffe75143b1 100644 --- a/packages/types/tanstack-table.d.ts +++ b/packages/types/tanstack-table.d.ts @@ -1,12 +1,14 @@ import "@tanstack/react-table"; +import type { ColumnFilterMeta } from "@calcom/features/data-table"; + declare module "@tanstack/table-core" { interface ColumnMeta { 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. diff --git a/yarn.lock b/yarn.lock index 3649318cc9..063c86b683 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"