[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:
parent
00e070b509
commit
d521eab22f
83 changed files with 4345 additions and 117 deletions
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
106
apps/web/core/components/rich-filters/add-filters-button.tsx
Normal file
106
apps/web/core/components/rich-filters/add-filters-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
161
apps/web/core/components/rich-filters/filter-item.tsx
Normal file
161
apps/web/core/components/rich-filters/filter-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
185
apps/web/core/components/rich-filters/filters-row.tsx
Normal file
185
apps/web/core/components/rich-filters/filters-row.tsx
Normal 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>
|
||||
));
|
||||
1
apps/web/core/components/rich-filters/shared.ts
Normal file
1
apps/web/core/components/rich-filters/shared.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const COMMON_FILTER_ITEM_BORDER_CLASSNAME = "border-r border-custom-border-200";
|
||||
Loading…
Add table
Add a link
Reference in a new issue