diff --git a/packages/features/data-table/components/filters/BaseSelectFilterOptions.tsx b/packages/features/data-table/components/filters/BaseSelectFilterOptions.tsx new file mode 100644 index 0000000000..7b2af4c05b --- /dev/null +++ b/packages/features/data-table/components/filters/BaseSelectFilterOptions.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useMemo } from "react"; + +import { classNames } from "@calcom/lib"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { + Command, + CommandInput, + CommandList, + CommandEmpty, + CommandItem, + CommandSeparator, + CommandGroup, + buttonClasses, + Icon, +} from "@calcom/ui"; + +import { useDataTable, useFilterValue } from "../../hooks"; +import type { + ColumnFilterType, + FacetedValue, + FilterableColumn as _FilterableColumn, + FilterValueSchema, +} from "../../lib/types"; + +type FilterableColumn = Extract< + _FilterableColumn, + { type: ColumnFilterType.MULTI_SELECT | ColumnFilterType.SINGLE_SELECT } +>; + +type FilterableSelectColumn = + Extract; + +type FilterValue = ReturnType< + typeof useFilterValue> +>; + +export type BaseSelectFilterOptionsProps< + T extends ColumnFilterType.MULTI_SELECT | ColumnFilterType.SINGLE_SELECT +> = { + column: FilterableSelectColumn; + filterValueSchema: FilterValueSchema; + isOptionSelected: (filterValue: FilterValue | undefined, optionValue: string | number) => boolean; + onOptionSelect: ( + column: FilterableSelectColumn, + currentFilterValue: FilterValue | undefined, + optionValue: string | number + ) => void; + testIdPrefix: string; +}; + +type SectionedOptions = { + [section: string]: FacetedValue[]; +}; + +function getSectionedOptions(options: FacetedValue[]) { + // First map and normalize the options + const normalizedOptions = options + .map((option) => { + if (!option) return null; + const { + label: optionLabel, + value: optionValue, + section, + } = typeof option === "string" ? { label: option, value: option, section: undefined } : option; + return { label: optionLabel, value: optionValue, section }; + }) + .filter((option): option is NonNullable => option !== null); + + // Group options by section + const sectionedOptions: SectionedOptions = {}; + + let currentSection = ""; + normalizedOptions.forEach((option) => { + const sectionKey = option.section ?? ""; // Use empty string for unsectioned items + if (sectionKey && sectionKey !== currentSection) { + currentSection = sectionKey; + } + if (!sectionedOptions[currentSection]) { + sectionedOptions[currentSection] = []; + } + const { section: _, ...optionWithoutSection } = option; + sectionedOptions[currentSection].push(optionWithoutSection); + }); + + return sectionedOptions; +} + +export function BaseSelectFilterOptions< + T extends ColumnFilterType.MULTI_SELECT | ColumnFilterType.SINGLE_SELECT +>({ + column, + filterValueSchema, + isOptionSelected, + onOptionSelect, + testIdPrefix, +}: BaseSelectFilterOptionsProps) { + const { t } = useLocale(); + + const filterValue = useFilterValue(column.id, filterValueSchema); + const { removeFilter } = useDataTable(); + + const options = useMemo(() => { + const sectionedOptions = getSectionedOptions(column.options); + + // Sort options within each section based on selection status + Object.keys(sectionedOptions).forEach((section) => { + sectionedOptions[section] = [ + ...sectionedOptions[section].filter((option) => isOptionSelected(filterValue, option.value)), + ...sectionedOptions[section].filter((option) => !isOptionSelected(filterValue, option.value)), + ]; + }); + + // Flatten the sections back into an array with section information + return Object.keys(sectionedOptions).flatMap((section) => + sectionedOptions[section].map((option, index) => ({ + section: section === "" || index > 0 ? undefined : section, + ...option, + })) + ); + }, [column.options, filterValue]); + + return ( + + + + {t("no_options_available")} + {options.map((option, index) => { + const { label: optionLabel, value: optionValue, section } = option; + + return ( + <> + {section && index !== 0 &&
} + {section && ( +
+ {section} +
+ )} + onOptionSelect(column, filterValue, optionValue)}> +
+ {isOptionSelected(filterValue, optionValue) && ( + + )} +
+ {optionLabel} +
+ + ); + })} +
+ + + { + removeFilter(column.id); + }} + className={classNames("w-full justify-center text-center", buttonClasses({ color: "secondary" }))}> + {t("clear")} + + +
+ ); +} diff --git a/packages/features/data-table/components/filters/MultiSelectFilterOptions.tsx b/packages/features/data-table/components/filters/MultiSelectFilterOptions.tsx index 2c8ccca959..b6c57461fc 100644 --- a/packages/features/data-table/components/filters/MultiSelectFilterOptions.tsx +++ b/packages/features/data-table/components/filters/MultiSelectFilterOptions.tsx @@ -1,86 +1,33 @@ "use client"; -import { classNames } from "@calcom/lib"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { - Command, - CommandInput, - CommandList, - CommandEmpty, - CommandItem, - CommandSeparator, - CommandGroup, - buttonClasses, - Icon, -} from "@calcom/ui"; - -import { useDataTable, useFilterValue } from "../../hooks"; +import { useDataTable } from "../../hooks"; import type { FilterableColumn } from "../../lib/types"; import { ZMultiSelectFilterValue, ColumnFilterType } from "../../lib/types"; +import { BaseSelectFilterOptions } from "./BaseSelectFilterOptions"; export type MultiSelectFilterOptionsProps = { column: Extract; }; export function MultiSelectFilterOptions({ column }: MultiSelectFilterOptionsProps) { - const { t } = useLocale(); - const filterValue = useFilterValue(column.id, ZMultiSelectFilterValue); - const { updateFilter, removeFilter } = useDataTable(); + const { updateFilter } = useDataTable(); return ( - - - - {t("no_options_available")} - {column.options.map((option, index) => { - if (!option) return null; - const { - label: optionLabel, - value: optionValue, - section, - } = typeof option === "string" ? { label: option, value: option, section: undefined } : option; - - return ( - <> - {section && index !== 0 &&
} - {section && ( -
- {section} -
- )} - { - const newFilterValue = filterValue?.data.includes(optionValue) - ? filterValue?.data.filter((value) => value !== optionValue) - : [...(filterValue?.data || []), optionValue]; - updateFilter(column.id, { type: ColumnFilterType.MULTI_SELECT, data: newFilterValue }); - }}> -
- {filterValue?.data.includes(optionValue) && ( - - )} -
- {optionLabel} -
- - ); - })} -
- - - { - removeFilter(column.id); - }} - className={classNames("w-full justify-center text-center", buttonClasses({ color: "secondary" }))}> - {t("clear")} - - -
+ + column={column} + filterValueSchema={ZMultiSelectFilterValue} + testIdPrefix="multi-select-options" + isOptionSelected={(filterValue, optionValue) => { + if (!filterValue?.data) return false; + return filterValue.data.includes(optionValue); + }} + onOptionSelect={(column, filterValue, optionValue) => { + const currentData = filterValue?.data ?? []; + const newFilterValue = currentData.includes(optionValue) + ? currentData.filter((value) => value !== optionValue) + : [...currentData, optionValue]; + updateFilter(column.id, { type: ColumnFilterType.MULTI_SELECT, data: newFilterValue }); + }} + /> ); } diff --git a/packages/features/data-table/components/filters/SingleSelectFilterOptions.tsx b/packages/features/data-table/components/filters/SingleSelectFilterOptions.tsx index 53d7025823..7cfaa76b70 100644 --- a/packages/features/data-table/components/filters/SingleSelectFilterOptions.tsx +++ b/packages/features/data-table/components/filters/SingleSelectFilterOptions.tsx @@ -1,83 +1,26 @@ "use client"; -import { classNames } from "@calcom/lib"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { - Command, - CommandInput, - CommandList, - CommandEmpty, - CommandItem, - CommandSeparator, - CommandGroup, - buttonClasses, - Icon, -} from "@calcom/ui"; - -import { useDataTable, useFilterValue } from "../../hooks"; +import { useDataTable } from "../../hooks"; import type { FilterableColumn } from "../../lib/types"; import { ZSingleSelectFilterValue, ColumnFilterType } from "../../lib/types"; +import { BaseSelectFilterOptions } from "./BaseSelectFilterOptions"; export type SingleSelectFilterOptionsProps = { column: Extract; }; export function SingleSelectFilterOptions({ column }: SingleSelectFilterOptionsProps) { - const { t } = useLocale(); - const filterValue = useFilterValue(column.id, ZSingleSelectFilterValue); - const { updateFilter, removeFilter } = useDataTable(); + const { updateFilter } = useDataTable(); return ( - - - - {t("no_options_available")} - {column.options.map((option, index) => { - if (!option) return null; - const { - label: optionLabel, - value: optionValue, - section, - } = typeof option === "string" ? { label: option, value: option, section: undefined } : option; - - return ( - <> - {section && index !== 0 &&
} - {section && ( -
- {section} -
- )} - { - updateFilter(column.id, { type: ColumnFilterType.SINGLE_SELECT, data: optionValue }); - }}> -
- {filterValue?.data === optionValue && ( - - )} -
- {optionLabel} -
- - ); - })} -
- - - { - removeFilter(column.id); - }} - className={classNames("w-full justify-center text-center", buttonClasses({ color: "secondary" }))}> - {t("clear")} - - -
+ + column={column} + filterValueSchema={ZSingleSelectFilterValue} + testIdPrefix="single-select-options" + isOptionSelected={(filterValue, optionValue) => filterValue?.data === optionValue} + onOptionSelect={(column, filterValue, optionValue) => { + updateFilter(column.id, { type: ColumnFilterType.SINGLE_SELECT, data: optionValue }); + }} + /> ); } diff --git a/packages/features/data-table/hooks/useFilterValue.ts b/packages/features/data-table/hooks/useFilterValue.ts index 6e3444365e..3ac8ff52a7 100644 --- a/packages/features/data-table/hooks/useFilterValue.ts +++ b/packages/features/data-table/hooks/useFilterValue.ts @@ -1,16 +1,20 @@ import { useMemo } from "react"; import type { z } from "zod"; +import type { FilterValueSchema, ColumnFilterType, ZFilterValue } from "../lib/types"; import { useDataTable } from "./useDataTable"; -export function useFilterValue(columnId: string, schema: z.ZodType) { +export function useFilterValue< + T extends ColumnFilterType, + TSchema extends FilterValueSchema | typeof ZFilterValue +>(columnId: string, schema: TSchema) { const { activeFilters } = useDataTable(); return useMemo(() => { const value = activeFilters.find((filter) => filter.f === columnId)?.v; if (schema && value) { const result = schema.safeParse(value); if (result.success) { - return result.data; + return result.data as z.infer; } } return undefined; diff --git a/packages/features/data-table/lib/types.ts b/packages/features/data-table/lib/types.ts index 0994ba279c..0d7811acb6 100644 --- a/packages/features/data-table/lib/types.ts +++ b/packages/features/data-table/lib/types.ts @@ -101,13 +101,6 @@ export const ZDateRangeFilterValue = z.object({ }), }) satisfies z.ZodType; -export type FilterValue = - | SingleSelectFilterValue - | MultiSelectFilterValue - | TextFilterValue - | NumberFilterValue - | DateRangeFilterValue; - export const ZFilterValue = z.union([ ZSingleSelectFilterValue, ZMultiSelectFilterValue, @@ -166,19 +159,34 @@ export const ZColumnFilter = z.object({ value: ZFilterValue, }) satisfies z.ZodType; -export type TypedColumnFilter = { - id: string; - value: T extends ColumnFilterType.TEXT - ? TextFilterValue - : T extends ColumnFilterType.NUMBER - ? NumberFilterValue - : T extends ColumnFilterType.SINGLE_SELECT +export type FilterValueSchema = T extends ColumnFilterType.SINGLE_SELECT + ? typeof ZSingleSelectFilterValue + : T extends ColumnFilterType.MULTI_SELECT + ? typeof ZMultiSelectFilterValue + : T extends ColumnFilterType.TEXT + ? typeof ZTextFilterValue + : T extends ColumnFilterType.NUMBER + ? typeof ZNumberFilterValue + : T extends ColumnFilterType.DATE_RANGE + ? typeof ZDateRangeFilterValue + : never; + +export type FilterValue = + T extends ColumnFilterType.SINGLE_SELECT ? SingleSelectFilterValue : T extends ColumnFilterType.MULTI_SELECT ? MultiSelectFilterValue + : T extends ColumnFilterType.TEXT + ? TextFilterValue + : T extends ColumnFilterType.NUMBER + ? NumberFilterValue : T extends ColumnFilterType.DATE_RANGE ? DateRangeFilterValue : never; + +export type TypedColumnFilter = { + id: string; + value: FilterValue; }; export type Sorting = {