Skip to content

Commit

Permalink
feat: add text & numeric filters to insights routing responses (#18016)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* rename fields to headers

* use safeParse

* fix type error

* push yarn.lock with faker

* fix MemberList

---------

Co-authored-by: Udit Takkar <[email protected]>
Co-authored-by: Alex van Andel <[email protected]>
Co-authored-by: sean-brydon <[email protected]>
  • Loading branch information
4 people authored Dec 12, 2024
1 parent e366c10 commit a7e7561
Show file tree
Hide file tree
Showing 36 changed files with 1,170 additions and 490 deletions.
22 changes: 3 additions & 19 deletions apps/web/modules/insights/insights-routing-view.tsx
Original file line number Diff line number Diff line change
@@ -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 />

Expand Down
8 changes: 8 additions & 0 deletions apps/web/public/icons/sprite.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion packages/core/EventManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
2 changes: 1 addition & 1 deletion packages/features/apps/AdminAppsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
11 changes: 9 additions & 2 deletions packages/features/data-table/components/DataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -82,7 +89,7 @@ export function DataTable<TData, TValue>({
usePersistentColumnResizing({
enabled: Boolean(enableColumnResizing),
table,
name: enableColumnResizing?.name,
identifier,
});

return (
Expand Down
5 changes: 4 additions & 1 deletion packages/features/data-table/components/DataTableToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down
59 changes: 11 additions & 48 deletions packages/features/data-table/components/filters/FilterOptions.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import {
Expand All @@ -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>
Expand All @@ -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>
);
})}
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
);
}
Loading

0 comments on commit a7e7561

Please sign in to comment.