fix(bookings): enable past date selection for cancelled bookings (#25644)
* refactor(data-table): clean up DateRangeFilter range options - Replace 'past' | 'custom' with 'past' | 'future' | 'any' | 'customOnly' - Add direction field to PresetOption for preset compatibility filtering - Derive presets visibility automatically based on compatible presets - Update bookings list to use new range values: - past -> 'past' - upcoming -> 'future' - unconfirmed/recurring/cancelled -> 'any' Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * feat(playground): add DateRangeFilter playground page with E2E tests - Add playground page at /settings/admin/playground/date-range-filter - Demonstrate all 4 range options: past, future, any, customOnly - Add link to playground index page - Add E2E tests for presets visibility and date restrictions Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix(playground): use correct meta.filter pattern for column filter config Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * clean up the playground esign * add unit tests instead of e2e * fix the implementation --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
eunjae@cal.com <hey@eunjae.dev>
eunjae@cal.com <hey@eunjae.dev>
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
dd27d077ca
commit
aaaff0705b
+150
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useReactTable, getCoreRowModel, createColumnHelper } from "@tanstack/react-table";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { ColumnFilterType, DateRangeFilter, DataTableProvider } from "@calcom/features/data-table";
|
||||
import type { DateRangeFilterOptions } from "@calcom/features/data-table/lib/types";
|
||||
|
||||
type DemoRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
date: string;
|
||||
};
|
||||
|
||||
const columnHelper = createColumnHelper<DemoRow>();
|
||||
|
||||
type ScenarioProps = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
expected: string;
|
||||
range: DateRangeFilterOptions["range"];
|
||||
};
|
||||
|
||||
const scenarios: ScenarioProps[] = [
|
||||
{
|
||||
id: "past",
|
||||
title: 'Range: "past"',
|
||||
description: "Restricts date selection to past dates only. Shows presets that are past-compatible.",
|
||||
expected:
|
||||
"Presets visible: Today, Last 7 days, Last 30 days, Month to date, Year to date, Custom. Calendar maxDate = today.",
|
||||
range: "past",
|
||||
},
|
||||
{
|
||||
id: "future",
|
||||
title: 'Range: "future"',
|
||||
description: "Restricts date selection to future dates only. Shows only future-compatible presets.",
|
||||
expected: "Presets visible: Custom only (presets with direction 'any'). Calendar minDate = today.",
|
||||
range: "future",
|
||||
},
|
||||
{
|
||||
id: "any",
|
||||
title: 'Range: "any"',
|
||||
description: "No date restrictions. Shows all presets.",
|
||||
expected: "All presets visible. No calendar date restrictions.",
|
||||
range: "any",
|
||||
},
|
||||
{
|
||||
id: "customOnly",
|
||||
title: 'Range: "customOnly"',
|
||||
description: "Forces custom date picker only. Always hides presets dropdown.",
|
||||
expected: "No presets dropdown. Only calendar picker visible when opened. No date restrictions.",
|
||||
range: "customOnly",
|
||||
},
|
||||
];
|
||||
|
||||
function ScenarioCard({ scenario }: { scenario: ScenarioProps }) {
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("name", {
|
||||
header: "Name",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("date", {
|
||||
id: "dateRange",
|
||||
header: "Date Range",
|
||||
cell: (info) => info.getValue(),
|
||||
enableColumnFilter: true,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
dateRangeOptions: {
|
||||
range: scenario.range,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
[scenario.range]
|
||||
);
|
||||
|
||||
const data = useMemo<DemoRow[]>(
|
||||
() => [
|
||||
{ id: 1, name: "Demo Item 1", date: "2024-01-15" },
|
||||
{ id: 2, name: "Demo Item 2", date: "2024-02-20" },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
// Get the column definition to pass to DateRangeFilter
|
||||
const dateRangeColumn = table.getAllColumns().find((col) => col.id === "dateRange");
|
||||
const columnMeta = dateRangeColumn?.columnDef.meta as
|
||||
| { filter?: { type: string; dateRangeOptions?: DateRangeFilterOptions } }
|
||||
| undefined;
|
||||
const dateRangeOptions = columnMeta?.filter?.dateRangeOptions;
|
||||
|
||||
return (
|
||||
<div className="border-subtle mb-8 rounded-lg border p-6" data-testid={`drf-scenario-${scenario.id}`}>
|
||||
<h3 className="text-emphasis mb-2 text-lg font-semibold">{scenario.title}</h3>
|
||||
<p className="text-default mb-2 text-sm">{scenario.description}</p>
|
||||
<p className="text-subtle mb-4 text-xs">
|
||||
<strong>Expected:</strong> {scenario.expected}
|
||||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<DataTableProvider tableIdentifier={`playground-date-range-${scenario.id}`}>
|
||||
{dateRangeColumn && (
|
||||
<DateRangeFilter
|
||||
column={{
|
||||
id: dateRangeColumn.id,
|
||||
title: dateRangeColumn.columnDef.header as string,
|
||||
type: "dr",
|
||||
}}
|
||||
options={dateRangeOptions}
|
||||
showColumnName={false}
|
||||
showClearButton={false}
|
||||
/>
|
||||
)}
|
||||
</DataTableProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DateRangeFilterPlayground() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-emphasis text-2xl font-bold">DateRangeFilter Playground</h1>
|
||||
<p className="text-default mt-2">
|
||||
This page demonstrates the different <code>range</code> options for the DateRangeFilter component.
|
||||
</p>
|
||||
<p className="text-subtle mt-1 text-sm">
|
||||
The <code>range</code> option controls both date restrictions and presets visibility. Presets
|
||||
visibility is derived automatically based on compatible presets.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{scenarios.map((scenario) => (
|
||||
<ScenarioCard key={scenario.id} scenario={scenario} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,19 +2,32 @@ import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import Link from "next/link";
|
||||
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
const LINKS = [
|
||||
{
|
||||
title: "Routing Funnel",
|
||||
description: "Visualize booking conversion flow and routing patterns",
|
||||
href: "/settings/admin/playground/routing-funnel",
|
||||
icon: "filter" as const,
|
||||
},
|
||||
{
|
||||
title: "Bookings by Hour",
|
||||
description: "View booking distribution across different hours",
|
||||
href: "/settings/admin/playground/bookings-by-hour",
|
||||
icon: "chart-bar" as const,
|
||||
},
|
||||
{
|
||||
title: "Weekly Calendar",
|
||||
description: "Interactive weekly calendar view for scheduling",
|
||||
href: "/settings/admin/playground/weekly-calendar",
|
||||
icon: "calendar" as const,
|
||||
},
|
||||
{
|
||||
title: "Date Range Filter",
|
||||
description: "Test date range selection and filtering components",
|
||||
href: "/settings/admin/playground/date-range-filter",
|
||||
icon: "calendar-days" as const,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -31,16 +44,27 @@ const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<SettingsHeader title={t("playground")} description={t("admin_playground_description")}>
|
||||
<div>
|
||||
<ul className="mt-8">
|
||||
<div className="mt-6">
|
||||
<div className="bg-default border-subtle divide-subtle flex flex-col divide-y rounded-md border">
|
||||
{LINKS.map((link) => (
|
||||
<li key={link.title}>
|
||||
<Link href={link.href} className="list-item list-disc font-medium underline">
|
||||
{link.title} →
|
||||
</Link>
|
||||
</li>
|
||||
<Link
|
||||
key={link.title}
|
||||
href={link.href}
|
||||
className="hover:bg-muted group flex items-center gap-4 p-5 transition-colors">
|
||||
<div className="bg-emphasis/10 group-hover:bg-emphasis/20 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md transition-colors">
|
||||
<Icon name={link.icon} className="text-emphasis h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-emphasis text-sm font-semibold leading-none">{link.title}</h3>
|
||||
<p className="text-subtle mt-2 text-sm">{link.description}</p>
|
||||
</div>
|
||||
<Icon
|
||||
name="arrow-right"
|
||||
className="text-subtle group-hover:text-emphasis h-4 w-4 flex-shrink-0 transition-colors"
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsHeader>
|
||||
);
|
||||
|
||||
@@ -109,7 +109,7 @@ export function buildFilterColumns({ t, permissions, status }: BuildFilterColumn
|
||||
filter: {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
dateRangeOptions: {
|
||||
range: status === "past" ? "past" : "custom",
|
||||
range: status === "past" ? "past" : status === "cancelled" ? "any" : "future", // upcoming, unconfirmed, recurring are all future-only
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -98,7 +98,7 @@ export function useBookingListColumns({
|
||||
filter: {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
dateRangeOptions: {
|
||||
range: status === "past" ? "past" : "custom",
|
||||
range: status === "past" ? "past" : status === "cancelled" ? "any" : "future", // upcoming, unconfirmed, recurring are all future-only
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -24,10 +24,10 @@ import {
|
||||
CUSTOM_PRESET,
|
||||
CUSTOM_PRESET_VALUE,
|
||||
DEFAULT_PRESET,
|
||||
PRESET_OPTIONS,
|
||||
getDefaultStartDate,
|
||||
getDefaultEndDate,
|
||||
getDateRangeFromPreset,
|
||||
getCompatiblePresets,
|
||||
type PresetOption,
|
||||
} from "../../lib/dateRange";
|
||||
import { preserveLocalTime } from "../../lib/preserveLocalTime";
|
||||
@@ -53,8 +53,10 @@ export const DateRangeFilter = ({
|
||||
const filterValue = useFilterValue(column.id, ZDateRangeFilterValue);
|
||||
const { updateFilter, removeFilter, timeZone: givenTimeZone } = useDataTable();
|
||||
const range = options?.range ?? "past";
|
||||
const forceCustom = range === "custom";
|
||||
const forcePast = range === "past";
|
||||
|
||||
const compatiblePresets = getCompatiblePresets(range);
|
||||
const showPresets = compatiblePresets.length > 1;
|
||||
const forceCustomOnly = range === "customOnly" || !showPresets;
|
||||
|
||||
const { t } = useLocale();
|
||||
const currentDate = dayjs();
|
||||
@@ -65,10 +67,10 @@ export const DateRangeFilter = ({
|
||||
filterValue?.data.endDate ? dayjs(filterValue.data.endDate) : undefined
|
||||
);
|
||||
const [selectedPreset, setSelectedPreset] = useState<PresetOption>(
|
||||
forceCustom
|
||||
forceCustomOnly
|
||||
? CUSTOM_PRESET
|
||||
: filterValue?.data.preset
|
||||
? PRESET_OPTIONS.find((o) => o.value === filterValue.data.preset) ?? DEFAULT_PRESET
|
||||
? compatiblePresets.find((o) => o.value === filterValue.data.preset) ?? DEFAULT_PRESET
|
||||
: DEFAULT_PRESET
|
||||
);
|
||||
|
||||
@@ -108,14 +110,14 @@ export const DateRangeFilter = ({
|
||||
useEffect(() => {
|
||||
// initially apply the default value
|
||||
// if the query param is not set yet
|
||||
if (!filterValue && !forceCustom) {
|
||||
if (!filterValue && !forceCustomOnly) {
|
||||
updateValues({
|
||||
preset: DEFAULT_PRESET,
|
||||
startDate: getDefaultStartDate(),
|
||||
endDate: getDefaultEndDate(),
|
||||
});
|
||||
}
|
||||
}, [filterValue, forceCustom, updateValues]);
|
||||
}, [filterValue, forceCustomOnly, updateValues]);
|
||||
|
||||
const updateDateRangeFromPreset = (val: string | null) => {
|
||||
if (val === CUSTOM_PRESET_VALUE) {
|
||||
@@ -195,13 +197,19 @@ export const DateRangeFilter = ({
|
||||
endDate: endDate?.toDate(),
|
||||
}}
|
||||
data-testid="date-range-calendar"
|
||||
minDate={forcePast ? currentDate.subtract(2, "year").toDate() : null}
|
||||
maxDate={forcePast ? currentDate.toDate() : undefined}
|
||||
minDate={
|
||||
range === "past"
|
||||
? currentDate.subtract(2, "year").toDate()
|
||||
: range === "future"
|
||||
? currentDate.toDate()
|
||||
: null
|
||||
}
|
||||
maxDate={range === "past" ? currentDate.toDate() : undefined}
|
||||
disabled={false}
|
||||
onDatesChange={updateDateRangeFromPicker}
|
||||
withoutPopover={true}
|
||||
/>
|
||||
{forceCustom && (
|
||||
{forceCustomOnly && (
|
||||
<div className="border-subtle border-t px-2 py-3">
|
||||
<Button
|
||||
color="secondary"
|
||||
@@ -213,10 +221,10 @@ export const DateRangeFilter = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!forceCustom && (
|
||||
{showPresets && (
|
||||
<Command className={classNames("w-40", isCustomPreset && "rounded-b-none")}>
|
||||
<CommandList>
|
||||
{PRESET_OPTIONS.map((option) => (
|
||||
{compatiblePresets.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
data-testid={`date-range-options-${option.value}`}
|
||||
|
||||
@@ -5,6 +5,7 @@ import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
getDateRangeFromPreset,
|
||||
recalculateDateRange,
|
||||
getCompatiblePresets,
|
||||
PRESET_OPTIONS,
|
||||
CUSTOM_PRESET,
|
||||
DEFAULT_PRESET,
|
||||
@@ -111,4 +112,55 @@ describe("PRESET_OPTIONS", () => {
|
||||
expect(CUSTOM_PRESET.value).toBe(CUSTOM_PRESET_VALUE);
|
||||
expect(CUSTOM_PRESET.labelKey).toBe("custom_range");
|
||||
});
|
||||
|
||||
it("should have correct direction for each preset", () => {
|
||||
expect(PRESET_OPTIONS[0].direction).toBe("past"); // Today
|
||||
expect(PRESET_OPTIONS[1].direction).toBe("past"); // Last 7 days
|
||||
expect(PRESET_OPTIONS[2].direction).toBe("past"); // Last 30 days
|
||||
expect(PRESET_OPTIONS[3].direction).toBe("past"); // Month to date
|
||||
expect(PRESET_OPTIONS[4].direction).toBe("past"); // Year to date
|
||||
expect(PRESET_OPTIONS[5].direction).toBe("any"); // Custom
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCompatiblePresets", () => {
|
||||
it("should return empty array for customOnly range", () => {
|
||||
const result = getCompatiblePresets("customOnly");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return all presets for any range", () => {
|
||||
const result = getCompatiblePresets("any");
|
||||
expect(result.length).toBe(6); // All 6 presets
|
||||
expect(result.map((p) => p.value)).toEqual(["tdy", "w", "t", "m", "y", "c"]);
|
||||
});
|
||||
|
||||
it("should return all presets for past range (including direction:any)", () => {
|
||||
const result = getCompatiblePresets("past");
|
||||
expect(result.length).toBe(6); // All 6 presets (5 past + 1 any)
|
||||
// Should include both "past" direction and "any" direction presets
|
||||
const directions = result.map((p) => p.direction);
|
||||
expect(directions).toContain("past");
|
||||
expect(directions).toContain("any");
|
||||
expect(result.map((p) => p.value)).toEqual(["tdy", "w", "t", "m", "y", "c"]);
|
||||
});
|
||||
|
||||
it("should return only any-direction presets for future range", () => {
|
||||
const result = getCompatiblePresets("future");
|
||||
expect(result.length).toBe(1); // Only Custom (Today is now past-direction)
|
||||
expect(result.map((p) => p.value)).toEqual(["c"]);
|
||||
// All returned presets should have direction "any"
|
||||
expect(result.every((p) => p.direction === "any")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not return past-only presets for future range", () => {
|
||||
const result = getCompatiblePresets("future");
|
||||
const values = result.map((p) => p.value);
|
||||
// Should NOT include past-only presets (including Today)
|
||||
expect(values).not.toContain("tdy"); // Today (now past-direction)
|
||||
expect(values).not.toContain("w"); // Last 7 days
|
||||
expect(values).not.toContain("t"); // Last 30 days
|
||||
expect(values).not.toContain("m"); // Month to date
|
||||
expect(values).not.toContain("y"); // Year to date
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,13 @@ import { ColumnFilterType, type DateRangeFilterValue } from "./types";
|
||||
|
||||
export type PresetOptionValue = "c" | "w" | "m" | "y" | "t" | "tdy";
|
||||
|
||||
export type PresetDirection = "past" | "future" | "any";
|
||||
|
||||
export type PresetOption = {
|
||||
labelKey: string;
|
||||
i18nOptions?: Record<string, string | number>;
|
||||
value: PresetOptionValue;
|
||||
direction: PresetDirection;
|
||||
};
|
||||
|
||||
export const CUSTOM_PRESET_VALUE = "c" as const;
|
||||
@@ -16,18 +19,34 @@ export const DEFAULT_PRESET: PresetOption = {
|
||||
labelKey: "last_number_of_days",
|
||||
i18nOptions: { count: 7 },
|
||||
value: "w",
|
||||
direction: "past",
|
||||
};
|
||||
export const CUSTOM_PRESET: PresetOption = {
|
||||
labelKey: "custom_range",
|
||||
value: CUSTOM_PRESET_VALUE,
|
||||
direction: "any",
|
||||
};
|
||||
export const CUSTOM_PRESET: PresetOption = { labelKey: "custom_range", value: CUSTOM_PRESET_VALUE };
|
||||
|
||||
export const PRESET_OPTIONS: PresetOption[] = [
|
||||
{ labelKey: "today", value: "tdy" },
|
||||
{ labelKey: "today", value: "tdy", direction: "past" },
|
||||
DEFAULT_PRESET,
|
||||
{ labelKey: "last_number_of_days", i18nOptions: { count: 30 }, value: "t" },
|
||||
{ labelKey: "month_to_date", value: "m" },
|
||||
{ labelKey: "year_to_date", value: "y" },
|
||||
{ labelKey: "last_number_of_days", i18nOptions: { count: 30 }, value: "t", direction: "past" },
|
||||
{ labelKey: "month_to_date", value: "m", direction: "past" },
|
||||
{ labelKey: "year_to_date", value: "y", direction: "past" },
|
||||
CUSTOM_PRESET,
|
||||
];
|
||||
|
||||
export const getCompatiblePresets = (range: "past" | "future" | "any" | "customOnly"): PresetOption[] => {
|
||||
if (range === "customOnly") {
|
||||
return [];
|
||||
}
|
||||
return PRESET_OPTIONS.filter((preset) => {
|
||||
if (preset.direction === "any") return true;
|
||||
if (range === "any") return true;
|
||||
return preset.direction === range;
|
||||
});
|
||||
};
|
||||
|
||||
export const getDefaultStartDate = () => dayjs().subtract(6, "day").startOf("day");
|
||||
|
||||
export const getDefaultEndDate = () => dayjs().endOf("day");
|
||||
|
||||
@@ -118,7 +118,7 @@ export const ZFilterValue = z.union([
|
||||
]);
|
||||
|
||||
export type DateRangeFilterOptions = {
|
||||
range?: "past" | "custom";
|
||||
range?: "past" | "future" | "any" | "customOnly";
|
||||
convertToTimeZone?: boolean;
|
||||
};
|
||||
|
||||
|
||||
Vendored
+2
-2
@@ -10,7 +10,7 @@ export type TextFilterOptions = {
|
||||
};
|
||||
|
||||
export type DateRangeFilterOptions = {
|
||||
range?: "past" | "custom";
|
||||
range?: "past" | "future" | "any" | "customOnly";
|
||||
convertToTimeZone?: boolean;
|
||||
};
|
||||
|
||||
@@ -54,4 +54,4 @@ export type FilterableColumn = {
|
||||
type: Extract<FilterType, "dr">;
|
||||
dateRangeOptions?: DateRangeFilterOptions;
|
||||
}
|
||||
);
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user