fix: show selected filter values first in single- & multi-select filters (#19620)

* fix: show selected filter values first in single- & multi-select filters

* fix type errors

* fix type error

* fix type error

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
Eunjae Lee
2025-03-03 10:28:45 +00:00
committed by GitHub
co-authored by Udit Takkar
parent 6244b5a2c0
commit 12724ef894
5 changed files with 229 additions and 157 deletions
@@ -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<T extends ColumnFilterType.MULTI_SELECT | ColumnFilterType.SINGLE_SELECT> =
Extract<FilterableColumn, { type: T }>;
type FilterValue<T extends ColumnFilterType.MULTI_SELECT | ColumnFilterType.SINGLE_SELECT> = ReturnType<
typeof useFilterValue<T, FilterValueSchema<T>>
>;
export type BaseSelectFilterOptionsProps<
T extends ColumnFilterType.MULTI_SELECT | ColumnFilterType.SINGLE_SELECT
> = {
column: FilterableSelectColumn<T>;
filterValueSchema: FilterValueSchema<T>;
isOptionSelected: (filterValue: FilterValue<T> | undefined, optionValue: string | number) => boolean;
onOptionSelect: (
column: FilterableSelectColumn<T>,
currentFilterValue: FilterValue<T> | 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<typeof option> => 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<T>) {
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 (
<Command data-testid={`${testIdPrefix}-${column.id}`}>
<CommandInput placeholder={t("search")} />
<CommandList>
<CommandEmpty>{t("no_options_available")}</CommandEmpty>
{options.map((option, index) => {
const { label: optionLabel, value: optionValue, section } = option;
return (
<>
{section && index !== 0 && <hr className="border-subtle my-1" />}
{section && (
<div className="text-subtle px-4 py-2 text-xs font-medium uppercase leading-none">
{section}
</div>
)}
<CommandItem
key={optionValue}
onSelect={() => onOptionSelect(column, filterValue, optionValue)}>
<div
className={classNames(
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
isOptionSelected(filterValue, optionValue) ? "bg-primary" : "opacity-50"
)}>
{isOptionSelected(filterValue, optionValue) && (
<Icon name="check" className="text-primary-foreground h-4 w-4" />
)}
</div>
{optionLabel}
</CommandItem>
</>
);
})}
</CommandList>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => {
removeFilter(column.id);
}}
className={classNames("w-full justify-center text-center", buttonClasses({ color: "secondary" }))}>
{t("clear")}
</CommandItem>
</CommandGroup>
</Command>
);
}
@@ -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<FilterableColumn, { type: ColumnFilterType.MULTI_SELECT }>;
};
export function MultiSelectFilterOptions({ column }: MultiSelectFilterOptionsProps) {
const { t } = useLocale();
const filterValue = useFilterValue(column.id, ZMultiSelectFilterValue);
const { updateFilter, removeFilter } = useDataTable();
const { updateFilter } = useDataTable();
return (
<Command data-testid={`multi-select-options-${column.id}`}>
<CommandInput placeholder={t("search")} />
<CommandList>
<CommandEmpty>{t("no_options_available")}</CommandEmpty>
{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 && <hr className="border-subtle my-1" />}
{section && (
<div className="text-subtle px-4 py-2 text-xs font-medium uppercase leading-none">
{section}
</div>
)}
<CommandItem
key={optionValue}
onSelect={() => {
const newFilterValue = filterValue?.data.includes(optionValue)
? filterValue?.data.filter((value) => value !== optionValue)
: [...(filterValue?.data || []), optionValue];
updateFilter(column.id, { type: ColumnFilterType.MULTI_SELECT, data: newFilterValue });
}}>
<div
className={classNames(
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
filterValue?.data.includes(optionValue) ? "bg-primary" : "opacity-50"
)}>
{filterValue?.data.includes(optionValue) && (
<Icon name="check" className="text-primary-foreground h-4 w-4" />
)}
</div>
{optionLabel}
</CommandItem>
</>
);
})}
</CommandList>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => {
removeFilter(column.id);
}}
className={classNames("w-full justify-center text-center", buttonClasses({ color: "secondary" }))}>
{t("clear")}
</CommandItem>
</CommandGroup>
</Command>
<BaseSelectFilterOptions<ColumnFilterType.MULTI_SELECT>
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 });
}}
/>
);
}
@@ -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<FilterableColumn, { type: ColumnFilterType.SINGLE_SELECT }>;
};
export function SingleSelectFilterOptions({ column }: SingleSelectFilterOptionsProps) {
const { t } = useLocale();
const filterValue = useFilterValue(column.id, ZSingleSelectFilterValue);
const { updateFilter, removeFilter } = useDataTable();
const { updateFilter } = useDataTable();
return (
<Command data-testid={`single-select-options-${column.id}`}>
<CommandInput placeholder={t("search")} />
<CommandList>
<CommandEmpty>{t("no_options_available")}</CommandEmpty>
{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 && <hr className="border-subtle my-1" />}
{section && (
<div className="text-subtle px-4 py-2 text-xs font-medium uppercase leading-none">
{section}
</div>
)}
<CommandItem
key={optionValue}
onSelect={() => {
updateFilter(column.id, { type: ColumnFilterType.SINGLE_SELECT, data: optionValue });
}}>
<div
className={classNames(
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
filterValue?.data === optionValue ? "bg-primary" : "opacity-50"
)}>
{filterValue?.data === optionValue && (
<Icon name="check" className="text-primary-foreground h-4 w-4" />
)}
</div>
{optionLabel}
</CommandItem>
</>
);
})}
</CommandList>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => {
removeFilter(column.id);
}}
className={classNames("w-full justify-center text-center", buttonClasses({ color: "secondary" }))}>
{t("clear")}
</CommandItem>
</CommandGroup>
</Command>
<BaseSelectFilterOptions<ColumnFilterType.SINGLE_SELECT>
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 });
}}
/>
);
}
@@ -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<T>(columnId: string, schema: z.ZodType<T>) {
export function useFilterValue<
T extends ColumnFilterType,
TSchema extends FilterValueSchema<T> | 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<TSchema>;
}
}
return undefined;
+22 -14
View File
@@ -101,13 +101,6 @@ export const ZDateRangeFilterValue = z.object({
}),
}) satisfies z.ZodType<DateRangeFilterValue>;
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<ColumnFilter>;
export type TypedColumnFilter<T extends ColumnFilterType> = {
id: string;
value: T extends ColumnFilterType.TEXT
? TextFilterValue
: T extends ColumnFilterType.NUMBER
? NumberFilterValue
: T extends ColumnFilterType.SINGLE_SELECT
export type FilterValueSchema<T extends ColumnFilterType> = 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 = ColumnFilterType> =
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<T extends ColumnFilterType> = {
id: string;
value: FilterValue<T>;
};
export type Sorting = {