[WEB-4885] feat: new filters architecture and UI components (#7802)

* feat: add rich filters types

* feat: add rich filters constants

* feat: add rich filters utils

* feat: add rich filters store in shared state package

* feat: add rich filters UI components

* fix: make setLoading optional in loadOptions function for improved flexibility

* chore: minor improvements to rich filters

* fix: formatting
This commit is contained in:
Prateek Shourya 2025-09-16 21:15:08 +05:30 committed by GitHub
parent 00e070b509
commit d521eab22f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
83 changed files with 4345 additions and 117 deletions

View file

@ -3,6 +3,7 @@
import React, { useEffect, useRef, useState } from "react";
import { Placement } from "@popperjs/core";
import { observer } from "mobx-react";
import { createPortal } from "react-dom";
import { usePopper } from "react-popper";
import { ArrowRight, CalendarCheck2, CalendarDays, X } from "lucide-react";
import { Combobox } from "@headlessui/react";
@ -59,6 +60,8 @@ type Props = {
renderPlaceholder?: boolean;
customTooltipContent?: React.ReactNode;
customTooltipHeading?: string;
defaultOpen?: boolean;
renderInPortal?: boolean;
};
export const DateRangeDropdown: React.FC<Props> = observer((props) => {
@ -93,9 +96,11 @@ export const DateRangeDropdown: React.FC<Props> = observer((props) => {
renderPlaceholder = true,
customTooltipContent,
customTooltipHeading,
defaultOpen = false,
renderInPortal = false,
} = props;
// states
const [isOpen, setIsOpen] = useState(false);
const [isOpen, setIsOpen] = useState(defaultOpen);
const [dateRange, setDateRange] = useState<DateRange>(value);
// hooks
const { data } = useUserProfile();
@ -193,7 +198,9 @@ export const DateRangeDropdown: React.FC<Props> = observer((props) => {
renderPlaceholder && (
<>
<span className="text-custom-text-400">{placeholder.from}</span>
<ArrowRight className="h-3 w-3 flex-shrink-0 text-custom-text-400" />
{placeholder.from && placeholder.to && (
<ArrowRight className="h-3 w-3 flex-shrink-0 text-custom-text-400" />
)}
<span className="text-custom-text-400">{placeholder.to}</span>
</>
)
@ -247,6 +254,34 @@ export const DateRangeDropdown: React.FC<Props> = observer((props) => {
</button>
);
const comboOptions = (
<Combobox.Options data-prevent-outside-click static>
<div
className="my-1 bg-custom-background-100 shadow-custom-shadow-rg border-[0.5px] border-custom-border-300 rounded-md overflow-hidden z-30"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<Calendar
className="rounded-md border border-custom-border-200 p-3"
captionLayout="dropdown"
selected={dateRange}
onSelect={(val: DateRange | undefined) => {
onSelect?.(val);
}}
mode="range"
disabled={disabledDays}
showOutsideDays
fixedWeeks
weekStartsOn={startOfWeek}
initialFocus
/>
</div>
</Combobox.Options>
);
const Options = renderInPortal ? createPortal(comboOptions, document.body) : comboOptions;
return (
<ComboDropDown
as="div"
@ -262,31 +297,7 @@ export const DateRangeDropdown: React.FC<Props> = observer((props) => {
disabled={disabled}
renderByDefault={renderByDefault}
>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
className="my-1 bg-custom-background-100 shadow-custom-shadow-rg border-[0.5px] border-custom-border-300 rounded-md overflow-hidden"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<Calendar
className="rounded-md border border-custom-border-200 p-3"
captionLayout="dropdown"
selected={dateRange}
onSelect={(val: DateRange | undefined) => {
onSelect?.(val);
}}
mode="range"
disabled={disabledDays}
showOutsideDays
fixedWeeks
weekStartsOn={startOfWeek}
initialFocus
/>
</div>
</Combobox.Options>
)}
{isOpen && Options}
</ComboDropDown>
);
});

View file

@ -1,3 +1,5 @@
"use client";
import React, { useRef, useState } from "react";
import { observer } from "mobx-react";
import { createPortal } from "react-dom";
@ -21,6 +23,7 @@ import { TDropdownProps } from "./types";
type Props = TDropdownProps & {
clearIconClassName?: string;
defaultOpen?: boolean;
optionsClassName?: string;
icon?: React.ReactNode;
isClearable?: boolean;
@ -41,6 +44,7 @@ export const DateDropdown: React.FC<Props> = observer((props) => {
buttonVariant,
className = "",
clearIconClassName = "",
defaultOpen = false,
optionsClassName = "",
closeOnSelect = true,
disabled = false,
@ -60,7 +64,7 @@ export const DateDropdown: React.FC<Props> = observer((props) => {
renderByDefault = true,
} = props;
// states
const [isOpen, setIsOpen] = useState(false);
const [isOpen, setIsOpen] = useState(defaultOpen);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// hooks

View file

@ -0,0 +1,106 @@
import React from "react";
import { observer } from "mobx-react";
import { ListFilter } from "lucide-react";
// plane imports
import { IFilterInstance } from "@plane/shared-state";
import { LOGICAL_OPERATOR, TExternalFilter, TFilterProperty } from "@plane/types";
import { CustomSearchSelect, getButtonStyling, TButtonVariant } from "@plane/ui";
import { cn, getOperatorForPayload } from "@plane/utils";
export type TAddFilterButtonProps<P extends TFilterProperty, E extends TExternalFilter> = {
buttonConfig?: {
label?: string;
variant?: TButtonVariant;
className?: string;
defaultOpen?: boolean;
iconConfig?: {
shouldShowIcon: boolean;
iconComponent?: React.ReactNode;
};
isDisabled?: boolean;
};
filter: IFilterInstance<P, E>;
onFilterSelect?: (id: string) => void;
};
export const AddFilterButton = observer(
<P extends TFilterProperty, E extends TExternalFilter>(props: TAddFilterButtonProps<P, E>) => {
const { filter, buttonConfig, onFilterSelect } = props;
const {
label = "Filters",
variant = "link-neutral",
className,
defaultOpen = false,
iconConfig = { shouldShowIcon: true },
isDisabled = false,
} = buttonConfig || {};
// Transform available filter configs to CustomSearchSelect options format
const filterOptions = filter.configManager.allAvailableConfigs.map((config) => ({
value: config.id,
content: (
<div className="flex items-center gap-2 text-custom-text-200 transition-all duration-200 ease-in-out">
{config.icon && (
<config.icon className="size-4 text-custom-text-300 transition-transform duration-200 ease-in-out" />
)}
<span>{config.label}</span>
</div>
),
query: config.label.toLowerCase(),
}));
// If all filters are applied, show disabled options
const allFiltersApplied = filterOptions.length === 0;
const displayOptions = allFiltersApplied
? [
{
value: "all_filters_applied",
content: <div className="text-custom-text-400 italic">All filters applied</div>,
query: "all filters applied",
disabled: true,
},
]
: filterOptions;
const handleFilterSelect = (property: P) => {
const config = filter.configManager.getConfigByProperty(property);
if (config && config.firstOperator) {
const { operator, isNegation } = getOperatorForPayload(config.firstOperator);
filter.addCondition(
LOGICAL_OPERATOR.AND,
{
property: config.id,
operator,
value: undefined,
},
isNegation
);
onFilterSelect?.(property);
}
};
if (isDisabled) return null;
return (
<div className="relative transition-all duration-200 ease-in-out">
<CustomSearchSelect
defaultOpen={defaultOpen}
value={""}
onChange={handleFilterSelect}
options={displayOptions}
optionsClassName="w-56"
maxHeight="full"
placement="bottom-start"
disabled={isDisabled}
customButtonClassName={cn(getButtonStyling(variant, "sm"), className)}
customButton={
<div className="flex items-center gap-1">
{iconConfig.shouldShowIcon &&
(iconConfig.iconComponent || <ListFilter className="size-4 text-custom-text-200" />)}
{label}
</div>
}
/>
</div>
);
}
);

View file

@ -0,0 +1,161 @@
import React, { useRef, useEffect } from "react";
import { observer } from "mobx-react";
import { X } from "lucide-react";
// plane imports
import { IFilterInstance } from "@plane/shared-state";
import {
SingleOrArray,
TExternalFilter,
TFilterProperty,
TFilterValue,
TFilterConditionNodeForDisplay,
TAllAvailableOperatorsForDisplay,
} from "@plane/types";
import { CustomSearchSelect } from "@plane/ui";
import { cn, hasValidValue, getOperatorForPayload } from "@plane/utils";
// local imports
import { FilterValueInput } from "./filter-value-input/root";
import { COMMON_FILTER_ITEM_BORDER_CLASSNAME } from "./shared";
interface FilterItemProps<P extends TFilterProperty, E extends TExternalFilter> {
condition: TFilterConditionNodeForDisplay<P, TFilterValue>;
filter: IFilterInstance<P, E>;
isDisabled?: boolean;
showTransition?: boolean;
}
export const FilterItem = observer(
<P extends TFilterProperty, E extends TExternalFilter>(props: FilterItemProps<P, E>) => {
const { condition, filter, isDisabled = false, showTransition = true } = props;
// refs
const itemRef = useRef<HTMLDivElement>(null);
// derived values
const filterConfig = condition?.property ? filter.configManager.getConfigByProperty(condition.property) : undefined;
const operatorOptions = filterConfig
?.getAllDisplayOperatorOptionsByValue(condition.value as TFilterValue)
.map((option) => ({
value: option.value,
content: option.label,
query: option.label.toLowerCase(),
}));
const selectedOperatorFieldConfig = filterConfig?.getOperatorConfig(condition.operator);
const selectedOperatorOption = filterConfig?.getDisplayOperatorByValue(
condition.operator,
condition.value as TFilterValue
);
// Disable operator selection when filter is disabled or only one operator option is available and selected
const isOperatorSelectionDisabled =
isDisabled ||
(condition.operator && operatorOptions?.length === 1 && operatorOptions[0]?.value === condition.operator);
// effects
useEffect(() => {
if (!showTransition) return;
const element = itemRef.current;
if (!element) return;
if (hasValidValue(condition.value)) return;
const applyInitialStyles = () => {
element.style.opacity = "0";
element.style.transform = "scale(0.95)";
};
const applyFinalStyles = () => {
// Force a reflow to ensure the initial state is applied
void element.offsetWidth;
element.style.opacity = "1";
element.style.transform = "scale(1)";
};
applyInitialStyles();
applyFinalStyles();
return () => {
applyInitialStyles();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleOperatorChange = (operator: TAllAvailableOperatorsForDisplay) => {
if (operator) {
const { operator: positiveOperator, isNegation } = getOperatorForPayload(operator);
filter.updateConditionOperator(condition.id, positiveOperator, isNegation);
}
};
const handleValueChange = (values: SingleOrArray<TFilterValue>) => {
filter.updateConditionValue(condition.id, values);
};
const handleRemoveFilter = () => {
filter.removeCondition(condition.id);
};
if (!filterConfig || !filterConfig.isEnabled) return null;
return (
<div
ref={itemRef}
className="flex h-7 items-stretch rounded overflow-hidden border border-custom-border-200 bg-custom-background-100 transition-all duration-200"
>
{/* Property section */}
<div
className={cn(
"flex items-center gap-1 px-2 py-0.5 text-xs text-custom-text-300 min-w-0",
COMMON_FILTER_ITEM_BORDER_CLASSNAME
)}
>
{filterConfig.icon && (
<div className="transition-transform duration-200 ease-in-out flex-shrink-0">
<filterConfig.icon className="size-3.5" />
</div>
)}
<span className="truncate">{filterConfig.label}</span>
</div>
{/* Operator section */}
<CustomSearchSelect
value={condition.operator}
onChange={handleOperatorChange}
options={operatorOptions}
className={COMMON_FILTER_ITEM_BORDER_CLASSNAME}
customButtonClassName={cn(
"h-full px-2 text-sm font-normal",
isOperatorSelectionDisabled && "hover:bg-custom-background-100"
)}
optionsClassName="w-48"
maxHeight="full"
disabled={isOperatorSelectionDisabled}
customButton={
<div className="flex items-center h-full" aria-disabled={isOperatorSelectionDisabled}>
{filterConfig.getLabelForOperator(selectedOperatorOption)}
</div>
}
/>
{/* Value section */}
{selectedOperatorFieldConfig && (
<FilterValueInput
filterFieldConfig={selectedOperatorFieldConfig}
condition={condition}
onChange={handleValueChange}
isDisabled={isDisabled}
/>
)}
{/* Remove button */}
{!isDisabled && (
<button
onClick={handleRemoveFilter}
className="px-1.5 text-custom-text-400 hover:text-custom-text-300 focus:outline-none hover:bg-custom-background-90"
type="button"
aria-label="Remove filter"
>
<X className="size-3.5" />
</button>
)}
</div>
);
}
);

View file

@ -0,0 +1,59 @@
import React from "react";
import { observer } from "mobx-react";
// plane imports
import { TDateRangeFilterFieldConfig, TFilterConditionNodeForDisplay, TFilterProperty } from "@plane/types";
import { cn, isValidDate, renderFormattedPayloadDate, toFilterArray } from "@plane/utils";
// components
import { DateRangeDropdown } from "@/components/dropdowns/date-range";
// local imports
import { COMMON_FILTER_ITEM_BORDER_CLASSNAME } from "../../shared";
type TDateRangeFilterValueInputProps<P extends TFilterProperty> = {
config: TDateRangeFilterFieldConfig<string>;
condition: TFilterConditionNodeForDisplay<P, string>;
isDisabled?: boolean;
onChange: (value: string[]) => void;
};
export const DateRangeFilterValueInput = observer(
<P extends TFilterProperty>(props: TDateRangeFilterValueInputProps<P>) => {
const { config, condition, isDisabled, onChange } = props;
// derived values
const [fromRaw, toRaw] = toFilterArray(condition.value) ?? [];
const from = isValidDate(fromRaw) ? new Date(fromRaw) : undefined;
const to = isValidDate(toRaw) ? new Date(toRaw) : undefined;
const isIncomplete = !from || !to;
// Handler for date range selection
const handleSelect = (range: { from?: Date; to?: Date } | undefined) => {
const formattedFrom = range?.from ? renderFormattedPayloadDate(range.from) : undefined;
const formattedTo = range?.to ? renderFormattedPayloadDate(range.to) : undefined;
if (formattedFrom && formattedTo) {
onChange([formattedFrom, formattedTo]);
} else {
onChange([]);
}
};
return (
<DateRangeDropdown
value={{ from, to }}
onSelect={handleSelect}
minDate={config.min}
maxDate={config.max}
mergeDates
placeholder={{ from: "--" }}
buttonVariant="transparent-with-text"
buttonClassName={cn("rounded-none", {
[COMMON_FILTER_ITEM_BORDER_CLASSNAME]: !isDisabled,
"text-red-500": isIncomplete,
"hover:bg-custom-background-100": isDisabled,
})}
renderPlaceholder
renderInPortal
defaultOpen={isIncomplete}
disabled={isDisabled}
/>
);
}
);

View file

@ -0,0 +1,46 @@
import React from "react";
import { observer } from "mobx-react";
// plane imports
import { TDateFilterFieldConfig, TFilterConditionNodeForDisplay, TFilterProperty } from "@plane/types";
import { cn, renderFormattedPayloadDate } from "@plane/utils";
import { DateDropdown } from "@/components/dropdowns/date";
import { COMMON_FILTER_ITEM_BORDER_CLASSNAME } from "../../shared";
type TSingleDateFilterValueInputProps<P extends TFilterProperty> = {
config: TDateFilterFieldConfig<string>;
condition: TFilterConditionNodeForDisplay<P, string>;
isDisabled?: boolean;
onChange: (value: string | null | undefined) => void;
};
export const SingleDateFilterValueInput = observer(
<P extends TFilterProperty>(props: TSingleDateFilterValueInputProps<P>) => {
const { config, condition, isDisabled, onChange } = props;
// derived values
const conditionValue = typeof condition.value === "string" ? condition.value : null;
return (
<DateDropdown
value={conditionValue}
onChange={(value: Date | null) => {
const formattedDate = value ? renderFormattedPayloadDate(value) : null;
onChange(formattedDate);
}}
buttonClassName={cn("rounded-none", {
[COMMON_FILTER_ITEM_BORDER_CLASSNAME]: !isDisabled,
"text-custom-text-400": !conditionValue,
"hover:bg-custom-background-100": isDisabled,
})}
minDate={config.min}
maxDate={config.max}
icon={null}
placeholder="--"
buttonVariant="transparent-with-text"
isClearable={false}
closeOnSelect
defaultOpen={!conditionValue}
disabled={isDisabled}
/>
);
}
);

View file

@ -0,0 +1,90 @@
import React from "react";
import { observer } from "mobx-react";
// plane imports
import {
FILTER_FIELD_TYPE,
TFilterConditionNode,
TFilterValue,
TFilterProperty,
SingleOrArray,
TSingleSelectFilterFieldConfig,
TMultiSelectFilterFieldConfig,
TDateFilterFieldConfig,
TDateRangeFilterFieldConfig,
TSupportedFilterFieldConfigs,
TFilterConditionNodeForDisplay,
} from "@plane/types";
// local imports
import { DateRangeFilterValueInput } from "./date/range";
import { SingleDateFilterValueInput } from "./date/single";
import { MultiSelectFilterValueInput } from "./select/multi";
import { SingleSelectFilterValueInput } from "./select/single";
type TFilterValueInputProps<P extends TFilterProperty, V extends TFilterValue> = {
condition: TFilterConditionNodeForDisplay<P, V>;
filterFieldConfig: TSupportedFilterFieldConfigs<V>;
isDisabled?: boolean;
onChange: (values: SingleOrArray<V>) => void;
};
// TODO: Prevent type assertion
export const FilterValueInput = observer(
<P extends TFilterProperty, V extends TFilterValue>(props: TFilterValueInputProps<P, V>) => {
const { condition, filterFieldConfig, isDisabled = false, onChange } = props;
// Single select input
if (filterFieldConfig?.type === FILTER_FIELD_TYPE.SINGLE_SELECT) {
return (
<SingleSelectFilterValueInput<P>
config={filterFieldConfig as TSingleSelectFilterFieldConfig<string>}
condition={condition as TFilterConditionNodeForDisplay<P, string>}
isDisabled={isDisabled}
onChange={(value) => onChange(value as SingleOrArray<V>)}
/>
);
}
// Multi select input
if (filterFieldConfig?.type === FILTER_FIELD_TYPE.MULTI_SELECT) {
return (
<MultiSelectFilterValueInput<P>
config={filterFieldConfig as TMultiSelectFilterFieldConfig<string>}
condition={condition as TFilterConditionNode<P, string>}
isDisabled={isDisabled}
onChange={(value) => onChange(value as SingleOrArray<V>)}
/>
);
}
// Date filter input
if (filterFieldConfig?.type === FILTER_FIELD_TYPE.DATE) {
return (
<SingleDateFilterValueInput<P>
config={filterFieldConfig as TDateFilterFieldConfig<string>}
condition={condition as TFilterConditionNodeForDisplay<P, string>}
isDisabled={isDisabled}
onChange={(value) => onChange(value as SingleOrArray<V>)}
/>
);
}
// Date range filter input
if (filterFieldConfig?.type === FILTER_FIELD_TYPE.DATE_RANGE) {
return (
<DateRangeFilterValueInput<P>
config={filterFieldConfig as TDateRangeFilterFieldConfig<string>}
condition={condition as TFilterConditionNodeForDisplay<P, string>}
isDisabled={isDisabled}
onChange={(value) => onChange(value as SingleOrArray<V>)}
/>
);
}
// Fallback
return (
<div className="h-full flex items-center px-4 text-xs text-custom-text-400 transition-opacity duration-200 cursor-not-allowed">
Filter type not supported
</div>
);
}
);

View file

@ -0,0 +1,54 @@
import React, { useState, useEffect, useMemo } from "react";
import { observer } from "mobx-react";
// plane imports
import {
SingleOrArray,
IFilterOption,
TFilterProperty,
TMultiSelectFilterFieldConfig,
TFilterConditionNodeForDisplay,
} from "@plane/types";
import { CustomSearchSelect } from "@plane/ui";
import { toFilterArray, getFilterValueLength } from "@plane/utils";
// local imports
import { SelectedOptionsDisplay } from "./selected-options-display";
import { getCommonCustomSearchSelectProps, getFormattedOptions, loadOptions } from "./shared";
type TMultiSelectFilterValueInputProps<P extends TFilterProperty> = {
config: TMultiSelectFilterFieldConfig<string>;
condition: TFilterConditionNodeForDisplay<P, string>;
isDisabled?: boolean;
onChange: (values: SingleOrArray<string>) => void;
};
export const MultiSelectFilterValueInput = observer(
<P extends TFilterProperty>(props: TMultiSelectFilterValueInputProps<P>) => {
const { config, condition, isDisabled, onChange } = props;
// states
const [options, setOptions] = useState<IFilterOption<string>[]>([]);
const [loading, setLoading] = useState<boolean>(false);
// derived values
const formattedOptions = useMemo(() => getFormattedOptions<string>(options), [options]);
useEffect(() => {
loadOptions({ config, setOptions, setLoading });
}, [config]);
const handleSelectChange = (values: string[]) => {
onChange(values);
};
return (
<CustomSearchSelect
{...getCommonCustomSearchSelectProps(isDisabled)}
value={toFilterArray(condition.value)}
onChange={handleSelectChange}
options={formattedOptions}
multiple
disabled={loading || isDisabled}
customButton={<SelectedOptionsDisplay<string> selectedValue={condition.value} options={options} />}
defaultOpen={getFilterValueLength(condition.value) === 0}
/>
);
}
);

View file

@ -0,0 +1,61 @@
import React from "react";
import { Transition } from "@headlessui/react";
// plane imports
import { SingleOrArray, IFilterOption, TFilterValue } from "@plane/types";
import { cn, toFilterArray } from "@plane/utils";
type TSelectedOptionsDisplayProps<V extends TFilterValue> = {
selectedValue: SingleOrArray<V>;
options: IFilterOption<V>[];
displayCount?: number;
emptyValue?: string;
fallbackText?: string;
};
export const SelectedOptionsDisplay = <V extends TFilterValue>(props: TSelectedOptionsDisplayProps<V>) => {
const { selectedValue, options, displayCount = 2, emptyValue = "--", fallbackText } = props;
// derived values
const selectedArray = toFilterArray(selectedValue);
const remainingCount = selectedArray.length - displayCount;
const selectedOptions = selectedArray
.map((value) => options.find((opt) => opt.value === value))
.filter(Boolean) as IFilterOption<V>[];
// When no value is selected, display the empty value
if (selectedArray.length === 0) {
return <span className="text-custom-text-400">{emptyValue}</span>;
}
// When no options are found but we have a fallback text
if (options.length === 0) {
return <span className="text-custom-text-400">{fallbackText ?? `${selectedArray.length} option(s) selected`}</span>;
}
return (
<div className="flex items-center h-full overflow-hidden">
{selectedOptions.slice(0, displayCount).map((option, index) => (
<React.Fragment key={index}>
<div className="flex items-center whitespace-nowrap">
{option?.icon && <span className={cn("mr-1", option.iconClassName)}>{option.icon}</span>}
<span className="truncate max-w-24">{option?.label}</span>
</div>
{index < Math.min(displayCount, selectedOptions.length) - 1 && (
<span className="text-custom-text-300 mx-1">,</span>
)}
</React.Fragment>
))}
{remainingCount > 0 && (
<Transition
show
appear
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
className="text-custom-text-300 whitespace-nowrap ml-1"
>
+{remainingCount} more
</Transition>
)}
</div>
);
};

View file

@ -0,0 +1,54 @@
// plane imports
import { TSupportedFilterFieldConfigs, IFilterOption, TFilterValue } from "@plane/types";
import { cn } from "@plane/utils";
// local imports
import { COMMON_FILTER_ITEM_BORDER_CLASSNAME } from "../../shared";
type TLoadOptionsProps<V extends TFilterValue> = {
config: TSupportedFilterFieldConfigs<V>;
setOptions: (options: IFilterOption<V>[]) => void;
setLoading?: (loading: boolean) => void;
};
export const loadOptions = async <V extends TFilterValue>(props: TLoadOptionsProps<V>) => {
const { config, setOptions, setLoading } = props;
// if the config has a getOptions function, load the options
if ("getOptions" in config && typeof config.getOptions === "function") {
setLoading?.(true);
try {
const result = await config.getOptions();
setOptions(result);
} catch (error) {
console.error("Failed to load options:", error);
} finally {
setLoading?.(false);
}
}
};
export const getFormattedOptions = <V extends TFilterValue>(options: IFilterOption<V>[]) =>
options.map((option) => ({
value: option.value,
content: (
<div className="flex items-center gap-2 transition-all duration-200 ease-in-out">
{option.icon && (
<span className={cn("transition-transform duration-200", option.iconClassName)}>{option.icon}</span>
)}
<span>{option.label}</span>
</div>
),
query: option.label.toString().toLowerCase(),
disabled: option.disabled,
tooltip: option.description,
}));
export const getCommonCustomSearchSelectProps = (isDisabled?: boolean) => ({
customButtonClassName: cn(
"h-full w-full px-2 text-sm font-normal transition-all duration-300 ease-in-out",
!isDisabled && COMMON_FILTER_ITEM_BORDER_CLASSNAME,
isDisabled && "hover:bg-custom-background-100"
),
optionsClassName: "w-56",
maxHeight: "md" as const,
});

View file

@ -0,0 +1,58 @@
import React, { useState, useEffect, useMemo } from "react";
import { observer } from "mobx-react";
// plane imports
import {
IFilterOption,
TFilterProperty,
TSingleSelectFilterFieldConfig,
TFilterConditionNodeForDisplay,
} from "@plane/types";
import { CustomSearchSelect } from "@plane/ui";
// local imports
import { SelectedOptionsDisplay } from "./selected-options-display";
import { getCommonCustomSearchSelectProps, getFormattedOptions, loadOptions } from "./shared";
type TSingleSelectFilterValueInputProps<P extends TFilterProperty> = {
config: TSingleSelectFilterFieldConfig<string>;
condition: TFilterConditionNodeForDisplay<P, string>;
isDisabled?: boolean;
onChange: (value: string | null) => void;
};
export const SingleSelectFilterValueInput = observer(
<P extends TFilterProperty>(props: TSingleSelectFilterValueInputProps<P>) => {
const { config, condition, onChange, isDisabled } = props;
// states
const [options, setOptions] = useState<IFilterOption<string>[]>([]);
const [loading, setLoading] = useState<boolean>(false);
// derived values
const formattedOptions = useMemo(() => getFormattedOptions<string>(options), [options]);
useEffect(() => {
loadOptions({ config, setOptions, setLoading });
}, [config]);
const handleSelectChange = (value: string) => {
if (value === condition.value) {
onChange(null);
} else {
onChange(value);
}
};
return (
<CustomSearchSelect
{...getCommonCustomSearchSelectProps(isDisabled)}
value={condition.value}
onChange={handleSelectChange}
options={formattedOptions}
multiple={false}
disabled={loading || isDisabled}
customButton={
<SelectedOptionsDisplay<string> selectedValue={condition.value} options={options} displayCount={1} />
}
defaultOpen={!condition.value}
/>
);
}
);

View file

@ -0,0 +1,185 @@
import React, { useCallback, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { Transition } from "@headlessui/react";
// plane imports
import { IFilterInstance } from "@plane/shared-state";
import { TExternalFilter, TFilterProperty } from "@plane/types";
import { Button, EHeaderVariant, Header } from "@plane/ui";
// local imports
import { AddFilterButton, TAddFilterButtonProps } from "./add-filters-button";
import { FilterItem } from "./filter-item";
export type TFiltersRowProps<K extends TFilterProperty, E extends TExternalFilter> = {
buttonConfig?: TAddFilterButtonProps<K, E>["buttonConfig"];
disabledAllOperations?: boolean;
filter: IFilterInstance<K, E>;
variant?: "default" | "header";
visible?: boolean;
maxVisibleConditions?: number;
trackerElements?: {
clearFilter?: string;
saveView?: string;
updateView?: string;
};
};
export const FiltersRow = observer(
<K extends TFilterProperty, E extends TExternalFilter>(props: TFiltersRowProps<K, E>) => {
const {
buttonConfig,
disabledAllOperations = false,
filter,
variant = "header",
visible = true,
maxVisibleConditions = 3,
trackerElements,
} = props;
// states
const [showAllConditions, setShowAllConditions] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
// derived values
const visibleConditions = useMemo(() => {
if (variant === "default" || !maxVisibleConditions || showAllConditions) {
return filter.allConditionsForDisplay;
}
return filter.allConditionsForDisplay.slice(0, maxVisibleConditions);
}, [filter.allConditionsForDisplay, maxVisibleConditions, showAllConditions, variant]);
const hiddenConditionsCount = useMemo(() => {
if (variant === "default" || !maxVisibleConditions || showAllConditions) {
return 0;
}
return Math.max(0, filter.allConditionsForDisplay.length - maxVisibleConditions);
}, [filter.allConditionsForDisplay.length, maxVisibleConditions, showAllConditions, variant]);
const handleUpdate = useCallback(async () => {
setIsUpdating(true);
await filter.updateView();
setTimeout(() => setIsUpdating(false), 240); // To avoid flickering
}, [filter]);
if (!visible) return null;
const leftContent = (
<>
<AddFilterButton
filter={filter}
buttonConfig={{
...buttonConfig,
isDisabled: disabledAllOperations,
}}
onFilterSelect={() => {
if (variant === "header") {
setShowAllConditions(true);
}
}}
/>
{visibleConditions.map((condition) => (
<FilterItem key={condition.id} filter={filter} condition={condition} isDisabled={disabledAllOperations} />
))}
{variant === "header" && hiddenConditionsCount > 0 && (
<Button
variant="neutral-primary"
size="sm"
className={COMMON_VISIBILITY_BUTTON_CLASSNAME}
onClick={() => setShowAllConditions(true)}
>
+{hiddenConditionsCount} more
</Button>
)}
{variant === "header" &&
showAllConditions &&
maxVisibleConditions &&
filter.allConditionsForDisplay.length > maxVisibleConditions && (
<Button
variant="neutral-primary"
size="sm"
className={COMMON_VISIBILITY_BUTTON_CLASSNAME}
onClick={() => setShowAllConditions(false)}
>
Show less
</Button>
)}
</>
);
const rightContent = !disabledAllOperations && (
<>
<ElementTransition show={filter.canClearFilters}>
<Button
variant="neutral-primary"
size="sm"
className={COMMON_OPERATION_BUTTON_CLASSNAME}
onClick={filter.clearFilters}
data-ph-element={trackerElements?.clearFilter}
>
{filter.clearFilterOptions?.label ?? "Clear all"}
</Button>
</ElementTransition>
<ElementTransition show={filter.canSaveView}>
<Button
variant="accent-primary"
size="sm"
className={COMMON_OPERATION_BUTTON_CLASSNAME}
onClick={filter.saveView}
data-ph-element={trackerElements?.saveView}
>
{filter.saveViewOptions?.label ?? "Save view"}
</Button>
</ElementTransition>
<ElementTransition show={filter.canUpdateView}>
<Button
variant="accent-primary"
size="sm"
className={COMMON_OPERATION_BUTTON_CLASSNAME}
onClick={handleUpdate}
loading={isUpdating}
disabled={isUpdating}
data-ph-element={trackerElements?.updateView}
>
{isUpdating ? "Confirming" : (filter.updateViewOptions?.label ?? "Update view")}
</Button>
</ElementTransition>
</>
);
if (variant === "default") {
return (
<div className="w-full flex flex-wrap items-center gap-2">
{leftContent}
{rightContent}
</div>
);
}
return (
<Header variant={EHeaderVariant.TERNARY}>
<div className="w-full flex items-start gap-2">
<div className="w-full flex flex-wrap items-center gap-2">{leftContent}</div>
<div className="flex items-center gap-2">{rightContent}</div>
</div>
</Header>
);
}
);
const COMMON_VISIBILITY_BUTTON_CLASSNAME = "py-0.5 px-2 text-custom-text-300 hover:text-custom-text-100 rounded-full";
const COMMON_OPERATION_BUTTON_CLASSNAME = "py-1";
type TElementTransitionProps = {
children: React.ReactNode;
show: boolean;
};
const ElementTransition = observer((props: TElementTransitionProps) => (
<Transition
show={props.show}
enter="transition ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
{props.children}
</Transition>
));

View file

@ -0,0 +1 @@
export const COMMON_FILTER_ITEM_BORDER_CLASSNAME = "border-r border-custom-border-200";