fix: re-implement filter components in /insights/routing (#18655)
* fix: re-implement team filter & people filter components * implement DateRangeFilter * show selected filter values --------- Co-authored-by: Benny Joo <sldisek783@gmail.com>
This commit is contained in:
@@ -118,6 +118,16 @@
|
||||
<path d="M16 2v4" ></path>
|
||||
<path d="M21.29 14.7a2.43 2.43 0 0 0-2.65-.52c-.3.12-.57.3-.8.53l-.34.34-.35-.34a2.43 2.43 0 0 0-2.65-.53c-.3.12-.56.3-.79.53-.95.94-1 2.53.2 3.74L17.5 22l3.6-3.55c1.2-1.21 1.14-2.8.19-3.74Z" ></path>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-calendar-range" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="calendar-range">
|
||||
<rect width="18" height="18" x="3" y="4" rx="2" ></rect>
|
||||
<path d="M16 2v4" ></path>
|
||||
<path d="M3 10h18" ></path>
|
||||
<path d="M8 2v4" ></path>
|
||||
<path d="M17 14h-6" ></path>
|
||||
<path d="M13 18H7" ></path>
|
||||
<path d="M7 14h.01" ></path>
|
||||
<path d="M17 18h.01" ></path>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-calendar-search" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="calendar-search">
|
||||
<path d="M16 2v4" ></path>
|
||||
<path d="M21 11.75V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h7.25" ></path>
|
||||
@@ -548,6 +558,17 @@
|
||||
<path d="M22 18h-5.9c-1.3 0-2.6-.7-3.3-1.8l-.5-.8" ></path>
|
||||
<path d="m18 14 4 4-4 4" ></path>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-sliders-horizontal" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="sliders-horizontal">
|
||||
<line x1="21" x2="14" y1="4" y2="4" ></line>
|
||||
<line x1="10" x2="3" y1="4" y2="4" ></line>
|
||||
<line x1="21" x2="12" y1="12" y2="12" ></line>
|
||||
<line x1="8" x2="3" y1="12" y2="12" ></line>
|
||||
<line x1="21" x2="16" y1="20" y2="20" ></line>
|
||||
<line x1="12" x2="3" y1="20" y2="20" ></line>
|
||||
<line x1="14" x2="14" y1="2" y2="6" ></line>
|
||||
<line x1="8" x2="8" y1="10" y2="14" ></line>
|
||||
<line x1="16" x2="16" y1="18" y2="22" ></line>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-sliders-vertical" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="sliders-vertical">
|
||||
<line x1="4" x2="4" y1="21" y2="14" ></line>
|
||||
<line x1="4" x2="4" y1="10" y2="3" ></line>
|
||||
|
||||
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 50 KiB |
@@ -2078,6 +2078,7 @@
|
||||
"looking_for_more_analytics": "Looking for more analytics?",
|
||||
"looking_for_more_insights": "Looking for more Insights?",
|
||||
"filters": "Filters",
|
||||
"filter": "Filter",
|
||||
"add_filter": "Add filter",
|
||||
"add_rule": "Add rule",
|
||||
"add_rule_group": "Add rule group",
|
||||
@@ -2741,10 +2742,10 @@
|
||||
"no_matching_members_will_fallback_to_all_assigned_members": "No matching members. It will fallback to using the team members assigned to the event type. Consider adding a fallback or correcting the logic of using_fallback_members",
|
||||
"hide_calendar_event_details": "Hide calendar event details on shared calendars",
|
||||
"description_hide_calendar_event_details": "When a calendar is shared, events are visible to readers but their details are hidden from those without write access.",
|
||||
"last_number_of_days": "last {{count}} days",
|
||||
"month_to_date": "month to date",
|
||||
"year_to_date": "year to date",
|
||||
"custom_range": "custom range",
|
||||
"last_number_of_days": "Last {{count}} days",
|
||||
"month_to_date": "Month to date",
|
||||
"year_to_date": "Year to date",
|
||||
"custom_range": "Custom range",
|
||||
"refund_policy": "Refund Policy",
|
||||
"always": "Always",
|
||||
"never": "Never",
|
||||
|
||||
@@ -70,8 +70,8 @@ export function DataTableWrapper<TData, TValue>({
|
||||
<DataTableToolbar.Root>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex w-full flex-wrap justify-between gap-2">
|
||||
<div className="flex flex-wrap gap-2">{ToolbarLeft}</div>
|
||||
<div className="flex flex-wrap gap-2">{ToolbarRight}</div>
|
||||
<div className="flex flex-wrap items-center gap-2">{ToolbarLeft}</div>
|
||||
<div className="flex flex-wrap items-center gap-2">{ToolbarRight}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useDataTable } from "@calcom/features/data-table";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon, Button, Tooltip } from "@calcom/ui";
|
||||
|
||||
export const ClearFiltersButton = ({ exclude }: { exclude?: string[] }) => {
|
||||
const { t } = useLocale();
|
||||
const { activeFilters, clearAll } = useDataTable();
|
||||
|
||||
if (!activeFilters?.length || (exclude && activeFilters.every((filter) => exclude.includes(filter.f)))) {
|
||||
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={() => clearAll(exclude)}>
|
||||
<Icon name="x" className="mr-1 h-4 w-4" />
|
||||
{t("clear")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { Dayjs } from "dayjs";
|
||||
import { useState } from "react";
|
||||
|
||||
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";
|
||||
|
||||
import "../../../insights/filters/DateSelect.css";
|
||||
import { useDataTable, useFilterValue } from "../../hooks";
|
||||
import type { FilterableColumn } from "../../lib/types";
|
||||
import { ZDateRangeFilterValue } from "../../lib/types";
|
||||
|
||||
type PresetOption = {
|
||||
labelKey: string;
|
||||
i18nOptions?: Record<string, string | number>;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type DateRangeFilterProps = {
|
||||
className?: string;
|
||||
column: Extract<FilterableColumn, { type: "date_range" }>;
|
||||
};
|
||||
|
||||
const CUSTOM_PRESET_VALUE = "c" as const;
|
||||
|
||||
const DEFAULT_PRESET: PresetOption = {
|
||||
labelKey: "last_number_of_days",
|
||||
i18nOptions: { count: 7 },
|
||||
value: "w",
|
||||
};
|
||||
const CUSTOM_PRESET: PresetOption = { labelKey: "custom_range", value: CUSTOM_PRESET_VALUE };
|
||||
|
||||
const PRESET_OPTIONS: PresetOption[] = [
|
||||
{ labelKey: "today", value: "tdy" },
|
||||
DEFAULT_PRESET,
|
||||
{ labelKey: "last_number_of_days", i18nOptions: { count: 30 }, value: "t" },
|
||||
{ labelKey: "month_to_date", value: "m" },
|
||||
{ labelKey: "year_to_date", value: "y" },
|
||||
CUSTOM_PRESET,
|
||||
];
|
||||
|
||||
const getDateRangeFromPreset = (val: string | null) => {
|
||||
let startDate;
|
||||
let endDate;
|
||||
const preset = PRESET_OPTIONS.find((o) => o.value === val);
|
||||
if (!preset) {
|
||||
return { startDate: getDefaultStartDate(), endDate: getDefaultEndDate(), preset: CUSTOM_PRESET };
|
||||
}
|
||||
|
||||
switch (val) {
|
||||
case "tdy": // Today
|
||||
startDate = dayjs().startOf("day");
|
||||
endDate = dayjs().endOf("day");
|
||||
break;
|
||||
case "w": // Last 7 days
|
||||
startDate = dayjs().subtract(1, "week").startOf("day");
|
||||
endDate = dayjs().endOf("day");
|
||||
break;
|
||||
case "t": // Last 30 days
|
||||
startDate = dayjs().subtract(30, "day").startOf("day");
|
||||
endDate = dayjs().endOf("day");
|
||||
break;
|
||||
case "m": // Month to Date
|
||||
startDate = dayjs().startOf("month");
|
||||
endDate = dayjs().endOf("day");
|
||||
break;
|
||||
case "y": // Year to Date
|
||||
startDate = dayjs().startOf("year");
|
||||
endDate = dayjs().endOf("day");
|
||||
break;
|
||||
default:
|
||||
startDate = getDefaultStartDate();
|
||||
endDate = getDefaultEndDate();
|
||||
break;
|
||||
}
|
||||
|
||||
return { startDate, endDate, preset };
|
||||
};
|
||||
|
||||
const getDefaultStartDate = () => dayjs().subtract(1, "week").startOf("day");
|
||||
|
||||
const getDefaultEndDate = () => dayjs().endOf("day");
|
||||
|
||||
export const DateRangeFilter = ({ className, column }: DateRangeFilterProps) => {
|
||||
const filterValue = useFilterValue(column.id, ZDateRangeFilterValue);
|
||||
const { updateFilter } = useDataTable();
|
||||
|
||||
const { t } = useLocale();
|
||||
const currentDate = dayjs();
|
||||
const [startDate, setStartDate] = useState<Dayjs>(
|
||||
filterValue?.data.startDate ? dayjs(filterValue.data.startDate) : getDefaultStartDate()
|
||||
);
|
||||
const [endDate, setEndDate] = useState<Dayjs | undefined>(
|
||||
filterValue?.data.endDate ? dayjs(filterValue.data.endDate) : getDefaultEndDate()
|
||||
);
|
||||
const [selectedPreset, setSelectedPreset] = useState<PresetOption>(DEFAULT_PRESET);
|
||||
|
||||
const updateValues = ({
|
||||
preset,
|
||||
startDate,
|
||||
endDate,
|
||||
}: {
|
||||
preset: PresetOption;
|
||||
startDate: Dayjs;
|
||||
endDate?: Dayjs;
|
||||
}) => {
|
||||
setSelectedPreset(preset);
|
||||
setStartDate(startDate);
|
||||
setEndDate(endDate);
|
||||
|
||||
if (startDate && endDate) {
|
||||
updateFilter(column.id, {
|
||||
type: "date_range",
|
||||
data: {
|
||||
startDate: startDate.toDate().toISOString(),
|
||||
endDate: endDate.toDate().toISOString(),
|
||||
preset: preset.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updateDateRangeFromPreset = (val: string | null) => {
|
||||
if (val === CUSTOM_PRESET_VALUE) {
|
||||
updateValues({
|
||||
preset: CUSTOM_PRESET,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
} else {
|
||||
const r = getDateRangeFromPreset(val);
|
||||
updateValues({
|
||||
preset: r.preset,
|
||||
startDate: r.startDate,
|
||||
endDate: r.endDate,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updateDateRangeFromPicker = ({
|
||||
startDate,
|
||||
endDate,
|
||||
}: {
|
||||
startDate?: Date | undefined;
|
||||
endDate?: Date | undefined;
|
||||
}) => {
|
||||
updateValues({
|
||||
preset: CUSTOM_PRESET,
|
||||
startDate: startDate ? dayjs(startDate) : getDefaultStartDate(),
|
||||
endDate: endDate ? dayjs(endDate) : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames("ml inline-flex space-x-2 rtl:space-x-reverse", className)}>
|
||||
<DateRangePicker
|
||||
dates={{
|
||||
startDate: startDate.toDate(),
|
||||
endDate: endDate?.toDate(),
|
||||
}}
|
||||
minDate={currentDate.subtract(2, "year").toDate()}
|
||||
maxDate={currentDate.toDate()}
|
||||
disabled={false}
|
||||
onDatesChange={updateDateRangeFromPicker}
|
||||
/>
|
||||
<Select
|
||||
variant="default"
|
||||
data-testid="insights-preset"
|
||||
options={PRESET_OPTIONS.map((o) => ({
|
||||
label: t(o.labelKey, o.i18nOptions),
|
||||
value: o.value,
|
||||
}))}
|
||||
value={{ label: t(selectedPreset.labelKey, selectedPreset.i18nOptions), value: selectedPreset.value }}
|
||||
className="w-40 capitalize text-black"
|
||||
defaultValue={{
|
||||
label: t(selectedPreset.labelKey, selectedPreset.i18nOptions),
|
||||
value: selectedPreset.value,
|
||||
}}
|
||||
styles={{ menuPortal: (base) => ({ ...base, zIndex: 9999 }) }}
|
||||
menuPortalTarget={document.body}
|
||||
onChange={(e) => {
|
||||
if (e) {
|
||||
updateDateRangeFromPreset(e.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { FilterableColumn } from "../../lib/types";
|
||||
import { DateRangeFilter } from "./DateRangeFilter";
|
||||
import { MultiSelectFilterOptions } from "./MultiSelectFilterOptions";
|
||||
import { NumberFilterOptions } from "./NumberFilterOptions";
|
||||
import { SingleSelectFilterOptions } from "./SingleSelectFilterOptions";
|
||||
@@ -19,6 +20,8 @@ export function FilterOptions({ column }: FilterOptionsProps) {
|
||||
return <SingleSelectFilterOptions column={column} />;
|
||||
} else if (column.type === "number") {
|
||||
return <NumberFilterOptions column={column} />;
|
||||
} else if (column.type === "date_range") {
|
||||
return <DateRangeFilter column={column} />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import startCase from "lodash/startCase";
|
||||
|
||||
import type { IconName } from "@calcom/ui";
|
||||
import { Button, Icon, Popover, PopoverContent, PopoverTrigger } from "@calcom/ui";
|
||||
|
||||
import { useFilterValue } from "../../hooks";
|
||||
import { type FilterableColumn, type FilterValue, ZFilterValue } from "../../lib/types";
|
||||
import {
|
||||
isSingleSelectFilterValue,
|
||||
isMultiSelectFilterValue,
|
||||
isTextFilterValue,
|
||||
isNumberFilterValue,
|
||||
} from "../../lib/utils";
|
||||
import { FilterOptions } from "./FilterOptions";
|
||||
|
||||
const FILTER_ICONS: Record<FilterableColumn["type"], IconName> = {
|
||||
text: "file-text",
|
||||
number: "binary",
|
||||
multi_select: "layers",
|
||||
single_select: "disc",
|
||||
date_range: "calendar-range",
|
||||
};
|
||||
|
||||
type FilterPopoverProps = {
|
||||
column: FilterableColumn;
|
||||
};
|
||||
|
||||
const useSelectedLabels = ({
|
||||
column,
|
||||
filterValue,
|
||||
}: {
|
||||
column: FilterableColumn;
|
||||
filterValue?: FilterValue;
|
||||
}) => {
|
||||
if (isTextFilterValue(filterValue)) {
|
||||
return { text: filterValue.data.operand, moreCount: 0 };
|
||||
} else if (isNumberFilterValue(filterValue)) {
|
||||
return { text: filterValue.data.operand, moreCount: 0 };
|
||||
} else if (isSingleSelectFilterValue(filterValue)) {
|
||||
const options = (column as Extract<FilterableColumn, { type: "single_select" }>).options;
|
||||
const text = options.find((opt) => opt.value === filterValue.data)?.label;
|
||||
return { text, moreCount: 0 };
|
||||
} else if (isMultiSelectFilterValue(filterValue)) {
|
||||
const options = (column as Extract<FilterableColumn, { type: "multi_select" }>).options;
|
||||
const text = options.find((opt) => opt.value === filterValue.data[0])?.label;
|
||||
return { text, moreCount: filterValue.data.length - 1 };
|
||||
} else {
|
||||
return { text: "", moreCount: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
export function FilterPopover({ column }: FilterPopoverProps) {
|
||||
const icon = column.icon || FILTER_ICONS[column.type];
|
||||
const filterValue = useFilterValue(column.id, ZFilterValue);
|
||||
const { text, moreCount } = useSelectedLabels({ column, filterValue });
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button color="secondary" className="items-center">
|
||||
<Icon name={icon} className="mr-2 h-4 w-4" />
|
||||
<span>{startCase(column.title)}</span>
|
||||
{text && <span className="bg-subtle ml-2 rounded-md px-2">{text}</span>}
|
||||
{moreCount > 0 && <span className="bg-subtle ml-2 rounded-md px-2">+{moreCount}</span>}
|
||||
<Icon name="chevron-down" className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<FilterOptions column={column} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -29,10 +29,10 @@ export function MultiSelectFilterOptions({ column }: MultiSelectFilterOptionsPro
|
||||
|
||||
return (
|
||||
<Command>
|
||||
<CommandInput placeholder={t("search_options")} />
|
||||
<CommandInput placeholder={t("search")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("no_options_found")}</CommandEmpty>
|
||||
{Array.from(column.options.keys()).map((option) => {
|
||||
{column.options.map((option) => {
|
||||
if (!option) return null;
|
||||
const { label: optionLabel, value: optionValue } =
|
||||
typeof option === "string" ? { label: option, value: option } : option;
|
||||
|
||||
@@ -29,10 +29,10 @@ export function SingleSelectFilterOptions({ column }: SingleSelectFilterOptionsP
|
||||
|
||||
return (
|
||||
<Command>
|
||||
<CommandInput placeholder={t("search_options")} />
|
||||
<CommandInput placeholder={t("search")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("no_options_found")}</CommandEmpty>
|
||||
{Array.from(column.options.keys()).map((option) => {
|
||||
{column.options.map((option) => {
|
||||
if (!option) return null;
|
||||
const { label: optionLabel, value: optionValue } =
|
||||
typeof option === "string" ? { label: option, value: option } : option;
|
||||
|
||||
@@ -25,8 +25,10 @@ import {
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { useDataTable } from "../../hooks";
|
||||
import type { FilterableColumn, ExternalFilter } from "../../lib/types";
|
||||
import { FilterOptions } from "./FilterOptions";
|
||||
import type { FilterableColumn } from "../../lib/types";
|
||||
import { ClearFiltersButton } from "./ClearFiltersButton";
|
||||
import { DateRangeFilter } from "./DateRangeFilter";
|
||||
import { FilterPopover } from "./FilterPopover";
|
||||
|
||||
interface ColumnVisiblityProps<TData> {
|
||||
table: Table<TData>;
|
||||
@@ -122,16 +124,14 @@ const ColumnVisibilityButton = forwardRef(ColumnVisibilityButtonComponent) as <T
|
||||
// Filters
|
||||
interface AddFilterButtonProps<TData> {
|
||||
table: Table<TData>;
|
||||
externalFilters?: ExternalFilter[];
|
||||
}
|
||||
|
||||
function AddFilterButtonComponent<TData>(
|
||||
{ table, externalFilters }: AddFilterButtonProps<TData>,
|
||||
{ table }: AddFilterButtonProps<TData>,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) {
|
||||
const { t } = useLocale();
|
||||
const { activeFilters, setActiveFilters, displayedExternalFilters, setDisplayedExternalFilters } =
|
||||
useDataTable();
|
||||
const { activeFilters, setActiveFilters } = useDataTable();
|
||||
|
||||
const filterableColumns = useFilterableColumns(table);
|
||||
|
||||
@@ -148,14 +148,14 @@ function AddFilterButtonComponent<TData>(
|
||||
<div className="flex items-center space-x-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button ref={ref} color="secondary" className="border-dashed">
|
||||
<Icon name="filter" className="mr-2 h-4 w-4" />
|
||||
{t("add_filter")}
|
||||
<Button ref={ref} color="secondary" data-testid="add-filter-button">
|
||||
<Icon name="sliders-horizontal" className="mr-2 h-4 w-4" />
|
||||
{t("filter")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={t("search_columns")} />
|
||||
<CommandInput placeholder={t("search")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("no_columns_found")}</CommandEmpty>
|
||||
{filterableColumns.map((column) => {
|
||||
@@ -169,16 +169,6 @@ function AddFilterButtonComponent<TData>(
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
{(externalFilters || [])
|
||||
.filter((filter) => !displayedExternalFilters.includes(filter.key))
|
||||
.map((filter, index) => (
|
||||
<CommandItem
|
||||
key={index}
|
||||
onSelect={() => setDisplayedExternalFilters((prev) => [...prev, filter.key])}
|
||||
className="px-4 py-2">
|
||||
{t(filter.titleKey)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
@@ -205,9 +195,23 @@ function useFilterableColumns<TData>(table: Table<TData>) {
|
||||
type,
|
||||
};
|
||||
if (type === "multi_select" || type === "single_select") {
|
||||
const values = column.getFacetedUniqueValues();
|
||||
const options = Array.from(values.keys()).map((option) => {
|
||||
if (typeof option === "string") {
|
||||
return {
|
||||
label: option,
|
||||
value: option,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
label: option.label as string,
|
||||
value: option.value as string | number,
|
||||
};
|
||||
}
|
||||
});
|
||||
return {
|
||||
...base,
|
||||
options: column.getFacetedUniqueValues(),
|
||||
options,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
@@ -229,18 +233,10 @@ const AddFilterButton = forwardRef(AddFilterButtonComponent) as <TData>(
|
||||
// Add the new ActiveFilters component
|
||||
interface ActiveFiltersProps<TData> {
|
||||
table: Table<TData>;
|
||||
externalFilters?: ExternalFilter[];
|
||||
}
|
||||
|
||||
const filterIcons = {
|
||||
text: "file-text",
|
||||
number: "binary",
|
||||
multi_select: "layers",
|
||||
single_select: "disc",
|
||||
} as const;
|
||||
|
||||
function ActiveFilters<TData>({ table, externalFilters }: ActiveFiltersProps<TData>) {
|
||||
const { activeFilters, displayedExternalFilters } = useDataTable();
|
||||
function ActiveFilters<TData>({ table }: ActiveFiltersProps<TData>) {
|
||||
const { activeFilters } = useDataTable();
|
||||
const filterableColumns = useFilterableColumns(table);
|
||||
|
||||
return (
|
||||
@@ -248,30 +244,18 @@ function ActiveFilters<TData>({ table, externalFilters }: ActiveFiltersProps<TDa
|
||||
{activeFilters.map((filter) => {
|
||||
const column = filterableColumns.find((col) => col.id === filter.f);
|
||||
if (!column) return null;
|
||||
const icon = column.icon || filterIcons[column.type];
|
||||
return (
|
||||
<Popover key={column.id}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button color="secondary">
|
||||
<Icon name={icon} className="mr-2 h-4 w-4" />
|
||||
{startCase(column.title)}
|
||||
<Icon name="chevron-down" className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<FilterOptions column={column} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
})}
|
||||
{(displayedExternalFilters || []).map((key) => {
|
||||
const filter = externalFilters?.find((filter) => filter.key === key);
|
||||
if (!filter) return null;
|
||||
return <Fragment key={key}>{filter.component()}</Fragment>;
|
||||
return <FilterPopover key={column.id} column={column} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Update the export to include ActiveFilters
|
||||
export const DataTableFilters = { ColumnVisibilityButton, AddFilterButton, ActiveFilters };
|
||||
export const DataTableFilters = {
|
||||
ColumnVisibilityButton,
|
||||
AddFilterButton,
|
||||
ActiveFilters,
|
||||
ClearFiltersButton,
|
||||
};
|
||||
|
||||
export { DateRangeFilter };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { DataTableToolbar } from "./DataTableToolbar";
|
||||
export { DataTableSelectionBar } from "./DataTableSelectionBar";
|
||||
export { DataTablePagination } from "./DataTablePagination";
|
||||
export { DataTableFilters } from "./filters";
|
||||
export * from "./filters";
|
||||
export { DataTable } from "./DataTable";
|
||||
export { DataTableSkeleton } from "./DataTableSkeleton";
|
||||
export { DataTableWrapper } from "./DataTableWrapper";
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import type { ColumnFilter } from "../lib/types";
|
||||
import { ZFilterValue } from "../lib/types";
|
||||
import { isMultiSelectFilterValue } from "../lib/utils";
|
||||
import { useDataTable } from "./useDataTable";
|
||||
|
||||
export function useColumnFilters(): ColumnFilter[] {
|
||||
export function useColumnFilters({ exclude }: { exclude?: string[] } = {}): ColumnFilter[] {
|
||||
const { activeFilters } = useDataTable();
|
||||
|
||||
// Stringify the exclude array for stable memoization
|
||||
const excludeKey = useMemo(() => JSON.stringify(exclude?.sort()), [exclude]);
|
||||
|
||||
// Now this will only change when the actual contents change
|
||||
const filterExcluded = useCallback(
|
||||
(filter: ColumnFilter) => !exclude?.includes(filter.id),
|
||||
[excludeKey] // Use the stringified key instead of the array
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
return (activeFilters || [])
|
||||
.filter(
|
||||
@@ -22,6 +32,7 @@ export function useColumnFilters(): ColumnFilter[] {
|
||||
};
|
||||
})
|
||||
.filter((filter): filter is ColumnFilter => filter !== null)
|
||||
.filter(filterExcluded)
|
||||
.filter((filter) => {
|
||||
// The empty arrays in `filtersSearchState` keep the filter UI component,
|
||||
// but we do not send them to the actual query.
|
||||
@@ -31,5 +42,5 @@ export function useColumnFilters(): ColumnFilter[] {
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [activeFilters]);
|
||||
}, [activeFilters, filterExcluded]);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { SortingState, OnChangeFn } from "@tanstack/react-table";
|
||||
import { useQueryState, parseAsArrayOf, parseAsJson } from "nuqs";
|
||||
import { createContext, useCallback, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { createContext, useCallback } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { type FilterValue, ZFilterValue, ZSorting } from "./types";
|
||||
@@ -17,16 +17,12 @@ type ActiveFilter = z.infer<typeof ZActiveFilter>;
|
||||
export type DataTableContextType = {
|
||||
activeFilters: ActiveFilter[];
|
||||
setActiveFilters: (filters: ActiveFilter[]) => void;
|
||||
clearAll: () => void;
|
||||
clearAll: (exclude?: string[]) => void;
|
||||
updateFilter: (columnId: string, value: FilterValue) => void;
|
||||
removeFilter: (columnId: string) => void;
|
||||
|
||||
sorting: SortingState;
|
||||
setSorting: OnChangeFn<SortingState>;
|
||||
|
||||
displayedExternalFilters: string[];
|
||||
setDisplayedExternalFilters: Dispatch<SetStateAction<string[]>>;
|
||||
removeDisplayedExternalFilter: (key: string) => void;
|
||||
};
|
||||
|
||||
export const DataTableContext = createContext<DataTableContextType | null>(null);
|
||||
@@ -41,24 +37,28 @@ export function DataTableProvider({ children }: { children: React.ReactNode }) {
|
||||
parseAsArrayOf(parseAsJson(ZSorting.parse)).withDefault([])
|
||||
);
|
||||
|
||||
const [displayedExternalFilters, setDisplayedExternalFilters] = useState<string[]>([]);
|
||||
|
||||
const removeDisplayedExternalFilter = useCallback(
|
||||
(key: string) => {
|
||||
setDisplayedExternalFilters((prev) => prev.filter((f) => f !== key));
|
||||
const clearAll = useCallback(
|
||||
(exclude?: string[]) => {
|
||||
setActiveFilters((prev) => prev.filter((filter) => exclude?.includes(filter.f)));
|
||||
},
|
||||
[setDisplayedExternalFilters]
|
||||
[setActiveFilters]
|
||||
);
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
setActiveFilters([]);
|
||||
setDisplayedExternalFilters([]);
|
||||
}, [setActiveFilters, setDisplayedExternalFilters]);
|
||||
|
||||
const updateFilter = useCallback(
|
||||
(columnId: string, value: FilterValue) => {
|
||||
setActiveFilters((prev) => {
|
||||
return prev.map((item) => (item.f === columnId ? { ...item, v: value } : item));
|
||||
let added = false;
|
||||
const newFilters = prev.map((item) => {
|
||||
if (item.f === columnId) {
|
||||
added = true;
|
||||
return { ...item, v: value };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
if (!added) {
|
||||
newFilters.push({ f: columnId, v: value });
|
||||
}
|
||||
return newFilters;
|
||||
});
|
||||
},
|
||||
[setActiveFilters]
|
||||
@@ -81,9 +81,6 @@ export function DataTableProvider({ children }: { children: React.ReactNode }) {
|
||||
removeFilter,
|
||||
sorting,
|
||||
setSorting,
|
||||
displayedExternalFilters,
|
||||
setDisplayedExternalFilters,
|
||||
removeDisplayedExternalFilter,
|
||||
}}>
|
||||
{children}
|
||||
</DataTableContext.Provider>
|
||||
|
||||
@@ -17,7 +17,7 @@ export type TextFilterOperator = z.infer<typeof ZTextFilterOperator>;
|
||||
|
||||
export const ZSingleSelectFilterValue = z.object({
|
||||
type: z.literal("single_select"),
|
||||
data: z.string(),
|
||||
data: z.union([z.string(), z.number()]),
|
||||
});
|
||||
|
||||
export type SingleSelectFilterValue = z.infer<typeof ZSingleSelectFilterValue>;
|
||||
@@ -60,16 +60,28 @@ export const ZNumberFilterValue = z.object({
|
||||
|
||||
export type NumberFilterValue = z.infer<typeof ZNumberFilterValue>;
|
||||
|
||||
export const ZDateRangeFilterValue = z.object({
|
||||
type: z.literal("date_range"),
|
||||
data: z.object({
|
||||
startDate: z.string().nullable(),
|
||||
endDate: z.string().nullable(),
|
||||
preset: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type DateRangeFilterValue = z.infer<typeof ZDateRangeFilterValue>;
|
||||
|
||||
export const ZFilterValue = z.union([
|
||||
ZSingleSelectFilterValue,
|
||||
ZMultiSelectFilterValue,
|
||||
ZTextFilterValue,
|
||||
ZNumberFilterValue,
|
||||
ZDateRangeFilterValue,
|
||||
]);
|
||||
|
||||
export type FilterValue = z.infer<typeof ZFilterValue>;
|
||||
|
||||
export type ColumnFilterType = "single_select" | "multi_select" | "text" | "number";
|
||||
export type ColumnFilterType = "single_select" | "multi_select" | "text" | "number" | "date_range";
|
||||
|
||||
export type ColumnFilterMeta = {
|
||||
type?: ColumnFilterType;
|
||||
@@ -83,12 +95,12 @@ export type FilterableColumn = {
|
||||
| {
|
||||
type: "single_select";
|
||||
icon?: IconName;
|
||||
options: Map<string | { label: string; value: string }, number>;
|
||||
options: Array<{ label: string; value: string | number }>;
|
||||
}
|
||||
| {
|
||||
type: "multi_select";
|
||||
icon?: IconName;
|
||||
options: Map<string | { label: string; value: string }, number>;
|
||||
options: Array<{ label: string; value: string | number }>;
|
||||
}
|
||||
| {
|
||||
type: "text";
|
||||
@@ -98,6 +110,10 @@ export type FilterableColumn = {
|
||||
type: "number";
|
||||
icon?: IconName;
|
||||
}
|
||||
| {
|
||||
type: "date_range";
|
||||
icon?: IconName;
|
||||
}
|
||||
);
|
||||
|
||||
export const ZColumnFilter = z.object({
|
||||
@@ -117,15 +133,11 @@ export type TypedColumnFilter<T extends ColumnFilterType> = {
|
||||
? SingleSelectFilterValue
|
||||
: T extends "multi_select"
|
||||
? MultiSelectFilterValue
|
||||
: T extends "date_range"
|
||||
? DateRangeFilterValue
|
||||
: never;
|
||||
};
|
||||
|
||||
export type ExternalFilter = {
|
||||
key: string;
|
||||
titleKey: string;
|
||||
component: () => React.ReactNode;
|
||||
};
|
||||
|
||||
export const ZSorting = z.object({
|
||||
id: z.string(),
|
||||
desc: z.boolean(),
|
||||
|
||||
@@ -6,12 +6,14 @@ import type {
|
||||
TextFilterValue,
|
||||
FilterValue,
|
||||
NumberFilterValue,
|
||||
DateRangeFilterValue,
|
||||
} from "./types";
|
||||
import {
|
||||
ZNumberFilterValue,
|
||||
ZSingleSelectFilterValue,
|
||||
ZMultiSelectFilterValue,
|
||||
ZTextFilterValue,
|
||||
ZDateRangeFilterValue,
|
||||
} from "./types";
|
||||
|
||||
export const textFilter = (cellValue: unknown, filterValue: TextFilterValue) => {
|
||||
@@ -113,6 +115,10 @@ export const isNumberFilterValue = (filterValue: unknown): filterValue is Number
|
||||
return ZNumberFilterValue.safeParse(filterValue).success;
|
||||
};
|
||||
|
||||
export const isDateRangeFilterValue = (filterValue: unknown): filterValue is DateRangeFilterValue => {
|
||||
return ZDateRangeFilterValue.safeParse(filterValue).success;
|
||||
};
|
||||
|
||||
export const dataTableFilter = (cellValue: unknown, filterValue: FilterValue) => {
|
||||
if (isSingleSelectFilterValue(filterValue)) {
|
||||
return singleSelectFilter(cellValue, filterValue);
|
||||
|
||||
@@ -10,22 +10,27 @@ import {
|
||||
} from "@tanstack/react-table";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import startCase from "lodash/startCase";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useId } from "react";
|
||||
import { useMemo, useId, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { ExternalFilter } from "@calcom/features/data-table";
|
||||
import {
|
||||
DataTableWrapper,
|
||||
DataTableFilters,
|
||||
DataTableProvider,
|
||||
DataTableSkeleton,
|
||||
useColumnFilters,
|
||||
useFilterValue,
|
||||
multiSelectFilter,
|
||||
textFilter,
|
||||
dataTableFilter,
|
||||
useDataTable,
|
||||
ZDateRangeFilterValue,
|
||||
ZSingleSelectFilterValue,
|
||||
DateRangeFilter,
|
||||
type FilterableColumn,
|
||||
} from "@calcom/features/data-table";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
@@ -44,13 +49,9 @@ import {
|
||||
HoverCardPortal,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { ClearFilters } from "../filters/ClearFilters";
|
||||
import { DateSelect } from "../filters/DateSelect";
|
||||
import { RoutingFormResponsesDownload } from "../filters/Download";
|
||||
import { RoutingFormFilterList } from "../filters/RoutingFormFilterList";
|
||||
import { TeamAndSelfList } from "../filters/TeamAndSelfList";
|
||||
import { UserListInTeam } from "../filters/UserListInTeam";
|
||||
import type { OrgTeamsType } from "../filters/OrgTeamsFilter";
|
||||
import { OrgTeamsFilter } from "../filters/OrgTeamsFilter";
|
||||
import {
|
||||
ZResponseMultipleValues,
|
||||
ZResponseSingleValue,
|
||||
@@ -259,57 +260,65 @@ export function RoutingFormResponsesTable() {
|
||||
);
|
||||
}
|
||||
|
||||
const createdAtColumn: Extract<FilterableColumn, { type: "date_range" }> = {
|
||||
id: "createdAt",
|
||||
title: "createdAt",
|
||||
type: "date_range",
|
||||
};
|
||||
|
||||
export function RoutingFormResponsesTableContent() {
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
const { copyToClipboard } = useCopy();
|
||||
const session = useSession();
|
||||
const currentOrgId = session.data?.user.org?.id;
|
||||
const [orgTeamsType, setOrgTeamsType] = useState<OrgTeamsType>(currentOrgId ? "org" : "yours");
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<number | undefined>();
|
||||
|
||||
const {
|
||||
dateRange,
|
||||
selectedTeamId,
|
||||
isAll,
|
||||
initialConfig,
|
||||
selectedRoutingFormId,
|
||||
selectedMemberUserId,
|
||||
selectedUserId,
|
||||
} = filter;
|
||||
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
|
||||
const [startDate, endDate] = dateRange;
|
||||
const columnFilters = useColumnFilters({ exclude: ["bookingUserId", "formId", "createdAt"] });
|
||||
|
||||
const columnFilters = useColumnFilters();
|
||||
const isAll = orgTeamsType === "org";
|
||||
const teamId = orgTeamsType === "team" ? selectedTeamId : undefined;
|
||||
const userId = orgTeamsType === "yours" ? session.data?.user.id : undefined;
|
||||
|
||||
const teamId = selectedTeamId ?? undefined;
|
||||
const userId = selectedUserId ?? undefined;
|
||||
const memberUserId = selectedMemberUserId ?? undefined;
|
||||
const routingFormId = selectedRoutingFormId ?? undefined;
|
||||
const memberUserId = useFilterValue("bookingUserId", ZSingleSelectFilterValue)?.data as number | undefined;
|
||||
const routingFormId = useFilterValue("formId", ZSingleSelectFilterValue)?.data as string | undefined;
|
||||
const createdAtRange = useFilterValue("createdAt", ZDateRangeFilterValue)?.data;
|
||||
const startDate = createdAtRange?.startDate ?? dayjs().subtract(1, "week").startOf("day").toISOString();
|
||||
const endDate = createdAtRange?.endDate ?? dayjs().endOf("day").toISOString();
|
||||
|
||||
const {
|
||||
data: headers,
|
||||
isLoading: isHeadersLoading,
|
||||
isSuccess: isHeadersSuccess,
|
||||
} = trpc.viewer.insights.routingFormResponsesHeaders.useQuery(
|
||||
{
|
||||
userId,
|
||||
teamId,
|
||||
isAll: isAll ?? false,
|
||||
routingFormId,
|
||||
},
|
||||
{
|
||||
enabled: initialConfigIsReady,
|
||||
}
|
||||
);
|
||||
} = trpc.viewer.insights.routingFormResponsesHeaders.useQuery({
|
||||
userId,
|
||||
teamId,
|
||||
isAll,
|
||||
routingFormId,
|
||||
});
|
||||
|
||||
const { removeDisplayedExternalFilter, sorting, setSorting } = useDataTable();
|
||||
const { data: forms } = trpc.viewer.insights.getRoutingFormsForFilters.useQuery({
|
||||
userId,
|
||||
teamId,
|
||||
isAll,
|
||||
});
|
||||
|
||||
const { data: users } = trpc.viewer.insights.userList.useQuery({
|
||||
teamId: teamId ?? -1,
|
||||
isAll,
|
||||
});
|
||||
|
||||
const { sorting, setSorting } = useDataTable();
|
||||
|
||||
const { data, fetchNextPage, isFetching, hasNextPage, isLoading } =
|
||||
trpc.viewer.insights.routingFormResponses.useInfiniteQuery(
|
||||
{
|
||||
teamId,
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
startDate,
|
||||
endDate,
|
||||
userId,
|
||||
memberUserId,
|
||||
isAll: isAll ?? false,
|
||||
isAll,
|
||||
routingFormId,
|
||||
columnFilters,
|
||||
sorting,
|
||||
@@ -321,7 +330,6 @@ export function RoutingFormResponsesTableContent() {
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
enabled: initialConfigIsReady,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -337,6 +345,26 @@ export function RoutingFormResponsesTableContent() {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
columnHelper.accessor("formId", {
|
||||
id: "formId",
|
||||
header: t("routing_forms"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
filter: { type: "single_select" },
|
||||
},
|
||||
cell: () => null,
|
||||
}),
|
||||
columnHelper.accessor("bookingUserId", {
|
||||
id: "bookingUserId",
|
||||
header: t("people"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
filter: { type: "single_select" },
|
||||
},
|
||||
cell: () => null,
|
||||
}),
|
||||
columnHelper.accessor("bookingAttendees", {
|
||||
id: "bookingAttendees",
|
||||
header: t("routing_form_insights_booked_by"),
|
||||
@@ -500,6 +528,12 @@ export function RoutingFormResponsesTableContent() {
|
||||
defaultColumn: {
|
||||
size: 150,
|
||||
},
|
||||
initialState: {
|
||||
columnVisibility: {
|
||||
formId: false,
|
||||
bookingUserId: false,
|
||||
},
|
||||
},
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
@@ -510,41 +544,47 @@ export function RoutingFormResponsesTableContent() {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const fromArrayToMap = (array: { label: string; value: string | number }[]) => {
|
||||
return new Map(array.map((option) => [{ label: option.label, value: option.value }, 1]));
|
||||
};
|
||||
|
||||
const fieldHeader = headers.find((h) => h.id === columnId);
|
||||
if (fieldHeader?.options) {
|
||||
return new Map(fieldHeader.options.map((option) => [{ label: option.label, value: option.id }, 1]));
|
||||
return fromArrayToMap(
|
||||
fieldHeader.options
|
||||
.filter((option): option is { id: string; label: string } => option.id !== null)
|
||||
.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.id,
|
||||
}))
|
||||
);
|
||||
} else if (columnId === "bookingStatusOrder") {
|
||||
return new Map(
|
||||
Object.keys(statusOrder).map((status) => [
|
||||
{
|
||||
value: statusOrder[status as BookingStatus],
|
||||
label: bookingStatusToText(status as BookingStatus),
|
||||
},
|
||||
1,
|
||||
])
|
||||
return fromArrayToMap(
|
||||
Object.keys(statusOrder).map((status) => ({
|
||||
value: statusOrder[status as BookingStatus],
|
||||
label: bookingStatusToText(status as BookingStatus),
|
||||
}))
|
||||
);
|
||||
} else if (columnId === "formId") {
|
||||
return fromArrayToMap(
|
||||
forms?.map((form) => ({
|
||||
label: form.name,
|
||||
value: form.id,
|
||||
})) ?? []
|
||||
);
|
||||
} else if (columnId === "bookingUserId") {
|
||||
return fromArrayToMap(
|
||||
users?.map((user) => ({
|
||||
label: user.name ?? user.email,
|
||||
value: user.id,
|
||||
})) ?? []
|
||||
);
|
||||
}
|
||||
return new Map();
|
||||
},
|
||||
});
|
||||
|
||||
const externalFilters = useMemo<ExternalFilter[]>(
|
||||
() => [
|
||||
{
|
||||
key: "memberUserId",
|
||||
titleKey: "people",
|
||||
component: () => (
|
||||
<UserListInTeam
|
||||
showOnlyWhenSelectedInContext={false}
|
||||
onClear={() => removeDisplayedExternalFilter("memberUserId")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[removeDisplayedExternalFilter]
|
||||
);
|
||||
|
||||
if (isHeadersLoading || ((isFetching || isLoading) && !data)) {
|
||||
if ((isHeadersLoading && !headers) || ((isFetching || isLoading) && !data)) {
|
||||
return <DataTableSkeleton columns={4} columnWidths={[200, 200, 250, 250]} />;
|
||||
}
|
||||
|
||||
@@ -558,16 +598,22 @@ export function RoutingFormResponsesTableContent() {
|
||||
isFetching={isFetching}
|
||||
ToolbarLeft={
|
||||
<>
|
||||
<TeamAndSelfList omitOrg={true} className="mb-0" />
|
||||
<DataTableFilters.AddFilterButton table={table} externalFilters={externalFilters} />
|
||||
<RoutingFormFilterList showOnlyWhenSelectedInContext={false} />
|
||||
<DataTableFilters.ActiveFilters table={table} externalFilters={externalFilters} />
|
||||
<ClearFilters />
|
||||
<OrgTeamsFilter
|
||||
selectedType={orgTeamsType}
|
||||
selectedTeamId={selectedTeamId}
|
||||
onSelected={(params) => {
|
||||
setOrgTeamsType(params.type);
|
||||
setSelectedTeamId(params.teamId);
|
||||
}}
|
||||
/>
|
||||
<DataTableFilters.AddFilterButton table={table} />
|
||||
<DataTableFilters.ActiveFilters table={table} />
|
||||
<DataTableFilters.ClearFiltersButton exclude={["createdAt"]} />
|
||||
</>
|
||||
}
|
||||
ToolbarRight={
|
||||
<>
|
||||
<DateSelect />
|
||||
<DateRangeFilter column={createdAtColumn} />
|
||||
<RoutingFormResponsesDownload
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
@@ -582,7 +628,7 @@ export function RoutingFormResponsesTableContent() {
|
||||
<DataTableFilters.ColumnVisibilityButton table={table} />
|
||||
</>
|
||||
}>
|
||||
<RoutingKPICards />
|
||||
<RoutingKPICards given={{ isAll, teamId, userId }} />
|
||||
</DataTableWrapper>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,26 +8,31 @@ import { useFilterContext } from "../context/provider";
|
||||
import { valueFormatter } from "../lib";
|
||||
import { CardInsights } from "./Card";
|
||||
|
||||
export const RoutingKPICards = () => {
|
||||
export const RoutingKPICards = ({
|
||||
given,
|
||||
}: {
|
||||
given?: { isAll: boolean; teamId: number | undefined; userId: number | undefined };
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
|
||||
const userId = given?.userId ?? filter.selectedUserId;
|
||||
const isAll = given?.isAll ?? filter.isAll;
|
||||
const teamId = given?.teamId ?? filter.selectedTeamId;
|
||||
|
||||
const {
|
||||
dateRange,
|
||||
selectedEventTypeId,
|
||||
selectedUserId,
|
||||
selectedMemberUserId,
|
||||
isAll,
|
||||
initialConfig,
|
||||
selectedRoutingFormId,
|
||||
selectedBookingStatus,
|
||||
selectedRoutingFormFilter,
|
||||
} = filter;
|
||||
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
|
||||
const initialConfigIsReady =
|
||||
Boolean(given) || !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
|
||||
const [startDate, endDate] = dateRange;
|
||||
|
||||
const { selectedTeamId: teamId } = filter;
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.routingFormsByStatus.useQuery(
|
||||
{
|
||||
startDate: startDate.toISOString(),
|
||||
@@ -36,7 +41,7 @@ export const RoutingKPICards = () => {
|
||||
eventTypeId: selectedEventTypeId ?? undefined,
|
||||
isAll,
|
||||
routingFormId: selectedRoutingFormId ?? undefined,
|
||||
userId: selectedUserId ?? undefined,
|
||||
userId,
|
||||
memberUserId: selectedMemberUserId ?? undefined,
|
||||
bookingStatus: selectedBookingStatus ?? undefined,
|
||||
fieldFilter: selectedRoutingFormFilter ?? undefined,
|
||||
@@ -72,7 +77,7 @@ export const RoutingKPICards = () => {
|
||||
return <LoadingKPICards categories={categories} />;
|
||||
}
|
||||
|
||||
if (!isSuccess || !startDate || !endDate || (!teamId && !selectedUserId)) return null;
|
||||
if (!isSuccess || !startDate || !endDate || (!teamId && !userId)) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { ColumnFilter, SortingState } from "@calcom/features/data-table";
|
||||
import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -17,8 +17,8 @@ type Props = {
|
||||
memberUserId: number | undefined;
|
||||
routingFormId: string | undefined;
|
||||
isAll: boolean;
|
||||
startDate: Dayjs;
|
||||
endDate: Dayjs;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
columnFilters: ColumnFilter[];
|
||||
sorting: SortingState;
|
||||
};
|
||||
@@ -52,8 +52,8 @@ export const RoutingFormResponsesDownload = ({
|
||||
}> => {
|
||||
const { data, nextCursor } = await utils.viewer.insights.routingFormResponsesForDownload.fetch({
|
||||
teamId,
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
startDate,
|
||||
endDate,
|
||||
userId,
|
||||
memberUserId,
|
||||
isAll: isAll ?? false,
|
||||
@@ -84,9 +84,9 @@ export const RoutingFormResponsesDownload = ({
|
||||
}
|
||||
|
||||
if (allData.length > 0) {
|
||||
const filename = `RoutingFormResponses-${startDate.format("YYYY-MM-DD")}-${endDate.format(
|
||||
"YYYY-MM-DD"
|
||||
)}.csv`;
|
||||
const filename = `RoutingFormResponses-${dayjs(startDate).format("YYYY-MM-DD")}-${dayjs(
|
||||
endDate
|
||||
).format("YYYY-MM-DD")}.csv`;
|
||||
downloadAsCsv(allData as Record<string, unknown>[], filename);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
FilterCheckboxField,
|
||||
FilterCheckboxFieldsContainer,
|
||||
} from "@calcom/features/filters/components/TeamsFilter";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { AnimatedPopover, Avatar, Divider, Icon, FilterSearchField } from "@calcom/ui";
|
||||
|
||||
export type OrgTeamsType = "org" | "team" | "yours";
|
||||
|
||||
// This is a clone of TeamAndSelfList, but without useFilterContext().
|
||||
// It's meant to be used in the RoutingFormResponsesTable.
|
||||
export const OrgTeamsFilter = ({
|
||||
selectedType,
|
||||
selectedTeamId,
|
||||
onSelected,
|
||||
}: {
|
||||
selectedType: OrgTeamsType;
|
||||
selectedTeamId?: number;
|
||||
onSelected: (params: { type: OrgTeamsType; teamId?: number }) => void;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const currentOrgId = session.data?.user.org?.id;
|
||||
const currentUserId = session.data?.user.id;
|
||||
const currentUserName = session.data?.user.name;
|
||||
|
||||
const [query, setQuery] = useState<string>("");
|
||||
|
||||
const { data, isSuccess } = trpc.viewer.insights.teamListForUser.useQuery(undefined, {
|
||||
// Teams don't change that frequently
|
||||
refetchOnWindowFocus: false,
|
||||
trpc: {
|
||||
context: {
|
||||
skipBatch: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const getPopoverProps = () => {
|
||||
if (selectedType === "org") {
|
||||
return { text: t("all"), placeholder: undefined, imageUrl: data?.[0].logoUrl };
|
||||
} else if (selectedType === "yours") {
|
||||
return { text: t("yours"), placeholder: currentUserName, imageUrl: session.data?.user.avatarUrl };
|
||||
} else if (selectedType === "team") {
|
||||
const selectedTeam = data?.find((item) => {
|
||||
return item.id === selectedTeamId;
|
||||
});
|
||||
return {
|
||||
text: `${t("team")}: ${selectedTeam?.name}`,
|
||||
placeholder: selectedTeam?.name,
|
||||
imageUrl: selectedTeam?.logoUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return { text: t("select"), imageUrl: undefined };
|
||||
};
|
||||
|
||||
const { text, placeholder, imageUrl } = getPopoverProps();
|
||||
const isOrgDataAvailable = !!data && data.length > 0 && !!data[0].isOrg;
|
||||
|
||||
const PrefixComponent =
|
||||
selectedType !== undefined && (imageUrl || placeholder) ? (
|
||||
<Avatar
|
||||
alt={`${placeholder} logo`}
|
||||
imageSrc={getPlaceholderAvatar(imageUrl, placeholder)}
|
||||
size="xsm"
|
||||
className="mr-2"
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<AnimatedPopover text={text} PrefixComponent={PrefixComponent} popoverTriggerClassNames="mb-0">
|
||||
<FilterCheckboxFieldsContainer>
|
||||
<FilterSearchField
|
||||
placeholder={t("search")}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<Divider />
|
||||
|
||||
{isOrgDataAvailable && (
|
||||
<FilterCheckboxField
|
||||
id="all"
|
||||
icon={<Icon name="layers" className="h-4 w-4" />}
|
||||
checked={selectedType === "org"}
|
||||
onChange={(e) => {
|
||||
onSelected({ type: "org", teamId: undefined });
|
||||
}}
|
||||
label={t("all")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FilterCheckboxField
|
||||
id="yours"
|
||||
icon={
|
||||
<Avatar
|
||||
alt={`${currentUserName} avatar`}
|
||||
imageSrc={getPlaceholderAvatar(session.data?.user.avatarUrl, currentUserName)}
|
||||
size="xsm"
|
||||
/>
|
||||
}
|
||||
checked={selectedType === "yours"}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
onSelected({ type: "yours", teamId: undefined });
|
||||
} else if (!e.target.checked) {
|
||||
onSelected({ type: currentOrgId ? "org" : "yours", teamId: undefined });
|
||||
}
|
||||
}}
|
||||
label={t("yours")}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
{data
|
||||
?.filter((team) => !team.isOrg)
|
||||
.filter((team) => team.name?.toLowerCase().includes(query.toLowerCase()))
|
||||
.map((team) => {
|
||||
return (
|
||||
<FilterCheckboxField
|
||||
key={team.id}
|
||||
id={team.name || ""}
|
||||
label={team.name || ""}
|
||||
checked={selectedTeamId === team.id && selectedType === "team"}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
onSelected({ type: "team", teamId: team.id });
|
||||
} else if (!e.target.checked) {
|
||||
onSelected({ type: currentOrgId ? "org" : "yours", teamId: undefined });
|
||||
}
|
||||
}}
|
||||
icon={
|
||||
<Avatar
|
||||
alt={team.name || ""}
|
||||
imageSrc={getPlaceholderAvatar(team.logoUrl, team.name)}
|
||||
size="xs"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</FilterCheckboxFieldsContainer>
|
||||
</AnimatedPopover>
|
||||
);
|
||||
};
|
||||
@@ -16,38 +16,36 @@ function buildFilterOptions(forms: Form[]) {
|
||||
}));
|
||||
}
|
||||
|
||||
export const RoutingFormFilterList = memo(
|
||||
({ showOnlyWhenSelectedInContext = true }: { showOnlyWhenSelectedInContext?: boolean }) => {
|
||||
const { t } = useLocale();
|
||||
const { filter, setConfigFilters } = useFilterContext();
|
||||
const { selectedTeamId, selectedUserId, selectedRoutingFormId, isAll } = filter;
|
||||
const { selectedFilter } = filter;
|
||||
export const RoutingFormFilterList = memo(() => {
|
||||
const { t } = useLocale();
|
||||
const { filter, setConfigFilters } = useFilterContext();
|
||||
const { selectedTeamId, selectedUserId, selectedRoutingFormId, isAll } = filter;
|
||||
const { selectedFilter } = filter;
|
||||
|
||||
const { data: allForms } = trpc.viewer.insights.getRoutingFormsForFilters.useQuery(
|
||||
{
|
||||
userId: selectedUserId ?? undefined,
|
||||
teamId: selectedTeamId ?? undefined,
|
||||
isAll: isAll ?? false,
|
||||
},
|
||||
{
|
||||
enabled: selectedFilter?.includes("routing_forms"),
|
||||
}
|
||||
);
|
||||
const { data: allForms } = trpc.viewer.insights.getRoutingFormsForFilters.useQuery(
|
||||
{
|
||||
userId: selectedUserId ?? undefined,
|
||||
teamId: selectedTeamId ?? undefined,
|
||||
isAll: isAll ?? false,
|
||||
},
|
||||
{
|
||||
enabled: selectedFilter?.includes("routing_forms"),
|
||||
}
|
||||
);
|
||||
|
||||
if (showOnlyWhenSelectedInContext && !selectedFilter?.includes("routing_forms")) return null;
|
||||
if (!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";
|
||||
|
||||
@@ -13,13 +13,7 @@ const mapUserToOption = (user: User) => ({
|
||||
icon: <Avatar alt={`${user.name} avatar`} imageSrc={user.avatarUrl} size="sm" className="mr-2" />,
|
||||
});
|
||||
|
||||
export const UserListInTeam = ({
|
||||
showOnlyWhenSelectedInContext = true,
|
||||
onClear,
|
||||
}: {
|
||||
showOnlyWhenSelectedInContext?: boolean;
|
||||
onClear?: () => void;
|
||||
}) => {
|
||||
export const UserListInTeam = ({ onClear }: { onClear?: () => void }) => {
|
||||
const { t } = useLocale();
|
||||
const { filter, setConfigFilters } = useFilterContext();
|
||||
const { selectedFilter, selectedTeamId, selectedMemberUserId, isAll } = filter;
|
||||
@@ -29,7 +23,7 @@ export const UserListInTeam = ({
|
||||
isAll: !!isAll,
|
||||
});
|
||||
|
||||
if (showOnlyWhenSelectedInContext && !selectedFilter?.includes("user")) {
|
||||
if (!selectedFilter?.includes("user")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -912,38 +912,11 @@ export const insightsRouter = router({
|
||||
return [];
|
||||
}
|
||||
|
||||
const membershipConditional: Prisma.MembershipWhereInput = {
|
||||
team: {
|
||||
slug: { not: null },
|
||||
},
|
||||
accepted: true,
|
||||
userId: user.id,
|
||||
OR: [
|
||||
{
|
||||
role: "ADMIN",
|
||||
},
|
||||
{
|
||||
role: "OWNER",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Validate if user belongs to org as admin/owner
|
||||
if (user.organizationId && user.organization.isOrgAdmin) {
|
||||
const teamsFromOrg = await ctx.insightsDb.team.findMany({
|
||||
const teamsAndOrg = await ctx.insightsDb.team.findMany({
|
||||
where: {
|
||||
parentId: user.organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
logoUrl: true,
|
||||
},
|
||||
});
|
||||
const orgTeam = await ctx.insightsDb.team.findUnique({
|
||||
where: {
|
||||
id: user.organizationId,
|
||||
OR: [{ parentId: user.organizationId }, { id: user.organizationId }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -952,25 +925,18 @@ export const insightsRouter = router({
|
||||
logoUrl: true,
|
||||
},
|
||||
});
|
||||
const teamsFromOrg = teamsAndOrg.filter((team) => team.id !== user.organizationId);
|
||||
const orgTeam = teamsAndOrg.find((team) => team.id === user.organizationId);
|
||||
if (!orgTeam) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: IResultTeamList[] = [
|
||||
{
|
||||
id: orgTeam.id,
|
||||
slug: orgTeam.slug,
|
||||
name: orgTeam.name,
|
||||
logoUrl: orgTeam.logoUrl,
|
||||
...orgTeam,
|
||||
isOrg: true,
|
||||
},
|
||||
...teamsFromOrg.map(
|
||||
(team: Prisma.TeamGetPayload<{ select: { id: true; slug: true; name: true; logoUrl: true } }>) => {
|
||||
return {
|
||||
...team,
|
||||
};
|
||||
}
|
||||
),
|
||||
...teamsFromOrg,
|
||||
];
|
||||
|
||||
return result;
|
||||
@@ -978,7 +944,21 @@ export const insightsRouter = router({
|
||||
|
||||
// Look if user it's admin/owner in multiple teams
|
||||
const belongsToTeams = await ctx.insightsDb.membership.findMany({
|
||||
where: membershipConditional,
|
||||
where: {
|
||||
team: {
|
||||
slug: { not: null },
|
||||
},
|
||||
accepted: true,
|
||||
userId: user.id,
|
||||
OR: [
|
||||
{
|
||||
role: "ADMIN",
|
||||
},
|
||||
{
|
||||
role: "OWNER",
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
|
||||
@@ -141,4 +141,6 @@ export const lucideIconList = new Set([
|
||||
"zap",
|
||||
"waypoints",
|
||||
"chevrons-down-up",
|
||||
"sliders-horizontal",
|
||||
"calendar-range",
|
||||
]);
|
||||
|
||||
@@ -23,6 +23,7 @@ export type IconName =
|
||||
| "calendar-check-2"
|
||||
| "calendar-days"
|
||||
| "calendar-heart"
|
||||
| "calendar-range"
|
||||
| "calendar-search"
|
||||
| "calendar-x-2"
|
||||
| "calendar"
|
||||
@@ -114,6 +115,7 @@ export type IconName =
|
||||
| "shield-check"
|
||||
| "shield"
|
||||
| "shuffle"
|
||||
| "sliders-horizontal"
|
||||
| "sliders-vertical"
|
||||
| "smartphone"
|
||||
| "sparkles"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Tooltip } from "../tooltip";
|
||||
|
||||
export const AnimatedPopover = ({
|
||||
text,
|
||||
PrefixComponent,
|
||||
count,
|
||||
popoverTriggerClassNames,
|
||||
children,
|
||||
@@ -16,6 +17,7 @@ export const AnimatedPopover = ({
|
||||
prefix,
|
||||
}: {
|
||||
text: string;
|
||||
PrefixComponent?: React.ReactNode;
|
||||
count?: number;
|
||||
children: React.ReactNode;
|
||||
popoverTriggerClassNames?: string;
|
||||
@@ -62,6 +64,7 @@ export const AnimatedPopover = ({
|
||||
<div className="max-w-36 flex items-center">
|
||||
<Tooltip content={prefix ? `${prefix}${text}` : text}>
|
||||
<div className="flex select-none truncate font-medium">
|
||||
{PrefixComponent ? PrefixComponent : null}
|
||||
{prefix && <span className="text-subtle">{prefix} </span>}
|
||||
{text}
|
||||
{count && count > 0 && (
|
||||
|
||||
Reference in New Issue
Block a user