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:
co-authored by
Udit Takkar
parent
6244b5a2c0
commit
12724ef894
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user