mirror of
https://github.com/gosticks/plane.git
synced 2025-10-16 12:45:33 +00:00
[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
@ -3,6 +3,7 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { Placement } from "@popperjs/core";
|
import { Placement } from "@popperjs/core";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { usePopper } from "react-popper";
|
import { usePopper } from "react-popper";
|
||||||
import { ArrowRight, CalendarCheck2, CalendarDays, X } from "lucide-react";
|
import { ArrowRight, CalendarCheck2, CalendarDays, X } from "lucide-react";
|
||||||
import { Combobox } from "@headlessui/react";
|
import { Combobox } from "@headlessui/react";
|
||||||
@ -59,6 +60,8 @@ type Props = {
|
|||||||
renderPlaceholder?: boolean;
|
renderPlaceholder?: boolean;
|
||||||
customTooltipContent?: React.ReactNode;
|
customTooltipContent?: React.ReactNode;
|
||||||
customTooltipHeading?: string;
|
customTooltipHeading?: string;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
renderInPortal?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DateRangeDropdown: React.FC<Props> = observer((props) => {
|
export const DateRangeDropdown: React.FC<Props> = observer((props) => {
|
||||||
@ -93,9 +96,11 @@ export const DateRangeDropdown: React.FC<Props> = observer((props) => {
|
|||||||
renderPlaceholder = true,
|
renderPlaceholder = true,
|
||||||
customTooltipContent,
|
customTooltipContent,
|
||||||
customTooltipHeading,
|
customTooltipHeading,
|
||||||
|
defaultOpen = false,
|
||||||
|
renderInPortal = false,
|
||||||
} = props;
|
} = props;
|
||||||
// states
|
// states
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||||
const [dateRange, setDateRange] = useState<DateRange>(value);
|
const [dateRange, setDateRange] = useState<DateRange>(value);
|
||||||
// hooks
|
// hooks
|
||||||
const { data } = useUserProfile();
|
const { data } = useUserProfile();
|
||||||
@ -193,7 +198,9 @@ export const DateRangeDropdown: React.FC<Props> = observer((props) => {
|
|||||||
renderPlaceholder && (
|
renderPlaceholder && (
|
||||||
<>
|
<>
|
||||||
<span className="text-custom-text-400">{placeholder.from}</span>
|
<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>
|
<span className="text-custom-text-400">{placeholder.to}</span>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@ -247,6 +254,34 @@ export const DateRangeDropdown: React.FC<Props> = observer((props) => {
|
|||||||
</button>
|
</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 (
|
return (
|
||||||
<ComboDropDown
|
<ComboDropDown
|
||||||
as="div"
|
as="div"
|
||||||
@ -262,31 +297,7 @@ export const DateRangeDropdown: React.FC<Props> = observer((props) => {
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
renderByDefault={renderByDefault}
|
renderByDefault={renderByDefault}
|
||||||
>
|
>
|
||||||
{isOpen && (
|
{isOpen && Options}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</ComboDropDown>
|
</ComboDropDown>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import React, { useRef, useState } from "react";
|
import React, { useRef, useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
@ -21,6 +23,7 @@ import { TDropdownProps } from "./types";
|
|||||||
|
|
||||||
type Props = TDropdownProps & {
|
type Props = TDropdownProps & {
|
||||||
clearIconClassName?: string;
|
clearIconClassName?: string;
|
||||||
|
defaultOpen?: boolean;
|
||||||
optionsClassName?: string;
|
optionsClassName?: string;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
isClearable?: boolean;
|
isClearable?: boolean;
|
||||||
@ -41,6 +44,7 @@ export const DateDropdown: React.FC<Props> = observer((props) => {
|
|||||||
buttonVariant,
|
buttonVariant,
|
||||||
className = "",
|
className = "",
|
||||||
clearIconClassName = "",
|
clearIconClassName = "",
|
||||||
|
defaultOpen = false,
|
||||||
optionsClassName = "",
|
optionsClassName = "",
|
||||||
closeOnSelect = true,
|
closeOnSelect = true,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@ -60,7 +64,7 @@ export const DateDropdown: React.FC<Props> = observer((props) => {
|
|||||||
renderByDefault = true,
|
renderByDefault = true,
|
||||||
} = props;
|
} = props;
|
||||||
// states
|
// states
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||||
// refs
|
// refs
|
||||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
// hooks
|
// 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";
|
||||||
@ -27,6 +27,7 @@
|
|||||||
"@plane/i18n": "workspace:*",
|
"@plane/i18n": "workspace:*",
|
||||||
"@plane/propel": "workspace:*",
|
"@plane/propel": "workspace:*",
|
||||||
"@plane/services": "workspace:*",
|
"@plane/services": "workspace:*",
|
||||||
|
"@plane/shared-state": "workspace:*",
|
||||||
"@plane/types": "workspace:*",
|
"@plane/types": "workspace:*",
|
||||||
"@plane/ui": "workspace:*",
|
"@plane/ui": "workspace:*",
|
||||||
"@plane/utils": "workspace:*",
|
"@plane/utils": "workspace:*",
|
||||||
|
|||||||
@ -1,38 +1,39 @@
|
|||||||
export * from "./ai";
|
export * from "./ai";
|
||||||
|
export * from "./analytics";
|
||||||
export * from "./auth";
|
export * from "./auth";
|
||||||
export * from "./chart";
|
export * from "./chart";
|
||||||
|
export * from "./cycle";
|
||||||
|
export * from "./dashboard";
|
||||||
|
export * from "./emoji";
|
||||||
export * from "./endpoints";
|
export * from "./endpoints";
|
||||||
|
export * from "./estimates";
|
||||||
|
export * from "./event-tracker";
|
||||||
export * from "./file";
|
export * from "./file";
|
||||||
export * from "./filter";
|
export * from "./filter";
|
||||||
export * from "./graph";
|
export * from "./graph";
|
||||||
|
export * from "./icon";
|
||||||
export * from "./instance";
|
export * from "./instance";
|
||||||
|
export * from "./intake";
|
||||||
export * from "./issue";
|
export * from "./issue";
|
||||||
|
export * from "./label";
|
||||||
export * from "./metadata";
|
export * from "./metadata";
|
||||||
|
export * from "./module";
|
||||||
export * from "./notification";
|
export * from "./notification";
|
||||||
|
export * from "./page";
|
||||||
|
export * from "./payment";
|
||||||
|
export * from "./profile";
|
||||||
|
export * from "./project";
|
||||||
|
export * from "./rich-filters";
|
||||||
|
export * from "./settings";
|
||||||
|
export * from "./sidebar";
|
||||||
|
export * from "./spreadsheet";
|
||||||
export * from "./state";
|
export * from "./state";
|
||||||
|
export * from "./stickies";
|
||||||
|
export * from "./subscription";
|
||||||
export * from "./swr";
|
export * from "./swr";
|
||||||
export * from "./tab-indices";
|
export * from "./tab-indices";
|
||||||
export * from "./user";
|
|
||||||
export * from "./payment";
|
|
||||||
export * from "./workspace";
|
|
||||||
export * from "./stickies";
|
|
||||||
export * from "./cycle";
|
|
||||||
export * from "./module";
|
|
||||||
export * from "./project";
|
|
||||||
export * from "./views";
|
|
||||||
export * from "./themes";
|
export * from "./themes";
|
||||||
export * from "./intake";
|
export * from "./user";
|
||||||
export * from "./profile";
|
export * from "./views";
|
||||||
export * from "./workspace-drafts";
|
export * from "./workspace-drafts";
|
||||||
export * from "./label";
|
export * from "./workspace";
|
||||||
export * from "./event-tracker";
|
|
||||||
export * from "./spreadsheet";
|
|
||||||
export * from "./dashboard";
|
|
||||||
export * from "./page";
|
|
||||||
export * from "./emoji";
|
|
||||||
export * from "./subscription";
|
|
||||||
export * from "./settings";
|
|
||||||
export * from "./icon";
|
|
||||||
export * from "./estimates";
|
|
||||||
export * from "./analytics";
|
|
||||||
export * from "./sidebar";
|
|
||||||
|
|||||||
2
packages/constants/src/rich-filters/index.ts
Normal file
2
packages/constants/src/rich-filters/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./operator-labels";
|
||||||
|
export * from "./option";
|
||||||
24
packages/constants/src/rich-filters/operator-labels/core.ts
Normal file
24
packages/constants/src/rich-filters/operator-labels/core.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
CORE_EQUALITY_OPERATOR,
|
||||||
|
CORE_COLLECTION_OPERATOR,
|
||||||
|
CORE_COMPARISON_OPERATOR,
|
||||||
|
TCoreSupportedOperators,
|
||||||
|
TCoreSupportedDateFilterOperators,
|
||||||
|
} from "@plane/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core operator labels
|
||||||
|
*/
|
||||||
|
export const CORE_OPERATOR_LABELS_MAP: Record<TCoreSupportedOperators, string> = {
|
||||||
|
[CORE_EQUALITY_OPERATOR.EXACT]: "is",
|
||||||
|
[CORE_COLLECTION_OPERATOR.IN]: "is any of",
|
||||||
|
[CORE_COMPARISON_OPERATOR.RANGE]: "between",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core date-specific operator labels
|
||||||
|
*/
|
||||||
|
export const CORE_DATE_OPERATOR_LABELS_MAP: Record<TCoreSupportedDateFilterOperators, string> = {
|
||||||
|
[CORE_EQUALITY_OPERATOR.EXACT]: "is",
|
||||||
|
[CORE_COMPARISON_OPERATOR.RANGE]: "between",
|
||||||
|
} as const;
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
import { TExtendedSupportedOperators } from "@plane/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended operator labels
|
||||||
|
*/
|
||||||
|
export const EXTENDED_OPERATOR_LABELS_MAP: Record<TExtendedSupportedOperators, string> = {} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended date-specific operator labels
|
||||||
|
*/
|
||||||
|
export const EXTENDED_DATE_OPERATOR_LABELS_MAP: Record<TExtendedSupportedOperators, string> = {} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Negated operator labels for all operators
|
||||||
|
*/
|
||||||
|
export const NEGATED_OPERATOR_LABELS_MAP: Record<never, string> = {} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Negated date operator labels for all date operators
|
||||||
|
*/
|
||||||
|
export const NEGATED_DATE_OPERATOR_LABELS_MAP: Record<never, string> = {} as const;
|
||||||
36
packages/constants/src/rich-filters/operator-labels/index.ts
Normal file
36
packages/constants/src/rich-filters/operator-labels/index.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { TAllAvailableOperatorsForDisplay, TAllAvailableDateFilterOperatorsForDisplay } from "@plane/types";
|
||||||
|
import { CORE_OPERATOR_LABELS_MAP, CORE_DATE_OPERATOR_LABELS_MAP } from "./core";
|
||||||
|
import {
|
||||||
|
EXTENDED_OPERATOR_LABELS_MAP,
|
||||||
|
EXTENDED_DATE_OPERATOR_LABELS_MAP,
|
||||||
|
NEGATED_OPERATOR_LABELS_MAP,
|
||||||
|
NEGATED_DATE_OPERATOR_LABELS_MAP,
|
||||||
|
} from "./extended";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty operator label for unselected state
|
||||||
|
*/
|
||||||
|
export const EMPTY_OPERATOR_LABEL = "--";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete operator labels mapping - combines core, extended, and negated labels
|
||||||
|
*/
|
||||||
|
export const OPERATOR_LABELS_MAP: Record<TAllAvailableOperatorsForDisplay, string> = {
|
||||||
|
...CORE_OPERATOR_LABELS_MAP,
|
||||||
|
...EXTENDED_OPERATOR_LABELS_MAP,
|
||||||
|
...NEGATED_OPERATOR_LABELS_MAP,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete date operator labels mapping - combines core, extended, and negated labels
|
||||||
|
*/
|
||||||
|
export const DATE_OPERATOR_LABELS_MAP: Record<TAllAvailableDateFilterOperatorsForDisplay, string> = {
|
||||||
|
...CORE_DATE_OPERATOR_LABELS_MAP,
|
||||||
|
...EXTENDED_DATE_OPERATOR_LABELS_MAP,
|
||||||
|
...NEGATED_DATE_OPERATOR_LABELS_MAP,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// -------- RE-EXPORTS --------
|
||||||
|
|
||||||
|
export * from "./core";
|
||||||
|
export * from "./extended";
|
||||||
63
packages/constants/src/rich-filters/option.ts
Normal file
63
packages/constants/src/rich-filters/option.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { TExternalFilter } from "@plane/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter config options.
|
||||||
|
*/
|
||||||
|
export type TConfigOptions = Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default filter config options.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_FILTER_CONFIG_OPTIONS: TConfigOptions = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear filter config.
|
||||||
|
*/
|
||||||
|
export type TClearFilterOptions = {
|
||||||
|
label?: string;
|
||||||
|
onFilterClear: () => void | Promise<void>;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save view config.
|
||||||
|
*/
|
||||||
|
export type TSaveViewOptions<E extends TExternalFilter> = {
|
||||||
|
label?: string;
|
||||||
|
onViewSave: (expression: E) => void | Promise<void>;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update view config.
|
||||||
|
*/
|
||||||
|
export type TUpdateViewOptions<E extends TExternalFilter> = {
|
||||||
|
label?: string;
|
||||||
|
hasAdditionalChanges?: boolean;
|
||||||
|
onViewUpdate: (expression: E) => void | Promise<void>;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter expression options.
|
||||||
|
*/
|
||||||
|
export type TExpressionOptions<E extends TExternalFilter> = {
|
||||||
|
clearFilterOptions?: TClearFilterOptions;
|
||||||
|
saveViewOptions?: TSaveViewOptions<E>;
|
||||||
|
updateViewOptions?: TUpdateViewOptions<E>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default filter expression options.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_FILTER_EXPRESSION_OPTIONS: TExpressionOptions<TExternalFilter> = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter options.
|
||||||
|
* - expression: Filter expression options.
|
||||||
|
* - config: Filter config options.
|
||||||
|
*/
|
||||||
|
export type TFilterOptions<E extends TExternalFilter> = {
|
||||||
|
expression: Partial<TExpressionOptions<E>>;
|
||||||
|
config: Partial<TConfigOptions>;
|
||||||
|
};
|
||||||
@ -15,13 +15,21 @@
|
|||||||
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
|
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@plane/constants": "workspace:*",
|
||||||
|
"@plane/types": "workspace:*",
|
||||||
|
"@plane/utils": "workspace:*",
|
||||||
|
"lodash": "catalog:",
|
||||||
"mobx": "catalog:",
|
"mobx": "catalog:",
|
||||||
|
"mobx-utils": "catalog:",
|
||||||
|
"uuid": "catalog:",
|
||||||
"zod": "^3.22.2"
|
"zod": "^3.22.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@plane/eslint-config": "workspace:*",
|
"@plane/eslint-config": "workspace:*",
|
||||||
"@plane/typescript-config": "workspace:*",
|
"@plane/typescript-config": "workspace:*",
|
||||||
"@types/node": "^22.5.4",
|
"@types/node": "^22.5.4",
|
||||||
|
"@types/lodash": "catalog:",
|
||||||
|
"@types/uuid": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./store";
|
||||||
|
export * from "./utils";
|
||||||
1
packages/shared-state/src/store/index.ts
Normal file
1
packages/shared-state/src/store/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./rich-filters";
|
||||||
31
packages/shared-state/src/store/rich-filters/adapter.ts
Normal file
31
packages/shared-state/src/store/rich-filters/adapter.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
// plane imports
|
||||||
|
import { IFilterAdapter, TExternalFilter, TFilterExpression, TFilterProperty } from "@plane/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract base class for converting between external filter formats and internal filter expressions.
|
||||||
|
* Provides common utilities for creating and manipulating filter nodes.
|
||||||
|
*
|
||||||
|
* @template K - Property key type that extends TFilterProperty
|
||||||
|
* @template E - External filter type that extends TExternalFilter
|
||||||
|
*/
|
||||||
|
export abstract class FilterAdapter<K extends TFilterProperty, E extends TExternalFilter>
|
||||||
|
implements IFilterAdapter<K, E>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Converts an external filter format to internal filter expression.
|
||||||
|
* Must be implemented by concrete adapter classes.
|
||||||
|
*
|
||||||
|
* @param externalFilter - The external filter to convert
|
||||||
|
* @returns The internal filter expression or null if conversion fails
|
||||||
|
*/
|
||||||
|
abstract toInternal(externalFilter: E): TFilterExpression<K> | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an internal filter expression to external filter format.
|
||||||
|
* Must be implemented by concrete adapter classes.
|
||||||
|
*
|
||||||
|
* @param internalFilter - The internal filter expression to convert
|
||||||
|
* @returns The external filter format
|
||||||
|
*/
|
||||||
|
abstract toExternal(internalFilter: TFilterExpression<K> | null): E;
|
||||||
|
}
|
||||||
173
packages/shared-state/src/store/rich-filters/config-manager.ts
Normal file
173
packages/shared-state/src/store/rich-filters/config-manager.ts
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import { action, computed, makeObservable, observable } from "mobx";
|
||||||
|
import { computedFn } from "mobx-utils";
|
||||||
|
// plane imports
|
||||||
|
import { DEFAULT_FILTER_CONFIG_OPTIONS, TConfigOptions } from "@plane/constants";
|
||||||
|
import { TExternalFilter, TFilterConfig, TFilterProperty, TFilterValue } from "@plane/types";
|
||||||
|
// local imports
|
||||||
|
import { FilterConfig, IFilterConfig } from "./config";
|
||||||
|
import { IFilterInstance } from "./filter";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for managing filter configurations.
|
||||||
|
* Provides methods to register, update, and retrieve filter configurations.
|
||||||
|
* - filterConfigs: Map storing filter configurations by their ID
|
||||||
|
* - configOptions: Configuration options controlling filter behavior
|
||||||
|
* - allConfigs: All registered filter configurations
|
||||||
|
* - allAvailableConfigs: All available filter configurations based on current state
|
||||||
|
* - getConfigByProperty: Retrieves a filter configuration by its ID
|
||||||
|
* - register: Registers a single filter configuration
|
||||||
|
* - registerAll: Registers multiple filter configurations
|
||||||
|
* - updateConfigByProperty: Updates an existing filter configuration by ID
|
||||||
|
* @template P - The filter property type extending TFilterProperty
|
||||||
|
*/
|
||||||
|
export interface IFilterConfigManager<P extends TFilterProperty> {
|
||||||
|
// observables
|
||||||
|
filterConfigs: Map<P, IFilterConfig<P, TFilterValue>>; // filter property -> config
|
||||||
|
configOptions: TConfigOptions;
|
||||||
|
// computed
|
||||||
|
allAvailableConfigs: IFilterConfig<P, TFilterValue>[];
|
||||||
|
// computed functions
|
||||||
|
getConfigByProperty: (property: P) => IFilterConfig<P, TFilterValue> | undefined;
|
||||||
|
// helpers
|
||||||
|
register: <C extends TFilterConfig<P, TFilterValue>>(config: C) => void;
|
||||||
|
registerAll: (configs: TFilterConfig<P, TFilterValue>[]) => void;
|
||||||
|
updateConfigByProperty: (property: P, configUpdates: Partial<TFilterConfig<P, TFilterValue>>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameters for initializing the FilterConfigManager.
|
||||||
|
* - options: Optional configuration options to override defaults
|
||||||
|
*/
|
||||||
|
export type TConfigManagerParams = {
|
||||||
|
options?: Partial<TConfigOptions>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages filter configurations for a filter instance.
|
||||||
|
* Handles registration, updates, and retrieval of filter configurations.
|
||||||
|
* Provides computed properties for available configurations based on current filter state.
|
||||||
|
*
|
||||||
|
* @template P - The filter property type extending TFilterProperty
|
||||||
|
* @template V - The filter value type extending TFilterValue
|
||||||
|
* @template E - The external filter type extending TExternalFilter
|
||||||
|
*/
|
||||||
|
export class FilterConfigManager<P extends TFilterProperty, E extends TExternalFilter = TExternalFilter>
|
||||||
|
implements IFilterConfigManager<P>
|
||||||
|
{
|
||||||
|
// observables
|
||||||
|
filterConfigs: IFilterConfigManager<P>["filterConfigs"];
|
||||||
|
configOptions: IFilterConfigManager<P>["configOptions"];
|
||||||
|
// parent filter instance
|
||||||
|
_filterInstance: IFilterInstance<P, E>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new FilterConfigManager instance.
|
||||||
|
*
|
||||||
|
* @param filterInstance - The parent filter instance this manager belongs to
|
||||||
|
* @param params - Configuration parameters for the manager
|
||||||
|
*/
|
||||||
|
constructor(filterInstance: IFilterInstance<P, E>, params: TConfigManagerParams) {
|
||||||
|
this.filterConfigs = new Map<P, IFilterConfig<P>>();
|
||||||
|
this.configOptions = this._initializeConfigOptions(params.options);
|
||||||
|
// parent filter instance
|
||||||
|
this._filterInstance = filterInstance;
|
||||||
|
|
||||||
|
makeObservable(this, {
|
||||||
|
filterConfigs: observable,
|
||||||
|
configOptions: observable,
|
||||||
|
// computed
|
||||||
|
allAvailableConfigs: computed,
|
||||||
|
// helpers
|
||||||
|
register: action,
|
||||||
|
registerAll: action,
|
||||||
|
updateConfigByProperty: action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------ computed ------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all available filterConfigs.
|
||||||
|
* If allowSameFilters is true, all enabled configs are returned.
|
||||||
|
* Otherwise, only configs that are not already applied to the filter instance are returned.
|
||||||
|
* @returns All available filterConfigs.
|
||||||
|
*/
|
||||||
|
get allAvailableConfigs(): IFilterConfigManager<P>["allAvailableConfigs"] {
|
||||||
|
const appliedProperties = new Set(this._filterInstance.allConditions.map((condition) => condition.property));
|
||||||
|
// Return all enabled configs that either allow multiple filters or are not currently applied
|
||||||
|
return this._allEnabledConfigs.filter((config) => config.allowMultipleFilters || !appliedProperties.has(config.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------ computed functions ------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a config by filter property.
|
||||||
|
* @param property - The property to get the config for.
|
||||||
|
* @returns The config for the property, or undefined if not found.
|
||||||
|
*/
|
||||||
|
getConfigByProperty: IFilterConfigManager<P>["getConfigByProperty"] = computedFn(
|
||||||
|
(property) => this.filterConfigs.get(property) as IFilterConfig<P, TFilterValue>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ------------ helpers ------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a config.
|
||||||
|
* If a config with the same property already exists, it will be updated with the new values.
|
||||||
|
* Otherwise, a new config will be created.
|
||||||
|
* @param configUpdates - The config updates to register.
|
||||||
|
*/
|
||||||
|
register: IFilterConfigManager<P>["register"] = action((configUpdates) => {
|
||||||
|
if (this.filterConfigs.has(configUpdates.id)) {
|
||||||
|
// Update existing config if it has differences
|
||||||
|
const existingConfig = this.filterConfigs.get(configUpdates.id)!;
|
||||||
|
existingConfig.mutate(configUpdates);
|
||||||
|
} else {
|
||||||
|
// Create new config if it doesn't exist
|
||||||
|
this.filterConfigs.set(configUpdates.id, new FilterConfig(configUpdates));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all configs.
|
||||||
|
* @param configs - The configs to register.
|
||||||
|
*/
|
||||||
|
registerAll: IFilterConfigManager<P>["registerAll"] = action((configs) => {
|
||||||
|
configs.forEach((config) => this.register(config));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a config by filter property.
|
||||||
|
* @param property - The property of the config to update.
|
||||||
|
* @param configUpdates - The updates to apply to the config.
|
||||||
|
*/
|
||||||
|
updateConfigByProperty: IFilterConfigManager<P>["updateConfigByProperty"] = action((property, configUpdates) => {
|
||||||
|
const prevConfig = this.filterConfigs.get(property);
|
||||||
|
prevConfig?.mutate(configUpdates);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------ private computed ------------
|
||||||
|
|
||||||
|
private get _allConfigs(): IFilterConfig<P, TFilterValue>[] {
|
||||||
|
return Array.from(this.filterConfigs.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all enabled filterConfigs.
|
||||||
|
* @returns All enabled filterConfigs.
|
||||||
|
*/
|
||||||
|
private get _allEnabledConfigs(): IFilterConfig<P, TFilterValue>[] {
|
||||||
|
return this._allConfigs.filter((config) => config.isEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------ private helpers ------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the config options.
|
||||||
|
* @param options - The options to initialize the config options with.
|
||||||
|
* @returns The initialized config options.
|
||||||
|
*/
|
||||||
|
private _initializeConfigOptions(options?: Partial<TConfigOptions>): TConfigOptions {
|
||||||
|
return DEFAULT_FILTER_CONFIG_OPTIONS ? { ...DEFAULT_FILTER_CONFIG_OPTIONS, ...options } : options || {};
|
||||||
|
}
|
||||||
|
}
|
||||||
212
packages/shared-state/src/store/rich-filters/config.ts
Normal file
212
packages/shared-state/src/store/rich-filters/config.ts
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
import set from "lodash/set";
|
||||||
|
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||||
|
import { computedFn } from "mobx-utils";
|
||||||
|
// plane imports
|
||||||
|
import { EMPTY_OPERATOR_LABEL } from "@plane/constants";
|
||||||
|
import {
|
||||||
|
FILTER_FIELD_TYPE,
|
||||||
|
TSupportedOperators,
|
||||||
|
TFilterConfig,
|
||||||
|
TFilterProperty,
|
||||||
|
TFilterValue,
|
||||||
|
TOperatorSpecificConfigs,
|
||||||
|
TAllAvailableOperatorsForDisplay,
|
||||||
|
} from "@plane/types";
|
||||||
|
import {
|
||||||
|
getOperatorLabel,
|
||||||
|
isDateFilterType,
|
||||||
|
getDateOperatorLabel,
|
||||||
|
isDateFilterOperator,
|
||||||
|
getOperatorForPayload,
|
||||||
|
} from "@plane/utils";
|
||||||
|
|
||||||
|
type TOperatorOptionForDisplay = {
|
||||||
|
value: TAllAvailableOperatorsForDisplay;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IFilterConfig<P extends TFilterProperty, V extends TFilterValue = TFilterValue>
|
||||||
|
extends TFilterConfig<P, V> {
|
||||||
|
// computed
|
||||||
|
allSupportedOperators: TSupportedOperators[];
|
||||||
|
allSupportedOperatorConfigs: TOperatorSpecificConfigs<V>[keyof TOperatorSpecificConfigs<V>][];
|
||||||
|
firstOperator: TSupportedOperators | undefined;
|
||||||
|
// computed functions
|
||||||
|
getOperatorConfig: (
|
||||||
|
operator: TAllAvailableOperatorsForDisplay
|
||||||
|
) => TOperatorSpecificConfigs<V>[keyof TOperatorSpecificConfigs<V>] | undefined;
|
||||||
|
getLabelForOperator: (operator: TAllAvailableOperatorsForDisplay | undefined) => string;
|
||||||
|
getDisplayOperatorByValue: <T extends TSupportedOperators | TAllAvailableOperatorsForDisplay>(
|
||||||
|
operator: T,
|
||||||
|
value: V
|
||||||
|
) => T;
|
||||||
|
getAllDisplayOperatorOptionsByValue: (value: V) => TOperatorOptionForDisplay[];
|
||||||
|
// actions
|
||||||
|
mutate: (updates: Partial<TFilterConfig<P, V>>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FilterConfig<P extends TFilterProperty, V extends TFilterValue = TFilterValue>
|
||||||
|
implements IFilterConfig<P, V>
|
||||||
|
{
|
||||||
|
// observables
|
||||||
|
id: IFilterConfig<P, V>["id"];
|
||||||
|
label: IFilterConfig<P, V>["label"];
|
||||||
|
icon?: IFilterConfig<P, V>["icon"];
|
||||||
|
isEnabled: IFilterConfig<P, V>["isEnabled"];
|
||||||
|
supportedOperatorConfigsMap: IFilterConfig<P, V>["supportedOperatorConfigsMap"];
|
||||||
|
allowMultipleFilters: IFilterConfig<P, V>["allowMultipleFilters"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new FilterConfig instance.
|
||||||
|
* @param params - The parameters for the filter config.
|
||||||
|
*/
|
||||||
|
constructor(params: TFilterConfig<P, V>) {
|
||||||
|
this.id = params.id;
|
||||||
|
this.label = params.label;
|
||||||
|
this.icon = params.icon;
|
||||||
|
this.isEnabled = params.isEnabled;
|
||||||
|
this.supportedOperatorConfigsMap = params.supportedOperatorConfigsMap;
|
||||||
|
this.allowMultipleFilters = params.allowMultipleFilters;
|
||||||
|
|
||||||
|
makeObservable(this, {
|
||||||
|
id: observable,
|
||||||
|
label: observable,
|
||||||
|
icon: observable,
|
||||||
|
isEnabled: observable,
|
||||||
|
supportedOperatorConfigsMap: observable,
|
||||||
|
allowMultipleFilters: observable,
|
||||||
|
// computed
|
||||||
|
allSupportedOperators: computed,
|
||||||
|
allSupportedOperatorConfigs: computed,
|
||||||
|
firstOperator: computed,
|
||||||
|
// actions
|
||||||
|
mutate: action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------ computed ------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all supported operators.
|
||||||
|
* @returns All supported operators.
|
||||||
|
*/
|
||||||
|
get allSupportedOperators(): IFilterConfig<P, V>["allSupportedOperators"] {
|
||||||
|
return Array.from(this.supportedOperatorConfigsMap.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all supported operator configs.
|
||||||
|
* @returns All supported operator configs.
|
||||||
|
*/
|
||||||
|
get allSupportedOperatorConfigs(): IFilterConfig<P, V>["allSupportedOperatorConfigs"] {
|
||||||
|
return Array.from(this.supportedOperatorConfigsMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the first operator.
|
||||||
|
* @returns The first operator.
|
||||||
|
*/
|
||||||
|
get firstOperator(): IFilterConfig<P, V>["firstOperator"] {
|
||||||
|
return this.allSupportedOperators[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------ computed functions ------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the operator config.
|
||||||
|
* @param operator - The operator.
|
||||||
|
* @returns The operator config.
|
||||||
|
*/
|
||||||
|
getOperatorConfig: IFilterConfig<P, V>["getOperatorConfig"] = computedFn((operator) =>
|
||||||
|
this.supportedOperatorConfigsMap.get(getOperatorForPayload(operator).operator)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the label for an operator.
|
||||||
|
* @param operator - The operator.
|
||||||
|
* @returns The label for the operator.
|
||||||
|
*/
|
||||||
|
getLabelForOperator: IFilterConfig<P, V>["getLabelForOperator"] = computedFn((operator) => {
|
||||||
|
if (!operator) return EMPTY_OPERATOR_LABEL;
|
||||||
|
|
||||||
|
const operatorConfig = this.getOperatorConfig(operator);
|
||||||
|
|
||||||
|
if (operatorConfig?.operatorLabel) {
|
||||||
|
return operatorConfig.operatorLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operatorConfig?.type && isDateFilterType(operatorConfig.type) && isDateFilterOperator(operator)) {
|
||||||
|
return getDateOperatorLabel(operator);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getOperatorLabel(operator);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the operator for a value.
|
||||||
|
* @param value - The value.
|
||||||
|
* @returns The operator for the value.
|
||||||
|
*/
|
||||||
|
getDisplayOperatorByValue: IFilterConfig<P, V>["getDisplayOperatorByValue"] = computedFn((operator, value) => {
|
||||||
|
const operatorConfig = this.getOperatorConfig(operator);
|
||||||
|
if (operatorConfig?.type === FILTER_FIELD_TYPE.MULTI_SELECT && (Array.isArray(value) ? value.length : 0) <= 1) {
|
||||||
|
return operatorConfig.singleValueOperator as typeof operator;
|
||||||
|
}
|
||||||
|
return operator;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all supported operator options for display in the filter UI.
|
||||||
|
* This method filters out operators that are already applied (unless multiple filters are allowed)
|
||||||
|
* and includes both positive and negative variants when supported.
|
||||||
|
*
|
||||||
|
* @param value - The current filter value used to determine the appropriate operator variant
|
||||||
|
* @returns Array of operator options with their display labels and values
|
||||||
|
*/
|
||||||
|
getAllDisplayOperatorOptionsByValue: IFilterConfig<P, V>["getAllDisplayOperatorOptionsByValue"] = computedFn(
|
||||||
|
(value) => {
|
||||||
|
const operatorOptions: TOperatorOptionForDisplay[] = [];
|
||||||
|
|
||||||
|
// Process each supported operator to build display options
|
||||||
|
for (const operator of this.allSupportedOperators) {
|
||||||
|
const displayOperator = this.getDisplayOperatorByValue(operator, value);
|
||||||
|
const displayOperatorLabel = this.getLabelForOperator(displayOperator);
|
||||||
|
operatorOptions.push({
|
||||||
|
value: operator,
|
||||||
|
label: displayOperatorLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
const additionalOperatorOption = this._getAdditionalOperatorOptions(operator, value);
|
||||||
|
if (additionalOperatorOption) {
|
||||||
|
operatorOptions.push(additionalOperatorOption);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return operatorOptions;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ------------ actions ------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutates the config.
|
||||||
|
* @param updates - The updates to apply to the config.
|
||||||
|
*/
|
||||||
|
mutate: IFilterConfig<P, V>["mutate"] = action((updates) => {
|
||||||
|
runInAction(() => {
|
||||||
|
for (const key in updates) {
|
||||||
|
if (updates.hasOwnProperty(key)) {
|
||||||
|
const configKey = key as keyof TFilterConfig<P, V>;
|
||||||
|
set(this, configKey, updates[configKey]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------ private helpers ------------
|
||||||
|
|
||||||
|
private _getAdditionalOperatorOptions = (
|
||||||
|
_operator: TSupportedOperators,
|
||||||
|
_value: V
|
||||||
|
): TOperatorOptionForDisplay | undefined => undefined;
|
||||||
|
}
|
||||||
172
packages/shared-state/src/store/rich-filters/filter-helpers.ts
Normal file
172
packages/shared-state/src/store/rich-filters/filter-helpers.ts
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import cloneDeep from "lodash/cloneDeep";
|
||||||
|
import { toJS } from "mobx";
|
||||||
|
// plane imports
|
||||||
|
import { DEFAULT_FILTER_EXPRESSION_OPTIONS, TExpressionOptions } from "@plane/constants";
|
||||||
|
import {
|
||||||
|
IFilterAdapter,
|
||||||
|
LOGICAL_OPERATOR,
|
||||||
|
TSupportedOperators,
|
||||||
|
TFilterExpression,
|
||||||
|
TFilterValue,
|
||||||
|
TFilterProperty,
|
||||||
|
TExternalFilter,
|
||||||
|
TLogicalOperator,
|
||||||
|
TFilterConditionPayload,
|
||||||
|
} from "@plane/types";
|
||||||
|
import { addAndCondition, createConditionNode, updateNodeInExpression } from "@plane/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for filter instance helper utilities.
|
||||||
|
* Provides comprehensive methods for filter expression manipulation, node operations,
|
||||||
|
* operator utilities, and expression restructuring.
|
||||||
|
* @template P - The filter property type extending TFilterProperty
|
||||||
|
* @template E - The external filter type extending TExternalFilter
|
||||||
|
*/
|
||||||
|
export interface IFilterInstanceHelper<P extends TFilterProperty, E extends TExternalFilter> {
|
||||||
|
// initialization
|
||||||
|
initializeExpression: (initialExpression?: E) => TFilterExpression<P> | null;
|
||||||
|
initializeExpressionOptions: (expressionOptions?: Partial<TExpressionOptions<E>>) => TExpressionOptions<E>;
|
||||||
|
// condition operations
|
||||||
|
addConditionToExpression: <V extends TFilterValue>(
|
||||||
|
expression: TFilterExpression<P> | null,
|
||||||
|
groupOperator: TLogicalOperator,
|
||||||
|
condition: TFilterConditionPayload<P, V>,
|
||||||
|
isNegation: boolean
|
||||||
|
) => TFilterExpression<P> | null;
|
||||||
|
// group operations
|
||||||
|
restructureExpressionForOperatorChange: (
|
||||||
|
expression: TFilterExpression<P> | null,
|
||||||
|
conditionId: string,
|
||||||
|
newOperator: TSupportedOperators,
|
||||||
|
isNegation: boolean,
|
||||||
|
shouldResetValue: boolean
|
||||||
|
) => TFilterExpression<P> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comprehensive helper class for filter instance operations.
|
||||||
|
* Provides utilities for filter expression manipulation, node operations,
|
||||||
|
* operator transformations, and expression restructuring.
|
||||||
|
*
|
||||||
|
* @template K - The filter property type extending TFilterProperty
|
||||||
|
* @template E - The external filter type extending TExternalFilter
|
||||||
|
*/
|
||||||
|
export class FilterInstanceHelper<P extends TFilterProperty, E extends TExternalFilter>
|
||||||
|
implements IFilterInstanceHelper<P, E>
|
||||||
|
{
|
||||||
|
private adapter: IFilterAdapter<P, E>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new FilterInstanceHelper instance.
|
||||||
|
*
|
||||||
|
* @param adapter - The filter adapter for converting between internal and external formats
|
||||||
|
*/
|
||||||
|
constructor(adapter: IFilterAdapter<P, E>) {
|
||||||
|
this.adapter = adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------ initialization ------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the filter expression from external format.
|
||||||
|
* @param initialExpression - The initial expression to initialize the filter with
|
||||||
|
* @returns The initialized filter expression or null if no initial expression provided
|
||||||
|
*/
|
||||||
|
initializeExpression: IFilterInstanceHelper<P, E>["initializeExpression"] = (initialExpression) => {
|
||||||
|
if (!initialExpression) return null;
|
||||||
|
return this.adapter.toInternal(toJS(cloneDeep(initialExpression)));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the filter expression options with defaults.
|
||||||
|
* @param expressionOptions - Optional expression options to override defaults
|
||||||
|
* @returns The initialized filter expression options
|
||||||
|
*/
|
||||||
|
initializeExpressionOptions: IFilterInstanceHelper<P, E>["initializeExpressionOptions"] = (expressionOptions) => ({
|
||||||
|
...DEFAULT_FILTER_EXPRESSION_OPTIONS,
|
||||||
|
...expressionOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------ condition operations ------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a condition to the filter expression based on the logical operator.
|
||||||
|
* @param expression - The current filter expression
|
||||||
|
* @param groupOperator - The logical operator to use for the condition
|
||||||
|
* @param condition - The condition to add
|
||||||
|
* @param isNegation - Whether the condition should be negated
|
||||||
|
* @returns The updated filter expression
|
||||||
|
*/
|
||||||
|
addConditionToExpression: IFilterInstanceHelper<P, E>["addConditionToExpression"] = (
|
||||||
|
expression,
|
||||||
|
groupOperator,
|
||||||
|
condition,
|
||||||
|
isNegation
|
||||||
|
) => this._addConditionByOperator(expression, groupOperator, this._getConditionPayloadToAdd(condition, isNegation));
|
||||||
|
|
||||||
|
// ------------ group operations ------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restructures the expression when a condition's operator changes between positive and negative.
|
||||||
|
* @param expression - The filter expression to operate on
|
||||||
|
* @param conditionId - The ID of the condition being updated
|
||||||
|
* @param newOperator - The new operator for the condition
|
||||||
|
* @param isNegation - Whether the operator is negation
|
||||||
|
* @param shouldResetValue - Whether to reset the condition value
|
||||||
|
* @returns The restructured expression
|
||||||
|
*/
|
||||||
|
restructureExpressionForOperatorChange: IFilterInstanceHelper<P, E>["restructureExpressionForOperatorChange"] = (
|
||||||
|
expression,
|
||||||
|
conditionId,
|
||||||
|
newOperator,
|
||||||
|
_isNegation,
|
||||||
|
shouldResetValue
|
||||||
|
) => {
|
||||||
|
if (!expression) return null;
|
||||||
|
|
||||||
|
const payload = shouldResetValue ? { operator: newOperator, value: undefined } : { operator: newOperator };
|
||||||
|
|
||||||
|
// Update the condition with the new operator
|
||||||
|
updateNodeInExpression(expression, conditionId, payload);
|
||||||
|
|
||||||
|
return expression;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------ private helpers ------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the condition payload to add to the expression.
|
||||||
|
* @param conditionNode - The condition node to add
|
||||||
|
* @param isNegation - Whether the condition should be negated
|
||||||
|
* @returns The condition payload to add
|
||||||
|
*/
|
||||||
|
private _getConditionPayloadToAdd = (
|
||||||
|
condition: TFilterConditionPayload<P, TFilterValue>,
|
||||||
|
_isNegation: boolean
|
||||||
|
): TFilterExpression<P> => {
|
||||||
|
const conditionNode = createConditionNode(condition);
|
||||||
|
|
||||||
|
return conditionNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the logical operator switch for adding conditions.
|
||||||
|
* @param expression - The current expression
|
||||||
|
* @param groupOperator - The logical operator
|
||||||
|
* @param conditionToAdd - The condition to add
|
||||||
|
* @returns The updated expression
|
||||||
|
*/
|
||||||
|
private _addConditionByOperator(
|
||||||
|
expression: TFilterExpression<P> | null,
|
||||||
|
groupOperator: TLogicalOperator,
|
||||||
|
conditionToAdd: TFilterExpression<P>
|
||||||
|
): TFilterExpression<P> | null {
|
||||||
|
switch (groupOperator) {
|
||||||
|
case LOGICAL_OPERATOR.AND:
|
||||||
|
return addAndCondition(expression, conditionToAdd);
|
||||||
|
default:
|
||||||
|
console.warn(`Unsupported logical operator: ${groupOperator}`);
|
||||||
|
return expression;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
490
packages/shared-state/src/store/rich-filters/filter.ts
Normal file
490
packages/shared-state/src/store/rich-filters/filter.ts
Normal file
@ -0,0 +1,490 @@
|
|||||||
|
import cloneDeep from "lodash/cloneDeep";
|
||||||
|
import { action, computed, makeObservable, observable, toJS } from "mobx";
|
||||||
|
import { computedFn } from "mobx-utils";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
// plane imports
|
||||||
|
import {
|
||||||
|
TClearFilterOptions,
|
||||||
|
TExpressionOptions,
|
||||||
|
TFilterOptions,
|
||||||
|
TSaveViewOptions,
|
||||||
|
TUpdateViewOptions,
|
||||||
|
} from "@plane/constants";
|
||||||
|
import {
|
||||||
|
FILTER_NODE_TYPE,
|
||||||
|
IFilterAdapter,
|
||||||
|
SingleOrArray,
|
||||||
|
TAllAvailableOperatorsForDisplay,
|
||||||
|
TExternalFilter,
|
||||||
|
TFilterConditionNode,
|
||||||
|
TFilterConditionNodeForDisplay,
|
||||||
|
TFilterConditionPayload,
|
||||||
|
TFilterExpression,
|
||||||
|
TFilterProperty,
|
||||||
|
TFilterValue,
|
||||||
|
TLogicalOperator,
|
||||||
|
TSupportedOperators,
|
||||||
|
} from "@plane/types";
|
||||||
|
// local imports
|
||||||
|
import {
|
||||||
|
deepCompareFilterExpressions,
|
||||||
|
extractConditions,
|
||||||
|
extractConditionsWithDisplayOperators,
|
||||||
|
findConditionsByPropertyAndOperator,
|
||||||
|
findNodeById,
|
||||||
|
hasValidValue,
|
||||||
|
removeNodeFromExpression,
|
||||||
|
sanitizeAndStabilizeExpression,
|
||||||
|
shouldNotifyChangeForExpression,
|
||||||
|
updateNodeInExpression,
|
||||||
|
} from "@plane/utils";
|
||||||
|
import { FilterConfigManager, IFilterConfigManager } from "./config-manager";
|
||||||
|
import { FilterInstanceHelper, IFilterInstanceHelper } from "./filter-helpers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for a filter instance.
|
||||||
|
* Provides methods to manage the filter expression and notify changes.
|
||||||
|
* - id: The id of the filter instance
|
||||||
|
* - expression: The filter expression
|
||||||
|
* - adapter: The filter adapter
|
||||||
|
* - configManager: The filter config manager
|
||||||
|
* - onExpressionChange: The callback to notify when the expression changes
|
||||||
|
* - hasActiveFilters: Whether the filter instance has any active filters
|
||||||
|
* - allConditions: All conditions in the filter expression
|
||||||
|
* - allConditionsForDisplay: All conditions in the filter expression
|
||||||
|
* - addCondition: Adds a condition to the filter expression
|
||||||
|
* - updateConditionOperator: Updates the operator of a condition in the filter expression
|
||||||
|
* - updateConditionValue: Updates the value of a condition in the filter expression
|
||||||
|
* - removeCondition: Removes a condition from the filter expression
|
||||||
|
* - clearFilters: Clears the filter expression
|
||||||
|
* @template P - The filter property type extending TFilterProperty
|
||||||
|
* @template E - The external filter type extending TExternalFilter
|
||||||
|
*/
|
||||||
|
export interface IFilterInstance<P extends TFilterProperty, E extends TExternalFilter> {
|
||||||
|
// observables
|
||||||
|
id: string;
|
||||||
|
initialFilterExpression: TFilterExpression<P> | null;
|
||||||
|
expression: TFilterExpression<P> | null;
|
||||||
|
adapter: IFilterAdapter<P, E>;
|
||||||
|
configManager: IFilterConfigManager<P>;
|
||||||
|
onExpressionChange?: (expression: E) => void;
|
||||||
|
// computed
|
||||||
|
hasActiveFilters: boolean;
|
||||||
|
hasChanges: boolean;
|
||||||
|
allConditions: TFilterConditionNode<P, TFilterValue>[];
|
||||||
|
allConditionsForDisplay: TFilterConditionNodeForDisplay<P, TFilterValue>[];
|
||||||
|
// computed option helpers
|
||||||
|
clearFilterOptions: TClearFilterOptions | undefined;
|
||||||
|
saveViewOptions: TSaveViewOptions<E> | undefined;
|
||||||
|
updateViewOptions: TUpdateViewOptions<E> | undefined;
|
||||||
|
// computed permissions
|
||||||
|
canClearFilters: boolean;
|
||||||
|
canSaveView: boolean;
|
||||||
|
canUpdateView: boolean;
|
||||||
|
// filter expression actions
|
||||||
|
resetExpression: (externalExpression: E, shouldResetInitialExpression?: boolean) => void;
|
||||||
|
// filter condition
|
||||||
|
findConditionsByPropertyAndOperator: (
|
||||||
|
property: P,
|
||||||
|
operator: TAllAvailableOperatorsForDisplay
|
||||||
|
) => TFilterConditionNodeForDisplay<P, TFilterValue>[];
|
||||||
|
findFirstConditionByPropertyAndOperator: (
|
||||||
|
property: P,
|
||||||
|
operator: TAllAvailableOperatorsForDisplay
|
||||||
|
) => TFilterConditionNodeForDisplay<P, TFilterValue> | undefined;
|
||||||
|
addCondition: <V extends TFilterValue>(
|
||||||
|
groupOperator: TLogicalOperator,
|
||||||
|
condition: TFilterConditionPayload<P, V>,
|
||||||
|
isNegation: boolean
|
||||||
|
) => void;
|
||||||
|
updateConditionOperator: (conditionId: string, operator: TSupportedOperators, isNegation: boolean) => void;
|
||||||
|
updateConditionValue: <V extends TFilterValue>(conditionId: string, value: SingleOrArray<V>) => void;
|
||||||
|
removeCondition: (conditionId: string) => void;
|
||||||
|
// config actions
|
||||||
|
clearFilters: () => Promise<void>;
|
||||||
|
saveView: () => Promise<void>;
|
||||||
|
updateView: () => Promise<void>;
|
||||||
|
// expression options actions
|
||||||
|
updateExpressionOptions: (newOptions: Partial<TExpressionOptions<E>>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TFilterParams<P extends TFilterProperty, E extends TExternalFilter> = {
|
||||||
|
adapter: IFilterAdapter<P, E>;
|
||||||
|
options?: Partial<TFilterOptions<E>>;
|
||||||
|
initialExpression?: E;
|
||||||
|
onExpressionChange?: (expression: E) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class FilterInstance<P extends TFilterProperty, E extends TExternalFilter> implements IFilterInstance<P, E> {
|
||||||
|
// observables
|
||||||
|
id: string;
|
||||||
|
initialFilterExpression: TFilterExpression<P> | null;
|
||||||
|
expression: TFilterExpression<P> | null;
|
||||||
|
expressionOptions: TExpressionOptions<E>;
|
||||||
|
adapter: IFilterAdapter<P, E>;
|
||||||
|
configManager: IFilterConfigManager<P>;
|
||||||
|
onExpressionChange?: (expression: E) => void;
|
||||||
|
|
||||||
|
// helper instance
|
||||||
|
private helper: IFilterInstanceHelper<P, E>;
|
||||||
|
|
||||||
|
constructor(params: TFilterParams<P, E>) {
|
||||||
|
this.id = uuidv4();
|
||||||
|
this.adapter = params.adapter;
|
||||||
|
this.helper = new FilterInstanceHelper<P, E>(this.adapter);
|
||||||
|
this.configManager = new FilterConfigManager<P, E>(this, {
|
||||||
|
options: params.options?.config,
|
||||||
|
});
|
||||||
|
// initialize expression
|
||||||
|
const initialExpression = this.helper.initializeExpression(params.initialExpression);
|
||||||
|
this.initialFilterExpression = cloneDeep(initialExpression);
|
||||||
|
this.expression = cloneDeep(initialExpression);
|
||||||
|
this.expressionOptions = this.helper.initializeExpressionOptions(params.options?.expression);
|
||||||
|
this.onExpressionChange = params.onExpressionChange;
|
||||||
|
|
||||||
|
makeObservable(this, {
|
||||||
|
// observables
|
||||||
|
id: observable,
|
||||||
|
initialFilterExpression: observable,
|
||||||
|
expression: observable,
|
||||||
|
expressionOptions: observable,
|
||||||
|
adapter: observable,
|
||||||
|
configManager: observable,
|
||||||
|
// computed
|
||||||
|
hasActiveFilters: computed,
|
||||||
|
hasChanges: computed,
|
||||||
|
allConditions: computed,
|
||||||
|
allConditionsForDisplay: computed,
|
||||||
|
// computed option helpers
|
||||||
|
clearFilterOptions: computed,
|
||||||
|
saveViewOptions: computed,
|
||||||
|
updateViewOptions: computed,
|
||||||
|
// computed permissions
|
||||||
|
canClearFilters: computed,
|
||||||
|
canSaveView: computed,
|
||||||
|
canUpdateView: computed,
|
||||||
|
// actions
|
||||||
|
resetExpression: action,
|
||||||
|
findConditionsByPropertyAndOperator: action,
|
||||||
|
findFirstConditionByPropertyAndOperator: action,
|
||||||
|
addCondition: action,
|
||||||
|
updateConditionOperator: action,
|
||||||
|
updateConditionValue: action,
|
||||||
|
removeCondition: action,
|
||||||
|
clearFilters: action,
|
||||||
|
saveView: action,
|
||||||
|
updateView: action,
|
||||||
|
updateExpressionOptions: action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------ computed ------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the filter instance has any active filters.
|
||||||
|
* @returns True if the filter instance has any active filters, false otherwise.
|
||||||
|
*/
|
||||||
|
get hasActiveFilters(): IFilterInstance<P, E>["hasActiveFilters"] {
|
||||||
|
// if the expression is null, return false
|
||||||
|
if (!this.expression) return false;
|
||||||
|
// if there are no conditions, return false
|
||||||
|
if (this.allConditionsForDisplay.length === 0) return false;
|
||||||
|
// if there are conditions, return true if any of them have a valid value
|
||||||
|
return this.allConditionsForDisplay.some((condition) => hasValidValue(condition.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the filter instance has any changes with respect to the initial expression.
|
||||||
|
* @returns True if the filter instance has any changes, false otherwise.
|
||||||
|
*/
|
||||||
|
get hasChanges(): IFilterInstance<P, E>["hasChanges"] {
|
||||||
|
return !deepCompareFilterExpressions(this.initialFilterExpression, this.expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all conditions from the filter expression.
|
||||||
|
* @returns An array of filter conditions.
|
||||||
|
*/
|
||||||
|
get allConditions(): IFilterInstance<P, E>["allConditions"] {
|
||||||
|
if (!this.expression) return [];
|
||||||
|
return extractConditions(this.expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all conditions in the filter expression for display purposes.
|
||||||
|
* @returns An array of filter conditions for display purposes.
|
||||||
|
*/
|
||||||
|
get allConditionsForDisplay(): IFilterInstance<P, E>["allConditionsForDisplay"] {
|
||||||
|
if (!this.expression) return [];
|
||||||
|
return extractConditionsWithDisplayOperators(this.expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------ computed option helpers ------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the clear filter options.
|
||||||
|
* @returns The clear filter options.
|
||||||
|
*/
|
||||||
|
get clearFilterOptions(): IFilterInstance<P, E>["clearFilterOptions"] {
|
||||||
|
return this.expressionOptions.clearFilterOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the save view options.
|
||||||
|
* @returns The save view options.
|
||||||
|
*/
|
||||||
|
get saveViewOptions(): IFilterInstance<P, E>["saveViewOptions"] {
|
||||||
|
return this.expressionOptions.saveViewOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the update view options.
|
||||||
|
* @returns The update view options.
|
||||||
|
*/
|
||||||
|
get updateViewOptions(): IFilterInstance<P, E>["updateViewOptions"] {
|
||||||
|
return this.expressionOptions.updateViewOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------ computed permissions ------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the filter expression can be cleared.
|
||||||
|
* @returns True if the filter expression can be cleared, false otherwise.
|
||||||
|
*/
|
||||||
|
get canClearFilters(): IFilterInstance<P, E>["canClearFilters"] {
|
||||||
|
if (!this.expression) return false;
|
||||||
|
if (this.allConditionsForDisplay.length === 0) return false;
|
||||||
|
return this.clearFilterOptions ? !this.clearFilterOptions.isDisabled : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the filter expression can be saved as a view.
|
||||||
|
* @returns True if the filter instance can be saved, false otherwise.
|
||||||
|
*/
|
||||||
|
get canSaveView(): IFilterInstance<P, E>["canSaveView"] {
|
||||||
|
return this.hasActiveFilters && !!this.saveViewOptions && !this.saveViewOptions.isDisabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the filter expression can be updated as a view.
|
||||||
|
* @returns True if the filter expression can be updated, false otherwise.
|
||||||
|
*/
|
||||||
|
get canUpdateView(): IFilterInstance<P, E>["canUpdateView"] {
|
||||||
|
return (
|
||||||
|
!!this.updateViewOptions &&
|
||||||
|
(this.hasChanges || !!this.updateViewOptions.hasAdditionalChanges) &&
|
||||||
|
!this.updateViewOptions.isDisabled
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------ actions ------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the filter expression to the initial expression.
|
||||||
|
* @param externalExpression - The external expression to reset to.
|
||||||
|
*/
|
||||||
|
resetExpression: IFilterInstance<P, E>["resetExpression"] = action(
|
||||||
|
(externalExpression, shouldResetInitialExpression = true) => {
|
||||||
|
this.expression = this.helper.initializeExpression(externalExpression);
|
||||||
|
if (shouldResetInitialExpression) {
|
||||||
|
this._resetInitialFilterExpression();
|
||||||
|
}
|
||||||
|
this._notifyExpressionChange();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds all conditions by property and operator.
|
||||||
|
* @param property - The property to find the conditions by.
|
||||||
|
* @param operator - The operator to find the conditions by.
|
||||||
|
* @returns All the conditions that match the property and operator.
|
||||||
|
*/
|
||||||
|
findConditionsByPropertyAndOperator: IFilterInstance<P, E>["findConditionsByPropertyAndOperator"] = action(
|
||||||
|
(property, operator) => {
|
||||||
|
if (!this.expression) return [];
|
||||||
|
return findConditionsByPropertyAndOperator(this.expression, property, operator);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the first condition by property and operator.
|
||||||
|
* @param property - The property to find the condition by.
|
||||||
|
* @param operator - The operator to find the condition by.
|
||||||
|
* @returns The first condition that matches the property and operator.
|
||||||
|
*/
|
||||||
|
findFirstConditionByPropertyAndOperator: IFilterInstance<P, E>["findFirstConditionByPropertyAndOperator"] = action(
|
||||||
|
(property, operator) => {
|
||||||
|
if (!this.expression) return undefined;
|
||||||
|
const conditions = findConditionsByPropertyAndOperator(this.expression, property, operator);
|
||||||
|
return conditions[0];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a condition to the filter expression.
|
||||||
|
* @param groupOperator - The logical operator to use for the condition.
|
||||||
|
* @param condition - The condition to add.
|
||||||
|
* @param isNegation - Whether the condition should be negated.
|
||||||
|
*/
|
||||||
|
addCondition: IFilterInstance<P, E>["addCondition"] = action((groupOperator, condition, isNegation = false) => {
|
||||||
|
const conditionValue = condition.value;
|
||||||
|
|
||||||
|
this.expression = this.helper.addConditionToExpression(this.expression, groupOperator, condition, isNegation);
|
||||||
|
|
||||||
|
if (hasValidValue(conditionValue)) {
|
||||||
|
this._notifyExpressionChange();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the operator of a condition in the filter expression.
|
||||||
|
* @param conditionId - The id of the condition to update.
|
||||||
|
* @param operator - The new operator for the condition.
|
||||||
|
*/
|
||||||
|
updateConditionOperator: IFilterInstance<P, E>["updateConditionOperator"] = action(
|
||||||
|
(conditionId: string, operator: TSupportedOperators, isNegation: boolean) => {
|
||||||
|
if (!this.expression) return;
|
||||||
|
const conditionBeforeUpdate = cloneDeep(findNodeById(this.expression, conditionId));
|
||||||
|
if (!conditionBeforeUpdate || conditionBeforeUpdate.type !== FILTER_NODE_TYPE.CONDITION) return;
|
||||||
|
|
||||||
|
// Get the operator configs for the current and new operators
|
||||||
|
const currentOperatorConfig = this.configManager
|
||||||
|
.getConfigByProperty(conditionBeforeUpdate.property)
|
||||||
|
?.getOperatorConfig(conditionBeforeUpdate.operator);
|
||||||
|
const newOperatorConfig = this.configManager
|
||||||
|
.getConfigByProperty(conditionBeforeUpdate.property)
|
||||||
|
?.getOperatorConfig(operator);
|
||||||
|
// Reset the value if the operator config types are different
|
||||||
|
const shouldResetConditionValue = currentOperatorConfig?.type !== newOperatorConfig?.type;
|
||||||
|
|
||||||
|
// Use restructuring logic for operator changes
|
||||||
|
const updatedExpression = this.helper.restructureExpressionForOperatorChange(
|
||||||
|
this.expression,
|
||||||
|
conditionId,
|
||||||
|
operator,
|
||||||
|
isNegation,
|
||||||
|
shouldResetConditionValue
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updatedExpression) {
|
||||||
|
this.expression = updatedExpression;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasValidValue(conditionBeforeUpdate.value)) {
|
||||||
|
this._notifyExpressionChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the value of a condition in the filter expression with automatic optimization.
|
||||||
|
* @param conditionId - The id of the condition to update.
|
||||||
|
* @param value - The new value for the condition.
|
||||||
|
*/
|
||||||
|
updateConditionValue: IFilterInstance<P, E>["updateConditionValue"] = action(
|
||||||
|
<V extends TFilterValue>(conditionId: string, value: SingleOrArray<V>) => {
|
||||||
|
// If the expression is not valid, return
|
||||||
|
if (!this.expression) return;
|
||||||
|
|
||||||
|
// If the value is not valid, remove the condition
|
||||||
|
if (!hasValidValue(value)) {
|
||||||
|
this.removeCondition(conditionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the condition value
|
||||||
|
updateNodeInExpression(this.expression, conditionId, {
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify the change
|
||||||
|
this._notifyExpressionChange();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a condition from the filter expression.
|
||||||
|
* @param conditionId - The id of the condition to remove.
|
||||||
|
*/
|
||||||
|
removeCondition: IFilterInstance<P, E>["removeCondition"] = action((conditionId) => {
|
||||||
|
if (!this.expression) return;
|
||||||
|
const { expression, shouldNotify } = removeNodeFromExpression(this.expression, conditionId);
|
||||||
|
this.expression = expression;
|
||||||
|
if (shouldNotify) {
|
||||||
|
this._notifyExpressionChange();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the filter expression.
|
||||||
|
*/
|
||||||
|
clearFilters: IFilterInstance<P, E>["clearFilters"] = action(async () => {
|
||||||
|
if (this.canClearFilters) {
|
||||||
|
const shouldNotify = shouldNotifyChangeForExpression(this.expression);
|
||||||
|
this.expression = null;
|
||||||
|
await this.clearFilterOptions?.onFilterClear();
|
||||||
|
if (shouldNotify) {
|
||||||
|
this._notifyExpressionChange();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("Cannot clear filters: invalid expression or missing options.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the filter expression.
|
||||||
|
*/
|
||||||
|
saveView: IFilterInstance<P, E>["saveView"] = action(async () => {
|
||||||
|
if (this.canSaveView && this.saveViewOptions) {
|
||||||
|
await this.saveViewOptions.onViewSave(this._getExternalExpression());
|
||||||
|
} else {
|
||||||
|
console.warn("Cannot save view: invalid expression or missing options.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the filter expression.
|
||||||
|
*/
|
||||||
|
updateView: IFilterInstance<P, E>["updateView"] = action(async () => {
|
||||||
|
if (this.canUpdateView && this.updateViewOptions) {
|
||||||
|
await this.updateViewOptions.onViewUpdate(this._getExternalExpression());
|
||||||
|
this._resetInitialFilterExpression();
|
||||||
|
} else {
|
||||||
|
console.warn("Cannot update view: invalid expression or missing options.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the expression options for the filter instance.
|
||||||
|
* This allows dynamic updates to options like isDisabled properties.
|
||||||
|
*/
|
||||||
|
updateExpressionOptions: IFilterInstance<P, E>["updateExpressionOptions"] = action((newOptions) => {
|
||||||
|
this.expressionOptions = {
|
||||||
|
...this.expressionOptions,
|
||||||
|
...newOptions,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------ private helpers ------------
|
||||||
|
/**
|
||||||
|
* Resets the initial filter expression to the current expression.
|
||||||
|
*/
|
||||||
|
private _resetInitialFilterExpression(): void {
|
||||||
|
this.initialFilterExpression = cloneDeep(this.expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the external filter representation of the filter instance.
|
||||||
|
* @returns The external filter representation of the filter instance.
|
||||||
|
*/
|
||||||
|
private _getExternalExpression = computedFn(() =>
|
||||||
|
this.adapter.toExternal(sanitizeAndStabilizeExpression(toJS(this.expression)))
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies the parent component of the expression change.
|
||||||
|
*/
|
||||||
|
private _notifyExpressionChange(): void {
|
||||||
|
this.onExpressionChange?.(this._getExternalExpression());
|
||||||
|
}
|
||||||
|
}
|
||||||
2
packages/shared-state/src/store/rich-filters/index.ts
Normal file
2
packages/shared-state/src/store/rich-filters/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./adapter";
|
||||||
|
export * from "./filter";
|
||||||
1
packages/shared-state/src/utils/index.ts
Normal file
1
packages/shared-state/src/utils/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./rich-filter.helper";
|
||||||
47
packages/shared-state/src/utils/rich-filter.helper.ts
Normal file
47
packages/shared-state/src/utils/rich-filter.helper.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
// plane imports
|
||||||
|
import {
|
||||||
|
LOGICAL_OPERATOR,
|
||||||
|
TBuildFilterExpressionParams,
|
||||||
|
TExternalFilter,
|
||||||
|
TFilterProperty,
|
||||||
|
TFilterValue,
|
||||||
|
} from "@plane/types";
|
||||||
|
import { getOperatorForPayload } from "@plane/utils";
|
||||||
|
// local imports
|
||||||
|
import { FilterInstance } from "../store/rich-filters/filter";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a temporary filter expression from conditions.
|
||||||
|
* @param params.conditions - The conditions for building the filter expression.
|
||||||
|
* @param params.adapter - The adapter for building the filter expression.
|
||||||
|
* @returns The temporary filter expression.
|
||||||
|
*/
|
||||||
|
export const buildTempFilterExpressionFromConditions = <
|
||||||
|
P extends TFilterProperty,
|
||||||
|
V extends TFilterValue,
|
||||||
|
E extends TExternalFilter,
|
||||||
|
>(
|
||||||
|
params: TBuildFilterExpressionParams<P, V, E>
|
||||||
|
): E | undefined => {
|
||||||
|
const { conditions, adapter } = params;
|
||||||
|
let tempExpression: E | undefined = undefined;
|
||||||
|
const tempFilterInstance = new FilterInstance<P, E>({
|
||||||
|
adapter,
|
||||||
|
onExpressionChange: (expression) => {
|
||||||
|
tempExpression = expression;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const condition of conditions) {
|
||||||
|
const { operator, isNegation } = getOperatorForPayload(condition.operator);
|
||||||
|
tempFilterInstance.addCondition(
|
||||||
|
LOGICAL_OPERATOR.AND,
|
||||||
|
{
|
||||||
|
property: condition.property,
|
||||||
|
operator,
|
||||||
|
value: condition.value,
|
||||||
|
},
|
||||||
|
isNegation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return tempExpression;
|
||||||
|
};
|
||||||
@ -1,49 +1,49 @@
|
|||||||
export * from "./users";
|
export * from "./activity";
|
||||||
export * from "./workspace";
|
|
||||||
export * from "./cycle";
|
|
||||||
export * from "./dashboard";
|
|
||||||
export * from "./de-dupe";
|
|
||||||
export * from "./description_version";
|
|
||||||
export * from "./enums";
|
|
||||||
export * from "./project";
|
|
||||||
export * from "./state";
|
|
||||||
export * from "./issues";
|
|
||||||
export * from "./module";
|
|
||||||
export * from "./views";
|
|
||||||
export * from "./integration";
|
|
||||||
export * from "./page";
|
|
||||||
export * from "./ai";
|
export * from "./ai";
|
||||||
export * from "./estimate";
|
|
||||||
export * from "./importer";
|
|
||||||
export * from "./inbox";
|
|
||||||
export * from "./analytics";
|
export * from "./analytics";
|
||||||
export * from "./api_token";
|
export * from "./api_token";
|
||||||
export * from "./auth";
|
export * from "./auth";
|
||||||
export * from "./calendar";
|
export * from "./calendar";
|
||||||
export * from "./instance";
|
export * from "./charts";
|
||||||
export * from "./issues/base"; // TODO: Remove this after development and the refactor/mobx-store-issue branch is stable
|
export * from "./command-palette";
|
||||||
export * from "./reaction";
|
|
||||||
export * from "./view-props";
|
|
||||||
export * from "./waitlist";
|
|
||||||
export * from "./webhook";
|
|
||||||
export * from "./workspace-views";
|
|
||||||
export * from "./common";
|
export * from "./common";
|
||||||
|
export * from "./cycle";
|
||||||
|
export * from "./dashboard";
|
||||||
|
export * from "./de-dupe";
|
||||||
|
export * from "./description_version";
|
||||||
export * from "./editor";
|
export * from "./editor";
|
||||||
export * from "./pragmatic";
|
export * from "./enums";
|
||||||
export * from "./publish";
|
export * from "./epics";
|
||||||
export * from "./search";
|
export * from "./estimate";
|
||||||
export * from "./workspace-notifications";
|
|
||||||
export * from "./favorite";
|
export * from "./favorite";
|
||||||
export * from "./file";
|
export * from "./file";
|
||||||
export * from "./workspace-draft-issues/base";
|
|
||||||
export * from "./command-palette";
|
|
||||||
export * from "./timezone";
|
|
||||||
export * from "./activity";
|
|
||||||
export * from "./epics";
|
|
||||||
export * from "./charts";
|
|
||||||
export * from "./home";
|
export * from "./home";
|
||||||
export * from "./stickies";
|
export * from "./importer";
|
||||||
export * from "./utils";
|
export * from "./inbox";
|
||||||
export * from "./payment";
|
export * from "./instance";
|
||||||
|
export * from "./integration";
|
||||||
|
export * from "./issues";
|
||||||
|
export * from "./issues/base"; // TODO: Remove this after development and the refactor/mobx-store-issue branch is stable
|
||||||
export * from "./layout";
|
export * from "./layout";
|
||||||
export * from "./analytics";
|
export * from "./module";
|
||||||
|
export * from "./page";
|
||||||
|
export * from "./payment";
|
||||||
|
export * from "./pragmatic";
|
||||||
|
export * from "./project";
|
||||||
|
export * from "./publish";
|
||||||
|
export * from "./reaction";
|
||||||
|
export * from "./rich-filters";
|
||||||
|
export * from "./search";
|
||||||
|
export * from "./state";
|
||||||
|
export * from "./stickies";
|
||||||
|
export * from "./timezone";
|
||||||
|
export * from "./users";
|
||||||
|
export * from "./utils";
|
||||||
|
export * from "./view-props";
|
||||||
|
export * from "./views";
|
||||||
|
export * from "./waitlist";
|
||||||
|
export * from "./webhook";
|
||||||
|
export * from "./workspace";
|
||||||
|
export * from "./workspace-draft-issues/base";
|
||||||
|
export * from "./workspace-notifications";
|
||||||
|
export * from "./workspace-views";
|
||||||
|
|||||||
23
packages/types/src/rich-filters/adapter.ts
Normal file
23
packages/types/src/rich-filters/adapter.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
// local imports
|
||||||
|
import { TFilterExpression, TFilterProperty } from "./expression";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* External filter format
|
||||||
|
*/
|
||||||
|
export type TExternalFilter = Record<string, unknown> | undefined | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter for converting between internal filter trees and external formats.
|
||||||
|
* @template P - Filter property type (e.g., 'state_id', 'priority', 'assignee')
|
||||||
|
* @template E - External filter format type (e.g., work item filters, automation filters)
|
||||||
|
*/
|
||||||
|
export interface IFilterAdapter<P extends TFilterProperty, E extends TExternalFilter> {
|
||||||
|
/**
|
||||||
|
* Converts external format to internal filter tree.
|
||||||
|
*/
|
||||||
|
toInternal(externalFilter: E): TFilterExpression<P> | null;
|
||||||
|
/**
|
||||||
|
* Converts internal filter tree to external format.
|
||||||
|
*/
|
||||||
|
toExternal(internalFilter: TFilterExpression<P> | null): E;
|
||||||
|
}
|
||||||
29
packages/types/src/rich-filters/builder.ts
Normal file
29
packages/types/src/rich-filters/builder.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { SingleOrArray } from "../utils";
|
||||||
|
import { IFilterAdapter, TExternalFilter } from "./adapter";
|
||||||
|
import { TFilterProperty, TFilterValue } from "./expression";
|
||||||
|
import { TAllAvailableOperatorsForDisplay } from "./operators";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Condition payload for building filter expressions.
|
||||||
|
* @template P - Property key type
|
||||||
|
* @template V - Value type
|
||||||
|
*/
|
||||||
|
export type TFilterConditionForBuild<P extends TFilterProperty, V extends TFilterValue> = {
|
||||||
|
property: P;
|
||||||
|
operator: TAllAvailableOperatorsForDisplay;
|
||||||
|
value: SingleOrArray<V>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameters for building filter expressions from multiple conditions.
|
||||||
|
* @template P - Property key type
|
||||||
|
* @template V - Value type
|
||||||
|
*/
|
||||||
|
export type TBuildFilterExpressionParams<
|
||||||
|
P extends TFilterProperty,
|
||||||
|
V extends TFilterValue,
|
||||||
|
E extends TExternalFilter,
|
||||||
|
> = {
|
||||||
|
conditions: TFilterConditionForBuild<P, V>[];
|
||||||
|
adapter: IFilterAdapter<P, E>;
|
||||||
|
};
|
||||||
18
packages/types/src/rich-filters/config/filter-config.ts
Normal file
18
packages/types/src/rich-filters/config/filter-config.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { TFilterProperty, TFilterValue } from "../expression";
|
||||||
|
import { TOperatorConfigMap } from "../operator-configs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main filter configuration type for different properties.
|
||||||
|
* This is the primary configuration type used throughout the application.
|
||||||
|
*
|
||||||
|
* @template P - Property key type (e.g., 'state_id', 'priority', 'assignee')
|
||||||
|
* @template V - Value type for the filter
|
||||||
|
*/
|
||||||
|
export type TFilterConfig<P extends TFilterProperty, V extends TFilterValue = TFilterValue> = {
|
||||||
|
id: P;
|
||||||
|
label: string;
|
||||||
|
icon?: React.FC<React.SVGAttributes<SVGElement>>;
|
||||||
|
isEnabled: boolean;
|
||||||
|
allowMultipleFilters?: boolean;
|
||||||
|
supportedOperatorConfigsMap: TOperatorConfigMap<V>;
|
||||||
|
};
|
||||||
1
packages/types/src/rich-filters/config/index.ts
Normal file
1
packages/types/src/rich-filters/config/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./filter-config";
|
||||||
77
packages/types/src/rich-filters/derived/core.ts
Normal file
77
packages/types/src/rich-filters/derived/core.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { TFilterValue } from "../expression";
|
||||||
|
import {
|
||||||
|
TDateFilterFieldConfig,
|
||||||
|
TDateRangeFilterFieldConfig,
|
||||||
|
TSingleSelectFilterFieldConfig,
|
||||||
|
TMultiSelectFilterFieldConfig,
|
||||||
|
} from "../field-types";
|
||||||
|
import { TCoreOperatorSpecificConfigs } from "../operator-configs";
|
||||||
|
import { TFilterOperatorHelper } from "./shared";
|
||||||
|
|
||||||
|
// -------- DATE FILTER OPERATORS --------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type representing all core operators that support single date filter types.
|
||||||
|
*/
|
||||||
|
export type TCoreSupportedSingleDateFilterOperators<V extends TFilterValue = TFilterValue> = {
|
||||||
|
[K in keyof TCoreOperatorSpecificConfigs<V>]: TFilterOperatorHelper<
|
||||||
|
TCoreOperatorSpecificConfigs<V>,
|
||||||
|
K,
|
||||||
|
TDateFilterFieldConfig<V>
|
||||||
|
>;
|
||||||
|
}[keyof TCoreOperatorSpecificConfigs<V>];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type representing all core operators that support range date filter types.
|
||||||
|
*/
|
||||||
|
export type TCoreSupportedRangeDateFilterOperators<V extends TFilterValue = TFilterValue> = {
|
||||||
|
[K in keyof TCoreOperatorSpecificConfigs<V>]: TFilterOperatorHelper<
|
||||||
|
TCoreOperatorSpecificConfigs<V>,
|
||||||
|
K,
|
||||||
|
TDateRangeFilterFieldConfig<V>
|
||||||
|
>;
|
||||||
|
}[keyof TCoreOperatorSpecificConfigs<V>];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type representing all core operators that support date filter types.
|
||||||
|
*/
|
||||||
|
export type TCoreSupportedDateFilterOperators<V extends TFilterValue = TFilterValue> =
|
||||||
|
| TCoreSupportedSingleDateFilterOperators<V>
|
||||||
|
| TCoreSupportedRangeDateFilterOperators<V>;
|
||||||
|
|
||||||
|
export type TCoreAllAvailableDateFilterOperatorsForDisplay<V extends TFilterValue = TFilterValue> =
|
||||||
|
TCoreSupportedDateFilterOperators<V>;
|
||||||
|
|
||||||
|
// -------- SELECT FILTER OPERATORS --------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type representing all core operators that support single select filter types.
|
||||||
|
*/
|
||||||
|
export type TCoreSupportedSingleSelectFilterOperators<V extends TFilterValue = TFilterValue> = {
|
||||||
|
[K in keyof TCoreOperatorSpecificConfigs<V>]: TFilterOperatorHelper<
|
||||||
|
TCoreOperatorSpecificConfigs<V>,
|
||||||
|
K,
|
||||||
|
TSingleSelectFilterFieldConfig<V>
|
||||||
|
>;
|
||||||
|
}[keyof TCoreOperatorSpecificConfigs<V>];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type representing all core operators that support multi select filter types.
|
||||||
|
*/
|
||||||
|
export type TCoreSupportedMultiSelectFilterOperators<V extends TFilterValue = TFilterValue> = {
|
||||||
|
[K in keyof TCoreOperatorSpecificConfigs<V>]: TFilterOperatorHelper<
|
||||||
|
TCoreOperatorSpecificConfigs<V>,
|
||||||
|
K,
|
||||||
|
TMultiSelectFilterFieldConfig<V>
|
||||||
|
>;
|
||||||
|
}[keyof TCoreOperatorSpecificConfigs<V>];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type representing all core operators that support any select filter types.
|
||||||
|
*/
|
||||||
|
export type TCoreSupportedSelectFilterOperators<V extends TFilterValue = TFilterValue> =
|
||||||
|
| TCoreSupportedSingleSelectFilterOperators<V>
|
||||||
|
| TCoreSupportedMultiSelectFilterOperators<V>;
|
||||||
|
|
||||||
|
export type TCoreAllAvailableSelectFilterOperatorsForDisplay<V extends TFilterValue = TFilterValue> =
|
||||||
|
TCoreSupportedSelectFilterOperators<V>;
|
||||||
19
packages/types/src/rich-filters/derived/extended.ts
Normal file
19
packages/types/src/rich-filters/derived/extended.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { TFilterValue } from "../expression";
|
||||||
|
|
||||||
|
// -------- DATE FILTER OPERATORS --------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type representing all extended operators that support date filter types.
|
||||||
|
*/
|
||||||
|
export type TExtendedSupportedDateFilterOperators<_V extends TFilterValue = TFilterValue> = never;
|
||||||
|
|
||||||
|
export type TExtendedAllAvailableDateFilterOperatorsForDisplay<_V extends TFilterValue = TFilterValue> = never;
|
||||||
|
|
||||||
|
// -------- SELECT FILTER OPERATORS --------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type representing all extended operators that support select filter types.
|
||||||
|
*/
|
||||||
|
export type TExtendedSupportedSelectFilterOperators<_V extends TFilterValue = TFilterValue> = never;
|
||||||
|
|
||||||
|
export type TExtendedAllAvailableSelectFilterOperatorsForDisplay<_V extends TFilterValue = TFilterValue> = never;
|
||||||
43
packages/types/src/rich-filters/derived/index.ts
Normal file
43
packages/types/src/rich-filters/derived/index.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { TFilterValue } from "../expression";
|
||||||
|
import {
|
||||||
|
TCoreAllAvailableDateFilterOperatorsForDisplay,
|
||||||
|
TCoreAllAvailableSelectFilterOperatorsForDisplay,
|
||||||
|
TCoreSupportedDateFilterOperators,
|
||||||
|
TCoreSupportedSelectFilterOperators,
|
||||||
|
} from "./core";
|
||||||
|
import {
|
||||||
|
TExtendedAllAvailableDateFilterOperatorsForDisplay,
|
||||||
|
TExtendedAllAvailableSelectFilterOperatorsForDisplay,
|
||||||
|
TExtendedSupportedDateFilterOperators,
|
||||||
|
TExtendedSupportedSelectFilterOperators,
|
||||||
|
} from "./extended";
|
||||||
|
|
||||||
|
// -------- COMPOSED SUPPORT TYPES --------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All supported date filter operators.
|
||||||
|
*/
|
||||||
|
export type TSupportedDateFilterOperators<V extends TFilterValue = TFilterValue> =
|
||||||
|
| TCoreSupportedDateFilterOperators<V>
|
||||||
|
| TExtendedSupportedDateFilterOperators<V>;
|
||||||
|
|
||||||
|
export type TAllAvailableDateFilterOperatorsForDisplay<V extends TFilterValue = TFilterValue> =
|
||||||
|
| TCoreAllAvailableDateFilterOperatorsForDisplay<V>
|
||||||
|
| TExtendedAllAvailableDateFilterOperatorsForDisplay<V>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All supported select filter operators.
|
||||||
|
*/
|
||||||
|
export type TSupportedSelectFilterOperators<V extends TFilterValue = TFilterValue> =
|
||||||
|
| TCoreSupportedSelectFilterOperators<V>
|
||||||
|
| TExtendedSupportedSelectFilterOperators<V>;
|
||||||
|
|
||||||
|
export type TAllAvailableSelectFilterOperatorsForDisplay<V extends TFilterValue = TFilterValue> =
|
||||||
|
| TCoreAllAvailableSelectFilterOperatorsForDisplay<V>
|
||||||
|
| TExtendedAllAvailableSelectFilterOperatorsForDisplay<V>;
|
||||||
|
|
||||||
|
// -------- RE-EXPORTS --------
|
||||||
|
|
||||||
|
export * from "./shared";
|
||||||
|
export * from "./core";
|
||||||
|
export * from "./extended";
|
||||||
9
packages/types/src/rich-filters/derived/shared.ts
Normal file
9
packages/types/src/rich-filters/derived/shared.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Generic utility type to check if a configuration type supports specific filter types.
|
||||||
|
* Returns the operator key if any member of the union includes the target filter types, never otherwise.
|
||||||
|
*/
|
||||||
|
export type TFilterOperatorHelper<
|
||||||
|
TOperatorConfigs,
|
||||||
|
K extends keyof TOperatorConfigs,
|
||||||
|
TTargetFilter,
|
||||||
|
> = TTargetFilter extends TOperatorConfigs[K] ? K : TOperatorConfigs[K] extends TTargetFilter ? K : never;
|
||||||
110
packages/types/src/rich-filters/expression.ts
Normal file
110
packages/types/src/rich-filters/expression.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
// local imports
|
||||||
|
import { SingleOrArray } from "../utils";
|
||||||
|
import { TSupportedOperators, LOGICAL_OPERATOR, TAllAvailableOperatorsForDisplay } from "./operators";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter node types for building hierarchical filter trees.
|
||||||
|
* - CONDITION: Single filter for one field (e.g., "state is backlog")
|
||||||
|
* - GROUP: Logical container combining multiple filters with AND/OR or single filter/group with NOT
|
||||||
|
*/
|
||||||
|
export const FILTER_NODE_TYPE = {
|
||||||
|
CONDITION: "condition",
|
||||||
|
GROUP: "group",
|
||||||
|
} as const;
|
||||||
|
export type TFilterNodeType = (typeof FILTER_NODE_TYPE)[keyof typeof FILTER_NODE_TYPE];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field property key that can be filtered (e.g., "state", "assignee", "created_at").
|
||||||
|
*/
|
||||||
|
export type TFilterProperty = string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowed filter values - primitives plus null/undefined for empty states.
|
||||||
|
*/
|
||||||
|
export type TFilterValue = string | number | Date | boolean | null | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base properties shared by all filter nodes.
|
||||||
|
* - id: Unique identifier for the node
|
||||||
|
* - type: Node type (condition or group)
|
||||||
|
*/
|
||||||
|
type TBaseFilterNode = {
|
||||||
|
id: string;
|
||||||
|
type: TFilterNodeType;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leaf node representing a single filter condition (e.g., "state is backlog").
|
||||||
|
* - type: Node type (condition)
|
||||||
|
* - property: Field being filtered
|
||||||
|
* - operator: Comparison operator (is, is not, between, not between, etc.)
|
||||||
|
* - value: Filter value(s) - array for operators that support multiple values
|
||||||
|
* @template P - Property key type
|
||||||
|
* @template V - Value type
|
||||||
|
*/
|
||||||
|
export type TFilterConditionNode<P extends TFilterProperty, V extends TFilterValue> = TBaseFilterNode & {
|
||||||
|
type: typeof FILTER_NODE_TYPE.CONDITION;
|
||||||
|
property: P;
|
||||||
|
operator: TSupportedOperators;
|
||||||
|
value: SingleOrArray<V>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter condition node for display purposes.
|
||||||
|
*/
|
||||||
|
export type TFilterConditionNodeForDisplay<P extends TFilterProperty, V extends TFilterValue> = Omit<
|
||||||
|
TFilterConditionNode<P, V>,
|
||||||
|
"operator"
|
||||||
|
> & {
|
||||||
|
operator: TAllAvailableOperatorsForDisplay;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container node that combines multiple conditions with AND logical operator.
|
||||||
|
* - type: Node type (group)
|
||||||
|
* - logicalOperator: AND operator for combining child filters
|
||||||
|
* - children: Child conditions and/or nested groups (minimum 2 for meaningful operations)
|
||||||
|
* @template P - Property key type
|
||||||
|
*/
|
||||||
|
export type TFilterAndGroupNode<P extends TFilterProperty> = TBaseFilterNode & {
|
||||||
|
type: typeof FILTER_NODE_TYPE.GROUP;
|
||||||
|
logicalOperator: typeof LOGICAL_OPERATOR.AND;
|
||||||
|
children: TFilterExpression<P>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type for all group node types - AND, OR, and NOT groups.
|
||||||
|
* @template P - Property key type
|
||||||
|
*/
|
||||||
|
export type TFilterGroupNode<P extends TFilterProperty> = TFilterAndGroupNode<P>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type for any filter node - either a single condition or a group container.
|
||||||
|
* @template P - Property key type
|
||||||
|
* @template V - Value type
|
||||||
|
*/
|
||||||
|
export type TFilterExpression<P extends TFilterProperty, V extends TFilterValue = TFilterValue> =
|
||||||
|
| TFilterConditionNode<P, V>
|
||||||
|
| TFilterGroupNode<P>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload for creating/updating condition nodes - excludes base node properties.
|
||||||
|
* @template P - Property key type
|
||||||
|
* @template V - Value type
|
||||||
|
*/
|
||||||
|
export type TFilterConditionPayload<P extends TFilterProperty, V extends TFilterValue> = Omit<
|
||||||
|
TFilterConditionNode<P, V>,
|
||||||
|
keyof TBaseFilterNode
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload for creating/updating AND group nodes - excludes base node properties.
|
||||||
|
* @template P - Property key type
|
||||||
|
*/
|
||||||
|
export type TFilterAndGroupPayload<P extends TFilterProperty> = Omit<TFilterAndGroupNode<P>, keyof TBaseFilterNode>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union payload type for creating/updating any group node - excludes base node properties.
|
||||||
|
* @template P - Property key type
|
||||||
|
*/
|
||||||
|
export type TFilterGroupPayload<P extends TFilterProperty> = TFilterAndGroupPayload<P>;
|
||||||
79
packages/types/src/rich-filters/field-types/core.ts
Normal file
79
packages/types/src/rich-filters/field-types/core.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { TFilterValue } from "../expression";
|
||||||
|
import { TSupportedOperators } from "../operators";
|
||||||
|
import { TBaseFilterFieldConfig, IFilterOption } from "./shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core filter types
|
||||||
|
*/
|
||||||
|
export const CORE_FILTER_FIELD_TYPE = {
|
||||||
|
DATE: "date",
|
||||||
|
DATE_RANGE: "date_range",
|
||||||
|
SINGLE_SELECT: "single_select",
|
||||||
|
MULTI_SELECT: "multi_select",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// -------- DATE FILTER CONFIGURATIONS --------
|
||||||
|
|
||||||
|
type TBaseDateFilterFieldConfig = TBaseFilterFieldConfig & {
|
||||||
|
min?: Date;
|
||||||
|
max?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date filter configuration - for temporal filtering.
|
||||||
|
* - defaultValue: Initial date/time value
|
||||||
|
* - min: Minimum allowed date
|
||||||
|
* - max: Maximum allowed date
|
||||||
|
*/
|
||||||
|
export type TDateFilterFieldConfig<V extends TFilterValue> = TBaseDateFilterFieldConfig & {
|
||||||
|
type: typeof CORE_FILTER_FIELD_TYPE.DATE;
|
||||||
|
defaultValue?: V;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date range filter configuration - for temporal filtering.
|
||||||
|
* - defaultValue: Initial date/time range values
|
||||||
|
* - min: Minimum allowed date
|
||||||
|
* - max: Maximum allowed date
|
||||||
|
*/
|
||||||
|
export type TDateRangeFilterFieldConfig<V extends TFilterValue> = TBaseDateFilterFieldConfig & {
|
||||||
|
type: typeof CORE_FILTER_FIELD_TYPE.DATE_RANGE;
|
||||||
|
defaultValue?: V[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------- SELECT FILTER CONFIGURATIONS --------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-select filter configuration - dropdown with one selectable option.
|
||||||
|
* - defaultValue: Initial selected value
|
||||||
|
* - getOptions: Options as static array or async function
|
||||||
|
*/
|
||||||
|
export type TSingleSelectFilterFieldConfig<V extends TFilterValue> = TBaseFilterFieldConfig & {
|
||||||
|
type: typeof CORE_FILTER_FIELD_TYPE.SINGLE_SELECT;
|
||||||
|
defaultValue?: V;
|
||||||
|
getOptions: IFilterOption<V>[] | (() => IFilterOption<V>[] | Promise<IFilterOption<V>[]>);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-select filter configuration - allows selecting multiple options.
|
||||||
|
* - defaultValue: Initial selected values array
|
||||||
|
* - getOptions: Options as static array or async function
|
||||||
|
* - singleValueOperator: Operator to show when single value is selected
|
||||||
|
*/
|
||||||
|
export type TMultiSelectFilterFieldConfig<V extends TFilterValue> = TBaseFilterFieldConfig & {
|
||||||
|
type: typeof CORE_FILTER_FIELD_TYPE.MULTI_SELECT;
|
||||||
|
defaultValue?: V[];
|
||||||
|
getOptions: IFilterOption<V>[] | (() => IFilterOption<V>[] | Promise<IFilterOption<V>[]>);
|
||||||
|
singleValueOperator: TSupportedOperators;
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------- UNION TYPES --------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All core filter configurations
|
||||||
|
*/
|
||||||
|
export type TCoreFilterFieldConfigs<V extends TFilterValue = TFilterValue> =
|
||||||
|
| TDateFilterFieldConfig<V>
|
||||||
|
| TDateRangeFilterFieldConfig<V>
|
||||||
|
| TSingleSelectFilterFieldConfig<V>
|
||||||
|
| TMultiSelectFilterFieldConfig<V>;
|
||||||
13
packages/types/src/rich-filters/field-types/extended.ts
Normal file
13
packages/types/src/rich-filters/field-types/extended.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { TFilterValue } from "../expression";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended filter types
|
||||||
|
*/
|
||||||
|
export const EXTENDED_FILTER_FIELD_TYPE = {} as const;
|
||||||
|
|
||||||
|
// -------- UNION TYPES --------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All extended filter configurations
|
||||||
|
*/
|
||||||
|
export type TExtendedFilterFieldConfigs<_V extends TFilterValue = TFilterValue> = never;
|
||||||
27
packages/types/src/rich-filters/field-types/index.ts
Normal file
27
packages/types/src/rich-filters/field-types/index.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { TFilterValue } from "../expression";
|
||||||
|
import { CORE_FILTER_FIELD_TYPE, TCoreFilterFieldConfigs } from "./core";
|
||||||
|
import { EXTENDED_FILTER_FIELD_TYPE, TExtendedFilterFieldConfigs } from "./extended";
|
||||||
|
|
||||||
|
// -------- COMPOSED FILTER TYPES --------
|
||||||
|
|
||||||
|
export const FILTER_FIELD_TYPE = {
|
||||||
|
...CORE_FILTER_FIELD_TYPE,
|
||||||
|
...EXTENDED_FILTER_FIELD_TYPE,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TFilterFieldType = (typeof FILTER_FIELD_TYPE)[keyof typeof FILTER_FIELD_TYPE];
|
||||||
|
|
||||||
|
// -------- COMPOSED CONFIGURATIONS --------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All supported filter configurations.
|
||||||
|
*/
|
||||||
|
export type TSupportedFilterFieldConfigs<V extends TFilterValue = TFilterValue> =
|
||||||
|
| TCoreFilterFieldConfigs<V>
|
||||||
|
| TExtendedFilterFieldConfigs<V>;
|
||||||
|
|
||||||
|
// -------- RE-EXPORTS --------
|
||||||
|
|
||||||
|
export * from "./shared";
|
||||||
|
export * from "./core";
|
||||||
|
export * from "./extended";
|
||||||
37
packages/types/src/rich-filters/field-types/shared.ts
Normal file
37
packages/types/src/rich-filters/field-types/shared.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { TFilterValue } from "../expression";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Negative operator configuration for operators.
|
||||||
|
* - allowNegative: Whether the operator supports negation
|
||||||
|
* - negOperatorLabel: Label to use when the operator is negated
|
||||||
|
*/
|
||||||
|
export type TNegativeOperatorConfig = { allowNegative: true; negOperatorLabel?: string } | { allowNegative?: false };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base filter configuration shared by all filter types.
|
||||||
|
* - operatorLabel: Label to use for the operator
|
||||||
|
* - negativeOperatorConfig: Configuration for negative operators
|
||||||
|
*/
|
||||||
|
export type TBaseFilterFieldConfig = {
|
||||||
|
operatorLabel?: string;
|
||||||
|
} & TNegativeOperatorConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual option for select/multi-select filters.
|
||||||
|
* - id: Unique identifier for the option
|
||||||
|
* - label: Display text shown to users
|
||||||
|
* - value: Actual value used in filtering
|
||||||
|
* - icon: Optional icon component
|
||||||
|
* - iconClassName: CSS class for icon styling
|
||||||
|
* - disabled: Whether option can be selected
|
||||||
|
* - description: Additional context to be displayed in the filter dropdown
|
||||||
|
*/
|
||||||
|
export interface IFilterOption<V extends TFilterValue> {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: V;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
iconClassName?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
8
packages/types/src/rich-filters/index.ts
Normal file
8
packages/types/src/rich-filters/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export * from "./adapter";
|
||||||
|
export * from "./builder";
|
||||||
|
export * from "./config";
|
||||||
|
export * from "./derived";
|
||||||
|
export * from "./expression";
|
||||||
|
export * from "./operator-configs";
|
||||||
|
export * from "./operators";
|
||||||
|
export * from "./field-types";
|
||||||
26
packages/types/src/rich-filters/operator-configs/core.ts
Normal file
26
packages/types/src/rich-filters/operator-configs/core.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { TFilterValue } from "../expression";
|
||||||
|
import {
|
||||||
|
TDateFilterFieldConfig,
|
||||||
|
TDateRangeFilterFieldConfig,
|
||||||
|
TSingleSelectFilterFieldConfig,
|
||||||
|
TMultiSelectFilterFieldConfig,
|
||||||
|
} from "../field-types";
|
||||||
|
import { CORE_COLLECTION_OPERATOR, CORE_COMPARISON_OPERATOR, CORE_EQUALITY_OPERATOR } from "../operators";
|
||||||
|
|
||||||
|
// ----------------------------- EXACT Operator -----------------------------
|
||||||
|
export type TCoreExactOperatorConfigs<V extends TFilterValue> =
|
||||||
|
| TSingleSelectFilterFieldConfig<V>
|
||||||
|
| TDateFilterFieldConfig<V>;
|
||||||
|
|
||||||
|
// ----------------------------- IN Operator -----------------------------
|
||||||
|
export type TCoreInOperatorConfigs<V extends TFilterValue> = TMultiSelectFilterFieldConfig<V>;
|
||||||
|
|
||||||
|
// ----------------------------- RANGE Operator -----------------------------
|
||||||
|
export type TCoreRangeOperatorConfigs<V extends TFilterValue> = TDateRangeFilterFieldConfig<V>;
|
||||||
|
|
||||||
|
// ----------------------------- Core Operator Specific Configs -----------------------------
|
||||||
|
export type TCoreOperatorSpecificConfigs<V extends TFilterValue> = {
|
||||||
|
[CORE_EQUALITY_OPERATOR.EXACT]: TCoreExactOperatorConfigs<V>;
|
||||||
|
[CORE_COLLECTION_OPERATOR.IN]: TCoreInOperatorConfigs<V>;
|
||||||
|
[CORE_COMPARISON_OPERATOR.RANGE]: TCoreRangeOperatorConfigs<V>;
|
||||||
|
};
|
||||||
13
packages/types/src/rich-filters/operator-configs/extended.ts
Normal file
13
packages/types/src/rich-filters/operator-configs/extended.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { TFilterValue } from "../expression";
|
||||||
|
|
||||||
|
// ----------------------------- EXACT Operator -----------------------------
|
||||||
|
export type TExtendedExactOperatorConfigs<_V extends TFilterValue> = never;
|
||||||
|
|
||||||
|
// ----------------------------- IN Operator -----------------------------
|
||||||
|
export type TExtendedInOperatorConfigs<_V extends TFilterValue> = never;
|
||||||
|
|
||||||
|
// ----------------------------- RANGE Operator -----------------------------
|
||||||
|
export type TExtendedRangeOperatorConfigs<_V extends TFilterValue> = never;
|
||||||
|
|
||||||
|
// ----------------------------- Extended Operator Specific Configs -----------------------------
|
||||||
|
export type TExtendedOperatorSpecificConfigs<_V extends TFilterValue> = unknown;
|
||||||
56
packages/types/src/rich-filters/operator-configs/index.ts
Normal file
56
packages/types/src/rich-filters/operator-configs/index.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { TFilterValue } from "../expression";
|
||||||
|
import { EQUALITY_OPERATOR, COLLECTION_OPERATOR, COMPARISON_OPERATOR } from "../operators";
|
||||||
|
import { TCoreExactOperatorConfigs, TCoreInOperatorConfigs, TCoreRangeOperatorConfigs } from "./core";
|
||||||
|
import {
|
||||||
|
TExtendedExactOperatorConfigs,
|
||||||
|
TExtendedInOperatorConfigs,
|
||||||
|
TExtendedOperatorSpecificConfigs,
|
||||||
|
TExtendedRangeOperatorConfigs,
|
||||||
|
} from "./extended";
|
||||||
|
|
||||||
|
// ----------------------------- Composed Operator Configs -----------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EXACT operator - combines core and extended configurations
|
||||||
|
*/
|
||||||
|
export type TExactOperatorConfigs<V extends TFilterValue> =
|
||||||
|
| TCoreExactOperatorConfigs<V>
|
||||||
|
| TExtendedExactOperatorConfigs<V>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IN operator - combines core and extended configurations
|
||||||
|
*/
|
||||||
|
export type TInOperatorConfigs<V extends TFilterValue> = TCoreInOperatorConfigs<V> | TExtendedInOperatorConfigs<V>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RANGE operator - combines core and extended configurations
|
||||||
|
*/
|
||||||
|
export type TRangeOperatorConfigs<V extends TFilterValue> =
|
||||||
|
| TCoreRangeOperatorConfigs<V>
|
||||||
|
| TExtendedRangeOperatorConfigs<V>;
|
||||||
|
|
||||||
|
// ----------------------------- Final Operator Specific Configs -----------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type-safe mapping of specific operators to their supported filter type configurations.
|
||||||
|
* Each operator maps to its composed (core + extended) configurations.
|
||||||
|
*/
|
||||||
|
export type TOperatorSpecificConfigs<V extends TFilterValue> = {
|
||||||
|
[EQUALITY_OPERATOR.EXACT]: TExactOperatorConfigs<V>;
|
||||||
|
[COLLECTION_OPERATOR.IN]: TInOperatorConfigs<V>;
|
||||||
|
[COMPARISON_OPERATOR.RANGE]: TRangeOperatorConfigs<V>;
|
||||||
|
} & TExtendedOperatorSpecificConfigs<V>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operator filter configuration mapping - for different operators.
|
||||||
|
* Provides type-safe mapping of operators to their specific supported configurations.
|
||||||
|
*/
|
||||||
|
export type TOperatorConfigMap<V extends TFilterValue> = Map<
|
||||||
|
keyof TOperatorSpecificConfigs<V>,
|
||||||
|
TOperatorSpecificConfigs<V>[keyof TOperatorSpecificConfigs<V>]
|
||||||
|
>;
|
||||||
|
|
||||||
|
// -------- RE-EXPORTS --------
|
||||||
|
|
||||||
|
export * from "./core";
|
||||||
|
export * from "./extended";
|
||||||
38
packages/types/src/rich-filters/operators/core.ts
Normal file
38
packages/types/src/rich-filters/operators/core.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Core logical operators
|
||||||
|
*/
|
||||||
|
export const CORE_LOGICAL_OPERATOR = {
|
||||||
|
AND: "and",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core equality operators
|
||||||
|
*/
|
||||||
|
export const CORE_EQUALITY_OPERATOR = {
|
||||||
|
EXACT: "exact",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core collection operators
|
||||||
|
*/
|
||||||
|
export const CORE_COLLECTION_OPERATOR = {
|
||||||
|
IN: "in",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core comparison operators
|
||||||
|
*/
|
||||||
|
export const CORE_COMPARISON_OPERATOR = {
|
||||||
|
RANGE: "range",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// -------- TYPE EXPORTS --------
|
||||||
|
|
||||||
|
type TCoreEqualityOperator = (typeof CORE_EQUALITY_OPERATOR)[keyof typeof CORE_EQUALITY_OPERATOR];
|
||||||
|
type TCoreCollectionOperator = (typeof CORE_COLLECTION_OPERATOR)[keyof typeof CORE_COLLECTION_OPERATOR];
|
||||||
|
type TCoreComparisonOperator = (typeof CORE_COMPARISON_OPERATOR)[keyof typeof CORE_COMPARISON_OPERATOR];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All core operators that can be used in filter conditions
|
||||||
|
*/
|
||||||
|
export type TCoreSupportedOperators = TCoreEqualityOperator | TCoreCollectionOperator | TCoreComparisonOperator;
|
||||||
33
packages/types/src/rich-filters/operators/extended.ts
Normal file
33
packages/types/src/rich-filters/operators/extended.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Extended logical operators
|
||||||
|
*/
|
||||||
|
export const EXTENDED_LOGICAL_OPERATOR = {} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended equality operators
|
||||||
|
*/
|
||||||
|
export const EXTENDED_EQUALITY_OPERATOR = {} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended collection operators
|
||||||
|
*/
|
||||||
|
export const EXTENDED_COLLECTION_OPERATOR = {} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended comparison operators
|
||||||
|
*/
|
||||||
|
export const EXTENDED_COMPARISON_OPERATOR = {} as const;
|
||||||
|
|
||||||
|
// -------- TYPE EXPORTS --------
|
||||||
|
|
||||||
|
type TExtendedEqualityOperator = (typeof EXTENDED_EQUALITY_OPERATOR)[keyof typeof EXTENDED_EQUALITY_OPERATOR];
|
||||||
|
type TExtendedCollectionOperator = (typeof EXTENDED_COLLECTION_OPERATOR)[keyof typeof EXTENDED_COLLECTION_OPERATOR];
|
||||||
|
type TExtendedComparisonOperator = (typeof EXTENDED_COMPARISON_OPERATOR)[keyof typeof EXTENDED_COMPARISON_OPERATOR];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All extended operators that can be used in filter conditions
|
||||||
|
*/
|
||||||
|
export type TExtendedSupportedOperators =
|
||||||
|
| TExtendedEqualityOperator
|
||||||
|
| TExtendedCollectionOperator
|
||||||
|
| TExtendedComparisonOperator;
|
||||||
59
packages/types/src/rich-filters/operators/index.ts
Normal file
59
packages/types/src/rich-filters/operators/index.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
CORE_LOGICAL_OPERATOR,
|
||||||
|
CORE_EQUALITY_OPERATOR,
|
||||||
|
CORE_COLLECTION_OPERATOR,
|
||||||
|
CORE_COMPARISON_OPERATOR,
|
||||||
|
TCoreSupportedOperators,
|
||||||
|
} from "./core";
|
||||||
|
import {
|
||||||
|
EXTENDED_LOGICAL_OPERATOR,
|
||||||
|
EXTENDED_EQUALITY_OPERATOR,
|
||||||
|
EXTENDED_COLLECTION_OPERATOR,
|
||||||
|
EXTENDED_COMPARISON_OPERATOR,
|
||||||
|
TExtendedSupportedOperators,
|
||||||
|
} from "./extended";
|
||||||
|
|
||||||
|
// -------- COMPOSED OPERATORS --------
|
||||||
|
|
||||||
|
export const LOGICAL_OPERATOR = {
|
||||||
|
...CORE_LOGICAL_OPERATOR,
|
||||||
|
...EXTENDED_LOGICAL_OPERATOR,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const EQUALITY_OPERATOR = {
|
||||||
|
...CORE_EQUALITY_OPERATOR,
|
||||||
|
...EXTENDED_EQUALITY_OPERATOR,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const COLLECTION_OPERATOR = {
|
||||||
|
...CORE_COLLECTION_OPERATOR,
|
||||||
|
...EXTENDED_COLLECTION_OPERATOR,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const COMPARISON_OPERATOR = {
|
||||||
|
...CORE_COMPARISON_OPERATOR,
|
||||||
|
...EXTENDED_COMPARISON_OPERATOR,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// -------- COMPOSED TYPES --------
|
||||||
|
|
||||||
|
export type TLogicalOperator = (typeof LOGICAL_OPERATOR)[keyof typeof LOGICAL_OPERATOR];
|
||||||
|
export type TEqualityOperator = (typeof EQUALITY_OPERATOR)[keyof typeof EQUALITY_OPERATOR];
|
||||||
|
export type TCollectionOperator = (typeof COLLECTION_OPERATOR)[keyof typeof COLLECTION_OPERATOR];
|
||||||
|
export type TComparisonOperator = (typeof COMPARISON_OPERATOR)[keyof typeof COMPARISON_OPERATOR];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type representing all operators that can be used in a filter condition.
|
||||||
|
* Combines core and extended operators.
|
||||||
|
*/
|
||||||
|
export type TSupportedOperators = TCoreSupportedOperators | TExtendedSupportedOperators;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All operators available for use in rich filters UI, including negated versions.
|
||||||
|
*/
|
||||||
|
export type TAllAvailableOperatorsForDisplay = TSupportedOperators;
|
||||||
|
|
||||||
|
// -------- RE-EXPORTS --------
|
||||||
|
|
||||||
|
export * from "./core";
|
||||||
|
export * from "./extended";
|
||||||
@ -5,3 +5,5 @@ export type PartialDeep<K> = {
|
|||||||
export type CompleteOrEmpty<T> = T | Record<string, never>;
|
export type CompleteOrEmpty<T> = T | Record<string, never>;
|
||||||
|
|
||||||
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||||
|
|
||||||
|
export type SingleOrArray<T> = T extends null | undefined ? T : T | T[];
|
||||||
|
|||||||
@ -162,6 +162,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
|
|||||||
"max-h-48": maxHeight === "md",
|
"max-h-48": maxHeight === "md",
|
||||||
"max-h-36": maxHeight === "rg",
|
"max-h-36": maxHeight === "rg",
|
||||||
"max-h-28": maxHeight === "sm",
|
"max-h-28": maxHeight === "sm",
|
||||||
|
"max-h-full": maxHeight === "full",
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{filteredOptions ? (
|
{filteredOptions ? (
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export interface IDropdownProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
input?: boolean;
|
input?: boolean;
|
||||||
label?: string | React.ReactNode;
|
label?: string | React.ReactNode;
|
||||||
maxHeight?: "sm" | "rg" | "md" | "lg";
|
maxHeight?: "sm" | "rg" | "md" | "lg" | "full";
|
||||||
noChevron?: boolean;
|
noChevron?: boolean;
|
||||||
chevronClassName?: string;
|
chevronClassName?: string;
|
||||||
onOpen?: () => void;
|
onOpen?: () => void;
|
||||||
|
|||||||
@ -535,18 +535,25 @@ export const formatDateRange = (
|
|||||||
// Duration Helpers
|
// Duration Helpers
|
||||||
/**
|
/**
|
||||||
* @returns {string} formatted duration in human readable format
|
* @returns {string} formatted duration in human readable format
|
||||||
* @description Converts seconds to human readable duration format (e.g., "1 hr 20 min 5 sec")
|
* @description Converts seconds to human readable duration format (e.g., "1 hr 20 min 5 sec" or "122.30 ms")
|
||||||
* @param {number} seconds - The duration in seconds
|
* @param {number} seconds - The duration in seconds
|
||||||
* @example formatDuration(3665) // "1 hr 1 min 5 sec"
|
* @example formatDuration(3665) // "1 hr 1 min 5 sec"
|
||||||
* @example formatDuration(125) // "2 min 5 sec"
|
* @example formatDuration(125) // "2 min 5 sec"
|
||||||
* @example formatDuration(45) // "45 sec"
|
* @example formatDuration(45) // "45 sec"
|
||||||
|
* @example formatDuration(0.1223094) // "122.31 ms"
|
||||||
*/
|
*/
|
||||||
export const formatDuration = (seconds: number | undefined | null): string => {
|
export const formatDuration = (seconds: number | undefined | null): string => {
|
||||||
// Return "N/A" if seconds is not a valid number
|
// Return "N/A" if seconds is not a valid number
|
||||||
if (!isNumber(seconds) || seconds === null || seconds === undefined || seconds < 0) {
|
if (seconds == null || typeof seconds !== "number" || !Number.isFinite(seconds) || seconds < 0) {
|
||||||
return "N/A";
|
return "N/A";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If less than 1 second, show in ms (2 decimal places)
|
||||||
|
if (seconds > 0 && seconds < 1) {
|
||||||
|
const ms = seconds * 1000;
|
||||||
|
return `${ms.toFixed(2)} ms`;
|
||||||
|
}
|
||||||
|
|
||||||
// Round to nearest second
|
// Round to nearest second
|
||||||
const totalSeconds = Math.round(seconds);
|
const totalSeconds = Math.round(seconds);
|
||||||
|
|
||||||
@ -559,7 +566,7 @@ export const formatDuration = (seconds: number | undefined | null): string => {
|
|||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
if (hours > 0) {
|
if (hours > 0) {
|
||||||
parts.push(`${hours} hr${hours !== 1 ? "" : ""}`); // Always use "hr" for consistency
|
parts.push(`${hours} hr`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (minutes > 0) {
|
if (minutes > 0) {
|
||||||
@ -572,3 +579,11 @@ export const formatDuration = (seconds: number | undefined | null): string => {
|
|||||||
|
|
||||||
return parts.join(" ");
|
return parts.join(" ");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a date is valid
|
||||||
|
* @param date The date to check
|
||||||
|
* @returns Whether the date is valid or not
|
||||||
|
*/
|
||||||
|
export const isValidDate = (date: unknown): date is string | Date =>
|
||||||
|
(typeof date === "string" || typeof date === "object") && date !== null && !isNaN(Date.parse(date as string));
|
||||||
|
|||||||
@ -19,8 +19,9 @@ export * from "./module";
|
|||||||
export * from "./notification";
|
export * from "./notification";
|
||||||
export * from "./page";
|
export * from "./page";
|
||||||
export * from "./permission";
|
export * from "./permission";
|
||||||
export * from "./project";
|
|
||||||
export * from "./project-views";
|
export * from "./project-views";
|
||||||
|
export * from "./project";
|
||||||
|
export * from "./rich-filters";
|
||||||
export * from "./router";
|
export * from "./router";
|
||||||
export * from "./string";
|
export * from "./string";
|
||||||
export * from "./subscription";
|
export * from "./subscription";
|
||||||
|
|||||||
156
packages/utils/src/rich-filters/factories/configs/core.ts
Normal file
156
packages/utils/src/rich-filters/factories/configs/core.ts
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
// plane imports
|
||||||
|
import {
|
||||||
|
FILTER_FIELD_TYPE,
|
||||||
|
TFilterValue,
|
||||||
|
TFilterProperty,
|
||||||
|
TFilterConfig,
|
||||||
|
TSupportedOperators,
|
||||||
|
TBaseFilterFieldConfig,
|
||||||
|
} from "@plane/types";
|
||||||
|
// local imports
|
||||||
|
import {
|
||||||
|
createFilterFieldConfig,
|
||||||
|
DEFAULT_DATE_FILTER_TYPE_CONFIG,
|
||||||
|
DEFAULT_DATE_RANGE_FILTER_TYPE_CONFIG,
|
||||||
|
DEFAULT_MULTI_SELECT_FILTER_TYPE_CONFIG,
|
||||||
|
DEFAULT_SINGLE_SELECT_FILTER_TYPE_CONFIG,
|
||||||
|
IFilterIconConfig,
|
||||||
|
} from "./shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a type-safe filter config
|
||||||
|
* @param config - The filter config to create
|
||||||
|
* @returns The created filter config
|
||||||
|
*/
|
||||||
|
export const createFilterConfig = <P extends TFilterProperty, V extends TFilterValue>(
|
||||||
|
config: TFilterConfig<P, V>
|
||||||
|
): TFilterConfig<P, V> => config;
|
||||||
|
|
||||||
|
// ------------ Selection filters ------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options transformation interface for selection filters
|
||||||
|
*/
|
||||||
|
export interface TOptionTransforms<TItem, TValue extends TFilterValue = string, TIconData = undefined> {
|
||||||
|
items: TItem[];
|
||||||
|
getId: (item: TItem) => string;
|
||||||
|
getLabel: (item: TItem) => string;
|
||||||
|
getValue: (item: TItem) => TValue;
|
||||||
|
getIconData?: (item: TItem) => TIconData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-select filter configuration
|
||||||
|
*/
|
||||||
|
export type TSingleSelectConfig<TValue extends TFilterValue = string> = TBaseFilterFieldConfig & {
|
||||||
|
defaultValue?: TValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get the single select config
|
||||||
|
* @param transforms - How to transform items into options
|
||||||
|
* @param config - Single-select specific configuration
|
||||||
|
* @param iconConfig - Icon configuration for options
|
||||||
|
* @returns The single select config
|
||||||
|
*/
|
||||||
|
export const getSingleSelectConfig = <
|
||||||
|
TItem,
|
||||||
|
TValue extends TFilterValue = string,
|
||||||
|
TIconData extends string | number | boolean | object | undefined = undefined,
|
||||||
|
>(
|
||||||
|
transforms: TOptionTransforms<TItem, TValue, TIconData>,
|
||||||
|
config?: TSingleSelectConfig<TValue>,
|
||||||
|
iconConfig?: IFilterIconConfig<TIconData>
|
||||||
|
) =>
|
||||||
|
createFilterFieldConfig<typeof FILTER_FIELD_TYPE.SINGLE_SELECT, TValue>({
|
||||||
|
type: FILTER_FIELD_TYPE.SINGLE_SELECT,
|
||||||
|
...DEFAULT_SINGLE_SELECT_FILTER_TYPE_CONFIG,
|
||||||
|
...config,
|
||||||
|
getOptions: () =>
|
||||||
|
transforms.items.map((item) => ({
|
||||||
|
id: transforms.getId(item),
|
||||||
|
label: transforms.getLabel(item),
|
||||||
|
value: transforms.getValue(item),
|
||||||
|
icon: iconConfig?.getOptionIcon?.(transforms.getIconData?.(item) as TIconData),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-select filter configuration
|
||||||
|
*/
|
||||||
|
export type TMultiSelectConfig<TValue extends TFilterValue = string> = TBaseFilterFieldConfig & {
|
||||||
|
defaultValue?: TValue[];
|
||||||
|
singleValueOperator: TSupportedOperators;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get the multi select config
|
||||||
|
* @param transforms - How to transform items into options
|
||||||
|
* @param config - Multi-select specific configuration
|
||||||
|
* @param iconConfig - Icon configuration for options
|
||||||
|
* @returns The multi select config
|
||||||
|
*/
|
||||||
|
export const getMultiSelectConfig = <
|
||||||
|
TItem,
|
||||||
|
TValue extends TFilterValue = string,
|
||||||
|
TIconData extends string | number | boolean | object | undefined = undefined,
|
||||||
|
>(
|
||||||
|
transforms: TOptionTransforms<TItem, TValue, TIconData>,
|
||||||
|
config: TMultiSelectConfig<TValue>,
|
||||||
|
iconConfig?: IFilterIconConfig<TIconData>
|
||||||
|
) =>
|
||||||
|
createFilterFieldConfig<typeof FILTER_FIELD_TYPE.MULTI_SELECT, TValue>({
|
||||||
|
type: FILTER_FIELD_TYPE.MULTI_SELECT,
|
||||||
|
...DEFAULT_MULTI_SELECT_FILTER_TYPE_CONFIG,
|
||||||
|
...config,
|
||||||
|
operatorLabel: config?.operatorLabel,
|
||||||
|
getOptions: () =>
|
||||||
|
transforms.items.map((item) => ({
|
||||||
|
id: transforms.getId(item),
|
||||||
|
label: transforms.getLabel(item),
|
||||||
|
value: transforms.getValue(item),
|
||||||
|
icon: iconConfig?.getOptionIcon?.(transforms.getIconData?.(item) as TIconData),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------ Date filters ------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date filter configuration
|
||||||
|
*/
|
||||||
|
export type TDateConfig = TBaseFilterFieldConfig & {
|
||||||
|
min?: Date;
|
||||||
|
max?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date range filter configuration
|
||||||
|
*/
|
||||||
|
export type TDateRangeConfig = TBaseFilterFieldConfig & {
|
||||||
|
min?: Date;
|
||||||
|
max?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get the date picker config
|
||||||
|
* @param config - Date-specific configuration
|
||||||
|
* @returns The date picker config
|
||||||
|
*/
|
||||||
|
export const getDatePickerConfig = (config?: TDateConfig) =>
|
||||||
|
createFilterFieldConfig<typeof FILTER_FIELD_TYPE.DATE, Date>({
|
||||||
|
type: FILTER_FIELD_TYPE.DATE,
|
||||||
|
...DEFAULT_DATE_FILTER_TYPE_CONFIG,
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get the date range picker config
|
||||||
|
* @param config - Date range-specific configuration
|
||||||
|
* @returns The date range picker config
|
||||||
|
*/
|
||||||
|
export const getDateRangePickerConfig = (config?: TDateRangeConfig) =>
|
||||||
|
createFilterFieldConfig<typeof FILTER_FIELD_TYPE.DATE_RANGE, Date>({
|
||||||
|
type: FILTER_FIELD_TYPE.DATE_RANGE,
|
||||||
|
...DEFAULT_DATE_RANGE_FILTER_TYPE_CONFIG,
|
||||||
|
...config,
|
||||||
|
});
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./core";
|
||||||
|
export * from "./shared";
|
||||||
76
packages/utils/src/rich-filters/factories/configs/shared.ts
Normal file
76
packages/utils/src/rich-filters/factories/configs/shared.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
FILTER_FIELD_TYPE,
|
||||||
|
TBaseFilterFieldConfig,
|
||||||
|
TDateFilterFieldConfig,
|
||||||
|
TDateRangeFilterFieldConfig,
|
||||||
|
TFilterConfig,
|
||||||
|
TFilterProperty,
|
||||||
|
TFilterFieldType,
|
||||||
|
TFilterValue,
|
||||||
|
TMultiSelectFilterFieldConfig,
|
||||||
|
TSingleSelectFilterFieldConfig,
|
||||||
|
TSupportedFilterFieldConfigs,
|
||||||
|
} from "@plane/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function signature for creating filter configurations.
|
||||||
|
*/
|
||||||
|
export type TCreateFilterConfig<P extends TFilterProperty, T> = (params: T) => TFilterConfig<P>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a type-safe filter field config
|
||||||
|
* @param config - The filter field config to create
|
||||||
|
* @returns The created filter field config
|
||||||
|
*/
|
||||||
|
export const createFilterFieldConfig = <T extends TFilterFieldType, V extends TFilterValue>(
|
||||||
|
config: T extends typeof FILTER_FIELD_TYPE.SINGLE_SELECT
|
||||||
|
? TSingleSelectFilterFieldConfig<V>
|
||||||
|
: T extends typeof FILTER_FIELD_TYPE.MULTI_SELECT
|
||||||
|
? TMultiSelectFilterFieldConfig<V>
|
||||||
|
: T extends typeof FILTER_FIELD_TYPE.DATE
|
||||||
|
? TDateFilterFieldConfig<V>
|
||||||
|
: T extends typeof FILTER_FIELD_TYPE.DATE_RANGE
|
||||||
|
? TDateRangeFilterFieldConfig<V>
|
||||||
|
: never
|
||||||
|
): TSupportedFilterFieldConfigs<V> => config as TSupportedFilterFieldConfigs<V>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base parameters for filter type config factory functions.
|
||||||
|
* - operator: The operator to use for the filter.
|
||||||
|
*/
|
||||||
|
export type TCreateFilterConfigParams = TBaseFilterFieldConfig & {
|
||||||
|
isEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon configuration for filters and their options.
|
||||||
|
* - filterIcon: Optional icon for the filter
|
||||||
|
* - getOptionIcon: Function to get icon for specific option values
|
||||||
|
*/
|
||||||
|
export interface IFilterIconConfig<T extends string | number | boolean | object | undefined = undefined> {
|
||||||
|
filterIcon?: React.FC<React.SVGAttributes<SVGElement>>;
|
||||||
|
getOptionIcon?: (value: T) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date filter config params
|
||||||
|
*/
|
||||||
|
export type TCreateDateFilterParams = TCreateFilterConfigParams & IFilterIconConfig<Date>;
|
||||||
|
|
||||||
|
// ------------ Default filter type configs ------------
|
||||||
|
|
||||||
|
export const DEFAULT_SINGLE_SELECT_FILTER_TYPE_CONFIG = {
|
||||||
|
allowNegative: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_MULTI_SELECT_FILTER_TYPE_CONFIG = {
|
||||||
|
allowNegative: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_DATE_FILTER_TYPE_CONFIG = {
|
||||||
|
allowNegative: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_DATE_RANGE_FILTER_TYPE_CONFIG = {
|
||||||
|
allowNegative: false,
|
||||||
|
};
|
||||||
3
packages/utils/src/rich-filters/factories/index.ts
Normal file
3
packages/utils/src/rich-filters/factories/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./configs/core";
|
||||||
|
export * from "./configs/shared";
|
||||||
|
export * from "./nodes/core";
|
||||||
39
packages/utils/src/rich-filters/factories/nodes/core.ts
Normal file
39
packages/utils/src/rich-filters/factories/nodes/core.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
// plane imports
|
||||||
|
import {
|
||||||
|
FILTER_NODE_TYPE,
|
||||||
|
LOGICAL_OPERATOR,
|
||||||
|
TFilterAndGroupNode,
|
||||||
|
TFilterConditionNode,
|
||||||
|
TFilterConditionPayload,
|
||||||
|
TFilterExpression,
|
||||||
|
TFilterProperty,
|
||||||
|
TFilterValue,
|
||||||
|
} from "@plane/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a condition node with a unique ID.
|
||||||
|
* @param condition - The condition to create
|
||||||
|
* @returns The created condition node
|
||||||
|
*/
|
||||||
|
export const createConditionNode = <P extends TFilterProperty, V extends TFilterValue>(
|
||||||
|
condition: TFilterConditionPayload<P, V>
|
||||||
|
): TFilterConditionNode<P, V> => ({
|
||||||
|
id: uuidv4(),
|
||||||
|
type: FILTER_NODE_TYPE.CONDITION,
|
||||||
|
...condition,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an AND group node with a unique ID.
|
||||||
|
* @param nodes - The nodes to add to the group
|
||||||
|
* @returns The created AND group node
|
||||||
|
*/
|
||||||
|
export const createAndGroupNode = <P extends TFilterProperty>(
|
||||||
|
nodes: TFilterExpression<P>[]
|
||||||
|
): TFilterAndGroupNode<P> => ({
|
||||||
|
id: uuidv4(),
|
||||||
|
type: FILTER_NODE_TYPE.GROUP,
|
||||||
|
logicalOperator: LOGICAL_OPERATOR.AND,
|
||||||
|
children: nodes,
|
||||||
|
});
|
||||||
6
packages/utils/src/rich-filters/index.ts
Normal file
6
packages/utils/src/rich-filters/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export * from "./factories";
|
||||||
|
export * from "./operations";
|
||||||
|
export * from "./operators";
|
||||||
|
export * from "./types";
|
||||||
|
export * from "./validators";
|
||||||
|
export * from "./values";
|
||||||
170
packages/utils/src/rich-filters/operations/comparison.ts
Normal file
170
packages/utils/src/rich-filters/operations/comparison.ts
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import compact from "lodash/compact";
|
||||||
|
import isEqual from "lodash/isEqual";
|
||||||
|
import sortBy from "lodash/sortBy";
|
||||||
|
// plane imports
|
||||||
|
import {
|
||||||
|
FILTER_NODE_TYPE,
|
||||||
|
TFilterConditionNode,
|
||||||
|
TFilterExpression,
|
||||||
|
TFilterGroupNode,
|
||||||
|
TFilterProperty,
|
||||||
|
TFilterValue,
|
||||||
|
} from "@plane/types";
|
||||||
|
// local imports
|
||||||
|
import { isConditionNode, isGroupNode } from "../types/core";
|
||||||
|
import { processGroupNode } from "../types/shared";
|
||||||
|
import { hasValidValue } from "../validators/core";
|
||||||
|
import { transformExpressionTree } from "./transformation/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a comparable representation of a condition for deep comparison.
|
||||||
|
* This uses property, operator, and value instead of ID for comparison.
|
||||||
|
* IDs are completely excluded to avoid UUID comparison issues.
|
||||||
|
* @param condition - The condition to create a comparable representation for
|
||||||
|
* @returns A comparable object without ID
|
||||||
|
*/
|
||||||
|
const createConditionComparable = <P extends TFilterProperty>(condition: TFilterConditionNode<P, TFilterValue>) => ({
|
||||||
|
// Explicitly exclude: id (random UUID should not be compared)
|
||||||
|
type: condition.type,
|
||||||
|
property: condition.property,
|
||||||
|
operator: condition.operator,
|
||||||
|
value: Array.isArray(condition.value) ? condition.value : [condition.value],
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to create comparable children for AND/OR groups.
|
||||||
|
* This eliminates code duplication between AND and OR group processing.
|
||||||
|
*/
|
||||||
|
const createComparableChildren = <P extends TFilterProperty>(
|
||||||
|
children: TFilterExpression<P>[],
|
||||||
|
baseComparable: Record<string, unknown>
|
||||||
|
): Record<string, unknown> => {
|
||||||
|
const childrenComparable = compact(children.map((child) => createExpressionComparable(child)));
|
||||||
|
|
||||||
|
// Sort children by a consistent key for comparison to ensure order doesn't affect equality
|
||||||
|
const sortedChildren = sortBy(childrenComparable, (child) => {
|
||||||
|
if (child?.type === FILTER_NODE_TYPE.CONDITION) {
|
||||||
|
return `condition_${child.property}_${child.operator}_${JSON.stringify(child.value)}`;
|
||||||
|
}
|
||||||
|
// For nested groups, sort by logical operator and recursive structure
|
||||||
|
if (child?.type === FILTER_NODE_TYPE.GROUP) {
|
||||||
|
const childrenCount = child.child ? 1 : Array.isArray(child.children) ? child.children.length : 0;
|
||||||
|
return `group_${child.logicalOperator}_${childrenCount}_${JSON.stringify(child)}`;
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseComparable,
|
||||||
|
children: sortedChildren,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a comparable representation of a group for deep comparison.
|
||||||
|
* This recursively creates comparable representations for all children.
|
||||||
|
* IDs are completely excluded to avoid UUID comparison issues.
|
||||||
|
* Uses processGroupNode for consistent group type handling.
|
||||||
|
* @param group - The group to create a comparable representation for
|
||||||
|
* @returns A comparable object without ID
|
||||||
|
*/
|
||||||
|
export const createGroupComparable = <P extends TFilterProperty>(
|
||||||
|
group: TFilterGroupNode<P>
|
||||||
|
): Record<string, unknown> => {
|
||||||
|
const baseComparable = {
|
||||||
|
// Explicitly exclude: id (random UUID should not be compared)
|
||||||
|
type: group.type,
|
||||||
|
logicalOperator: group.logicalOperator,
|
||||||
|
};
|
||||||
|
|
||||||
|
return processGroupNode(group, {
|
||||||
|
onAndGroup: (andGroup) => createComparableChildren(andGroup.children, baseComparable),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a comparable representation of any filter expression.
|
||||||
|
* Recursively handles deep nesting of groups within groups.
|
||||||
|
* Completely excludes IDs from comparison to avoid UUID issues.
|
||||||
|
* @param expression - The expression to create a comparable representation for
|
||||||
|
* @returns A comparable object without IDs or null if the expression is empty
|
||||||
|
*/
|
||||||
|
export const createExpressionComparable = <P extends TFilterProperty>(
|
||||||
|
expression: TFilterExpression<P> | null
|
||||||
|
): Record<string, unknown> | null => {
|
||||||
|
if (!expression) return null;
|
||||||
|
|
||||||
|
// Handle condition nodes - exclude ID completely
|
||||||
|
if (isConditionNode(expression)) {
|
||||||
|
return createConditionComparable(expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle group nodes - exclude ID completely and support deep nesting
|
||||||
|
if (isGroupNode(expression)) {
|
||||||
|
return createGroupComparable(expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should never reach here with proper typing, but return null for safety
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a filter expression by removing empty conditions and groups.
|
||||||
|
* This helps compare expressions by focusing only on meaningful content.
|
||||||
|
* Uses the transformExpressionTree utility for consistent tree processing.
|
||||||
|
* @param expression - The filter expression to normalize
|
||||||
|
* @returns The normalized expression or null if the entire expression is empty
|
||||||
|
*/
|
||||||
|
export const normalizeFilterExpression = <P extends TFilterProperty>(
|
||||||
|
expression: TFilterExpression<P> | null
|
||||||
|
): TFilterExpression<P> | null => {
|
||||||
|
const result = transformExpressionTree<P>(expression, (node: TFilterExpression<P>) => {
|
||||||
|
// Only transform condition nodes - check if they have valid values
|
||||||
|
if (isConditionNode(node)) {
|
||||||
|
return {
|
||||||
|
expression: hasValidValue(node.value) ? node : null,
|
||||||
|
shouldNotify: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// For group nodes, let the generic transformer handle the recursion
|
||||||
|
return { expression: node, shouldNotify: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.expression;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs a deep comparison of two filter expressions based on their meaningful content.
|
||||||
|
* This comparison completely ignores IDs (UUIDs) and focuses on property, operator, value, and tree structure.
|
||||||
|
* Empty conditions and groups are normalized before comparison.
|
||||||
|
* Supports deep nesting of groups within groups recursively.
|
||||||
|
* @param expression1 - The first expression to compare
|
||||||
|
* @param expression2 - The second expression to compare
|
||||||
|
* @returns True if the expressions are meaningfully equal, false otherwise
|
||||||
|
*/
|
||||||
|
export const deepCompareFilterExpressions = <P extends TFilterProperty>(
|
||||||
|
expression1: TFilterExpression<P> | null,
|
||||||
|
expression2: TFilterExpression<P> | null
|
||||||
|
): boolean => {
|
||||||
|
// Normalize both expressions to remove empty conditions and groups
|
||||||
|
const normalized1 = normalizeFilterExpression(expression1);
|
||||||
|
const normalized2 = normalizeFilterExpression(expression2);
|
||||||
|
|
||||||
|
// If both are null after normalization, they're equal
|
||||||
|
if (!normalized1 && !normalized2) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If one is null and the other isn't, they're different
|
||||||
|
if (!normalized1 || !normalized2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create comparable representations (IDs completely excluded)
|
||||||
|
const comparable1 = createExpressionComparable(normalized1);
|
||||||
|
const comparable2 = createExpressionComparable(normalized2);
|
||||||
|
|
||||||
|
// Deep compare using lodash isEqual for reliable object comparison
|
||||||
|
// This handles deep nesting recursively and ignores UUID differences
|
||||||
|
return isEqual(comparable1, comparable2);
|
||||||
|
};
|
||||||
4
packages/utils/src/rich-filters/operations/index.ts
Normal file
4
packages/utils/src/rich-filters/operations/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./comparison";
|
||||||
|
export * from "./manipulation/core";
|
||||||
|
export * from "./transformation/core";
|
||||||
|
export * from "./traversal/core";
|
||||||
124
packages/utils/src/rich-filters/operations/manipulation/core.ts
Normal file
124
packages/utils/src/rich-filters/operations/manipulation/core.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
// plane imports
|
||||||
|
import {
|
||||||
|
TFilterConditionPayload,
|
||||||
|
TFilterExpression,
|
||||||
|
TFilterGroupNode,
|
||||||
|
TFilterProperty,
|
||||||
|
TFilterValue,
|
||||||
|
} from "@plane/types";
|
||||||
|
// local imports
|
||||||
|
import { createAndGroupNode } from "../../factories/nodes/core";
|
||||||
|
import { getGroupChildren } from "../../types";
|
||||||
|
import { isAndGroupNode, isConditionNode, isGroupNode } from "../../types/core";
|
||||||
|
import { shouldUnwrapGroup } from "../../validators/shared";
|
||||||
|
import { transformExpressionTree } from "../transformation/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an AND condition to the filter expression.
|
||||||
|
* @param expression - The current filter expression
|
||||||
|
* @param condition - The condition to add
|
||||||
|
* @returns The updated filter expression
|
||||||
|
*/
|
||||||
|
export const addAndCondition = <P extends TFilterProperty>(
|
||||||
|
expression: TFilterExpression<P> | null,
|
||||||
|
condition: TFilterExpression<P>
|
||||||
|
): TFilterExpression<P> => {
|
||||||
|
// if no expression, set the new condition
|
||||||
|
if (!expression) {
|
||||||
|
return condition;
|
||||||
|
}
|
||||||
|
// if the expression is a condition, convert it to an AND group
|
||||||
|
if (isConditionNode(expression)) {
|
||||||
|
return createAndGroupNode([expression, condition]);
|
||||||
|
}
|
||||||
|
// if the expression is a group, and the group is an AND group, add the new condition to the group
|
||||||
|
if (isGroupNode(expression) && isAndGroupNode(expression)) {
|
||||||
|
expression.children.push(condition);
|
||||||
|
return expression;
|
||||||
|
}
|
||||||
|
// if the expression is a group, but not an AND group, create a new AND group and add the new condition to it
|
||||||
|
if (isGroupNode(expression) && !isAndGroupNode(expression)) {
|
||||||
|
return createAndGroupNode([expression, condition]);
|
||||||
|
}
|
||||||
|
// Throw error for unexpected expression type
|
||||||
|
console.error("Invalid expression type", expression);
|
||||||
|
return expression;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces a node in the expression tree with another node.
|
||||||
|
* Uses transformExpressionTree for consistent tree processing and better maintainability.
|
||||||
|
* @param expression - The expression tree to search in
|
||||||
|
* @param targetId - The ID of the node to replace
|
||||||
|
* @param replacement - The node to replace with
|
||||||
|
* @returns The updated expression tree
|
||||||
|
*/
|
||||||
|
export const replaceNodeInExpression = <P extends TFilterProperty>(
|
||||||
|
expression: TFilterExpression<P>,
|
||||||
|
targetId: string,
|
||||||
|
replacement: TFilterExpression<P>
|
||||||
|
): TFilterExpression<P> => {
|
||||||
|
const result = transformExpressionTree(expression, (node: TFilterExpression<P>) => {
|
||||||
|
// If this is the node we want to replace, return the replacement
|
||||||
|
if (node.id === targetId) {
|
||||||
|
return {
|
||||||
|
expression: replacement,
|
||||||
|
shouldNotify: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// For all other nodes, let the generic transformer handle the recursion
|
||||||
|
return { expression: node, shouldNotify: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Since we're doing a replacement, the result should never be null
|
||||||
|
return result.expression || expression;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a node in the filter expression.
|
||||||
|
* Uses recursive tree traversal with proper type handling.
|
||||||
|
* @param expression - The filter expression to update
|
||||||
|
* @param targetId - The id of the node to update
|
||||||
|
* @param updates - The updates to apply to the node
|
||||||
|
*/
|
||||||
|
export const updateNodeInExpression = <P extends TFilterProperty>(
|
||||||
|
expression: TFilterExpression<P>,
|
||||||
|
targetId: string,
|
||||||
|
updates: Partial<TFilterConditionPayload<P, TFilterValue>>
|
||||||
|
) => {
|
||||||
|
// Helper function to recursively update nodes
|
||||||
|
const updateNode = (node: TFilterExpression<P>): void => {
|
||||||
|
if (node.id === targetId) {
|
||||||
|
if (!isConditionNode<P, TFilterValue>(node)) {
|
||||||
|
console.warn("updateNodeInExpression: targetId matched a group; ignoring updates");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Object.assign(node, updates);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGroupNode(node)) {
|
||||||
|
const children = getGroupChildren(node);
|
||||||
|
children.forEach((child) => updateNode(child));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateNode(expression);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unwraps a group if it meets the unwrapping criteria, otherwise returns the group.
|
||||||
|
* @param group - The group node to potentially unwrap
|
||||||
|
* @param preserveNotGroups - Whether to preserve NOT groups even with single children
|
||||||
|
* @returns The unwrapped child or the original group
|
||||||
|
*/
|
||||||
|
export const unwrapGroupIfNeeded = <P extends TFilterProperty>(
|
||||||
|
group: TFilterGroupNode<P>,
|
||||||
|
preserveNotGroups = true
|
||||||
|
) => {
|
||||||
|
if (shouldUnwrapGroup(group, preserveNotGroups)) {
|
||||||
|
const children = getGroupChildren(group);
|
||||||
|
return children[0];
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
};
|
||||||
@ -0,0 +1,178 @@
|
|||||||
|
// plane imports
|
||||||
|
import { TFilterExpression, TFilterGroupNode, TFilterProperty } from "@plane/types";
|
||||||
|
// local imports
|
||||||
|
import { isConditionNode, isGroupNode } from "../../types/core";
|
||||||
|
import { getGroupChildren } from "../../types/shared";
|
||||||
|
import { hasValidValue } from "../../validators/core";
|
||||||
|
import { unwrapGroupIfNeeded } from "../manipulation/core";
|
||||||
|
import { transformGroup } from "./shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic tree transformation result type
|
||||||
|
*/
|
||||||
|
export type TTreeTransformResult<P extends TFilterProperty> = {
|
||||||
|
expression: TFilterExpression<P> | null;
|
||||||
|
shouldNotify?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform function type for tree processing
|
||||||
|
*/
|
||||||
|
export type TTreeTransformFn<P extends TFilterProperty> = (expression: TFilterExpression<P>) => TTreeTransformResult<P>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic recursive tree transformer that handles common tree manipulation logic.
|
||||||
|
* This function provides a reusable way to transform expression trees while maintaining
|
||||||
|
* tree integrity, handling group restructuring, and applying stabilization.
|
||||||
|
*
|
||||||
|
* @param expression - The expression to transform
|
||||||
|
* @param transformFn - Function that defines the transformation logic for each node
|
||||||
|
* @returns The transformation result with expression and metadata
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Helper function to create a consistent transformation result for group nodes.
|
||||||
|
* Centralizes the logic for wrapping group expressions and tracking notifications.
|
||||||
|
*/
|
||||||
|
const createGroupTransformResult = <P extends TFilterProperty>(
|
||||||
|
groupExpression: TFilterGroupNode<P> | null,
|
||||||
|
shouldNotify: boolean
|
||||||
|
): TTreeTransformResult<P> => ({
|
||||||
|
expression: groupExpression ? unwrapGroupIfNeeded(groupExpression, true) : null,
|
||||||
|
shouldNotify,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms groups with children by processing all children.
|
||||||
|
* Handles child collection, null filtering, and empty group removal.
|
||||||
|
*/
|
||||||
|
export const transformGroupWithChildren = <P extends TFilterProperty>(
|
||||||
|
group: TFilterGroupNode<P>,
|
||||||
|
transformFn: TTreeTransformFn<P>
|
||||||
|
): TTreeTransformResult<P> => {
|
||||||
|
const children = getGroupChildren(group);
|
||||||
|
const transformedChildren: TFilterExpression<P>[] = [];
|
||||||
|
let shouldNotify = false;
|
||||||
|
|
||||||
|
// Transform all children and collect non-null results
|
||||||
|
for (const child of children) {
|
||||||
|
const childResult = transformExpressionTree(child, transformFn);
|
||||||
|
|
||||||
|
if (childResult.shouldNotify) {
|
||||||
|
shouldNotify = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childResult.expression !== null) {
|
||||||
|
transformedChildren.push(childResult.expression);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no children remain, remove the entire group
|
||||||
|
if (transformedChildren.length === 0) {
|
||||||
|
return { expression: null, shouldNotify };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create updated group with transformed children - type-safe without casting
|
||||||
|
const updatedGroup: TFilterGroupNode<P> = {
|
||||||
|
...group,
|
||||||
|
children: transformedChildren,
|
||||||
|
} as TFilterGroupNode<P>;
|
||||||
|
|
||||||
|
return createGroupTransformResult(updatedGroup, shouldNotify);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic recursive tree transformer that handles common tree manipulation logic.
|
||||||
|
* This function provides a reusable way to transform expression trees while maintaining
|
||||||
|
* tree integrity, handling group restructuring, and applying stabilization.
|
||||||
|
*
|
||||||
|
* @param expression - The expression to transform
|
||||||
|
* @param transformFn - Function that defines the transformation logic for each node
|
||||||
|
* @returns The transformation result with expression and metadata
|
||||||
|
*/
|
||||||
|
export const transformExpressionTree = <P extends TFilterProperty>(
|
||||||
|
expression: TFilterExpression<P> | null,
|
||||||
|
transformFn: TTreeTransformFn<P>
|
||||||
|
): TTreeTransformResult<P> => {
|
||||||
|
// Handle null expressions early
|
||||||
|
if (!expression) {
|
||||||
|
return { expression: null, shouldNotify: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the transformation function to the current node
|
||||||
|
const transformResult = transformFn(expression);
|
||||||
|
|
||||||
|
// If the transform function handled this node completely, return its result
|
||||||
|
if (transformResult.expression === null || transformResult.expression !== expression) {
|
||||||
|
return transformResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle condition nodes (no children to transform)
|
||||||
|
if (isConditionNode(expression)) {
|
||||||
|
return { expression, shouldNotify: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle group nodes by delegating to the extended transformGroup function
|
||||||
|
if (isGroupNode(expression)) {
|
||||||
|
return transformGroup(expression, transformFn);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unknown expression type in transformExpressionTree");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a node from the filter expression.
|
||||||
|
* @param expression - The filter expression to remove the node from
|
||||||
|
* @param targetId - The id of the node to remove
|
||||||
|
* @returns An object containing the updated filter expression and whether to notify about the change
|
||||||
|
*/
|
||||||
|
export const removeNodeFromExpression = <P extends TFilterProperty>(
|
||||||
|
expression: TFilterExpression<P>,
|
||||||
|
targetId: string
|
||||||
|
): { expression: TFilterExpression<P> | null; shouldNotify: boolean } => {
|
||||||
|
const result = transformExpressionTree(expression, (node) => {
|
||||||
|
// If this node matches the target ID, remove it
|
||||||
|
if (node.id === targetId) {
|
||||||
|
const shouldNotify = isConditionNode(node) ? hasValidValue(node.value) : true;
|
||||||
|
return {
|
||||||
|
expression: null,
|
||||||
|
shouldNotify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// For all other nodes, let the generic transformer handle the recursion
|
||||||
|
return { expression: node, shouldNotify: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
expression: result.expression,
|
||||||
|
shouldNotify: result.shouldNotify || false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes and stabilizes a filter expression by removing invalid conditions and unnecessary groups.
|
||||||
|
* This function performs deep sanitization of the entire expression tree:
|
||||||
|
* 1. Removes condition nodes that don't have valid values
|
||||||
|
* 2. Removes empty groups (groups with no children after sanitization)
|
||||||
|
* 3. Unwraps single-child groups that don't need to be wrapped
|
||||||
|
* 4. Preserves tree integrity and logical operators
|
||||||
|
*
|
||||||
|
* @param expression - The filter expression to sanitize
|
||||||
|
* @returns The sanitized expression or null if no valid conditions remain
|
||||||
|
*/
|
||||||
|
export const sanitizeAndStabilizeExpression = <P extends TFilterProperty>(
|
||||||
|
expression: TFilterExpression<P> | null
|
||||||
|
): TFilterExpression<P> | null => {
|
||||||
|
const result = transformExpressionTree(expression, (node) => {
|
||||||
|
// Only transform condition nodes - check if they have valid values
|
||||||
|
if (isConditionNode(node)) {
|
||||||
|
return {
|
||||||
|
expression: hasValidValue(node.value) ? node : null,
|
||||||
|
shouldNotify: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// For group nodes, let the generic transformer handle the recursion
|
||||||
|
return { expression: node, shouldNotify: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.expression;
|
||||||
|
};
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import { TFilterGroupNode, TFilterProperty } from "@plane/types";
|
||||||
|
import { processGroupNode } from "../../types/shared";
|
||||||
|
import { transformGroupWithChildren, TTreeTransformFn, TTreeTransformResult } from "./core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms groups by processing children.
|
||||||
|
* Handles AND/OR groups with children and NOT groups with single child.
|
||||||
|
* @param group - The group to transform
|
||||||
|
* @param transformFn - The transformation function
|
||||||
|
* @returns The transformation result
|
||||||
|
*/
|
||||||
|
export const transformGroup = <P extends TFilterProperty>(
|
||||||
|
group: TFilterGroupNode<P>,
|
||||||
|
transformFn: TTreeTransformFn<P>
|
||||||
|
): TTreeTransformResult<P> =>
|
||||||
|
processGroupNode(group, {
|
||||||
|
onAndGroup: (andGroup) => transformGroupWithChildren(andGroup, transformFn),
|
||||||
|
});
|
||||||
210
packages/utils/src/rich-filters/operations/traversal/core.ts
Normal file
210
packages/utils/src/rich-filters/operations/traversal/core.ts
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
// plane imports
|
||||||
|
import {
|
||||||
|
TAllAvailableOperatorsForDisplay,
|
||||||
|
TFilterConditionNode,
|
||||||
|
TFilterConditionNodeForDisplay,
|
||||||
|
TFilterExpression,
|
||||||
|
TFilterGroupNode,
|
||||||
|
TFilterProperty,
|
||||||
|
TFilterValue,
|
||||||
|
} from "@plane/types";
|
||||||
|
// local imports
|
||||||
|
import { isConditionNode, isGroupNode } from "../../types/core";
|
||||||
|
import { getGroupChildren } from "../../types/shared";
|
||||||
|
import { getDisplayOperator } from "./shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic tree visitor function type
|
||||||
|
*/
|
||||||
|
export type TreeVisitorFn<P extends TFilterProperty, T> = (
|
||||||
|
expression: TFilterExpression<P>,
|
||||||
|
parent?: TFilterGroupNode<P>,
|
||||||
|
depth?: number
|
||||||
|
) => T | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tree traversal modes
|
||||||
|
*/
|
||||||
|
export enum TreeTraversalMode {
|
||||||
|
/** Visit all nodes depth-first */
|
||||||
|
ALL = "ALL",
|
||||||
|
/** Visit only condition nodes */
|
||||||
|
CONDITIONS = "CONDITIONS",
|
||||||
|
/** Visit only group nodes */
|
||||||
|
GROUPS = "GROUPS",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic tree traversal utility that visits nodes based on the specified mode.
|
||||||
|
* This eliminates code duplication in tree walking functions.
|
||||||
|
*
|
||||||
|
* @param expression - The expression to traverse
|
||||||
|
* @param visitor - Function to call for each visited node
|
||||||
|
* @param mode - Traversal mode to determine which nodes to visit
|
||||||
|
* @param parent - Parent node (used internally for recursion)
|
||||||
|
* @param depth - Current depth (used internally for recursion)
|
||||||
|
* @returns Array of results from the visitor function (nulls are filtered out)
|
||||||
|
*/
|
||||||
|
export const traverseExpressionTree = <P extends TFilterProperty, T>(
|
||||||
|
expression: TFilterExpression<P> | null,
|
||||||
|
visitor: TreeVisitorFn<P, T>,
|
||||||
|
mode: TreeTraversalMode = TreeTraversalMode.ALL,
|
||||||
|
parent?: TFilterGroupNode<P>,
|
||||||
|
depth: number = 0
|
||||||
|
): T[] => {
|
||||||
|
if (!expression) return [];
|
||||||
|
|
||||||
|
const results: T[] = [];
|
||||||
|
|
||||||
|
// Determine if we should visit this node based on the mode
|
||||||
|
const shouldVisit =
|
||||||
|
mode === TreeTraversalMode.ALL ||
|
||||||
|
(mode === TreeTraversalMode.CONDITIONS && isConditionNode(expression)) ||
|
||||||
|
(mode === TreeTraversalMode.GROUPS && isGroupNode(expression));
|
||||||
|
|
||||||
|
if (shouldVisit) {
|
||||||
|
const result = visitor(expression, parent, depth);
|
||||||
|
if (result !== null) {
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively traverse children for group nodes
|
||||||
|
if (isGroupNode(expression)) {
|
||||||
|
const children = getGroupChildren(expression);
|
||||||
|
for (const child of children) {
|
||||||
|
const childResults = traverseExpressionTree(child, visitor, mode, expression, depth + 1);
|
||||||
|
results.push(...childResults);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a node by its ID in the filter expression tree.
|
||||||
|
* Uses the generic tree traversal utility for better maintainability.
|
||||||
|
* @param expression - The filter expression to search in
|
||||||
|
* @param targetId - The ID of the node to find
|
||||||
|
* @returns The found node or null if not found
|
||||||
|
*/
|
||||||
|
export const findNodeById = <P extends TFilterProperty>(
|
||||||
|
expression: TFilterExpression<P>,
|
||||||
|
targetId: string
|
||||||
|
): TFilterExpression<P> | null => {
|
||||||
|
const results = traverseExpressionTree(
|
||||||
|
expression,
|
||||||
|
(node) => (node.id === targetId ? node : null),
|
||||||
|
TreeTraversalMode.ALL
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return the first match (there should only be one with unique IDs)
|
||||||
|
return results.length > 0 ? results[0] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the parent chain of a given node ID in the filter expression tree.
|
||||||
|
* @param expression - The filter expression to search in
|
||||||
|
* @param targetId - The ID of the node whose parent chain to find
|
||||||
|
* @param currentPath - Current path of parent nodes (used internally for recursion)
|
||||||
|
* @returns Array of parent nodes from immediate parent to root, or null if not found
|
||||||
|
*/
|
||||||
|
export const findParentChain = <P extends TFilterProperty>(
|
||||||
|
expression: TFilterExpression<P>,
|
||||||
|
targetId: string,
|
||||||
|
currentPath: TFilterGroupNode<P>[] = []
|
||||||
|
): TFilterGroupNode<P>[] | null => {
|
||||||
|
// if the expression is a group, search in the children
|
||||||
|
if (isGroupNode(expression)) {
|
||||||
|
const children = getGroupChildren(expression);
|
||||||
|
|
||||||
|
// check if any direct child has the target ID
|
||||||
|
for (const child of children) {
|
||||||
|
if (child.id === targetId) {
|
||||||
|
return [expression, ...currentPath];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// recursively search in child groups
|
||||||
|
for (const child of children) {
|
||||||
|
if (isGroupNode(child)) {
|
||||||
|
const chain = findParentChain(child, targetId, [expression, ...currentPath]);
|
||||||
|
if (chain) return chain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the immediate parent node of a given node ID.
|
||||||
|
* @param expression - The filter expression to find parent in
|
||||||
|
* @param targetId - The ID of the node whose parent to find
|
||||||
|
* @returns The immediate parent node or null if not found or if the target is the root
|
||||||
|
*/
|
||||||
|
export const findImmediateParent = <P extends TFilterProperty>(
|
||||||
|
expression: TFilterExpression<P>,
|
||||||
|
targetId: string
|
||||||
|
): TFilterGroupNode<P> | null => {
|
||||||
|
// if the expression is null, return null
|
||||||
|
if (!expression) return null;
|
||||||
|
|
||||||
|
// find the parent chain
|
||||||
|
const parentChain = findParentChain(expression, targetId);
|
||||||
|
|
||||||
|
// return the immediate parent if it exists
|
||||||
|
return parentChain && parentChain.length > 0 ? parentChain[0] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts all conditions from a filter expression.
|
||||||
|
* Uses the generic tree traversal utility for better maintainability and consistency.
|
||||||
|
* @param expression - The filter expression to extract conditions from
|
||||||
|
* @returns An array of filter conditions
|
||||||
|
*/
|
||||||
|
export const extractConditions = <P extends TFilterProperty>(
|
||||||
|
expression: TFilterExpression<P>
|
||||||
|
): TFilterConditionNode<P, TFilterValue>[] =>
|
||||||
|
traverseExpressionTree(
|
||||||
|
expression,
|
||||||
|
(node) => (isConditionNode(node) ? node : null),
|
||||||
|
TreeTraversalMode.CONDITIONS
|
||||||
|
) as TFilterConditionNode<P, TFilterValue>[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts all conditions from a filter expression, including their display operators.
|
||||||
|
* @param expression - The filter expression to extract conditions from
|
||||||
|
* @returns An array of filter conditions with their display operators
|
||||||
|
*/
|
||||||
|
export const extractConditionsWithDisplayOperators = <P extends TFilterProperty>(
|
||||||
|
expression: TFilterExpression<P>
|
||||||
|
): TFilterConditionNodeForDisplay<P, TFilterValue>[] => {
|
||||||
|
// First extract all raw conditions
|
||||||
|
const rawConditions = extractConditions(expression);
|
||||||
|
|
||||||
|
// Transform operators using the extended helper
|
||||||
|
return rawConditions.map((condition) => {
|
||||||
|
const displayOperator = getDisplayOperator(condition.operator, expression, condition.id);
|
||||||
|
return {
|
||||||
|
...condition,
|
||||||
|
operator: displayOperator,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds all conditions by property and operator.
|
||||||
|
* @param expression - The filter expression to search in
|
||||||
|
* @param property - The property to find the conditions by
|
||||||
|
* @param operator - The operator to find the conditions by
|
||||||
|
* @returns An array of conditions that match the property and operator
|
||||||
|
*/
|
||||||
|
export const findConditionsByPropertyAndOperator = <P extends TFilterProperty>(
|
||||||
|
expression: TFilterExpression<P>,
|
||||||
|
property: P,
|
||||||
|
operator: TAllAvailableOperatorsForDisplay
|
||||||
|
): TFilterConditionNodeForDisplay<P, TFilterValue>[] => {
|
||||||
|
const conditions = extractConditionsWithDisplayOperators(expression);
|
||||||
|
return conditions.filter((condition) => condition.property === property && condition.operator === operator);
|
||||||
|
};
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
// plane imports
|
||||||
|
import {
|
||||||
|
TAllAvailableOperatorsForDisplay,
|
||||||
|
TFilterExpression,
|
||||||
|
TFilterProperty,
|
||||||
|
TSupportedOperators,
|
||||||
|
} from "@plane/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get the display operator for a condition.
|
||||||
|
* This checks for NOT group context and applies negation if needed.
|
||||||
|
* @param operator - The original operator
|
||||||
|
* @param expression - The filter expression
|
||||||
|
* @param conditionId - The ID of the condition
|
||||||
|
* @returns The display operator (possibly negated)
|
||||||
|
*/
|
||||||
|
export const getDisplayOperator = <P extends TFilterProperty>(
|
||||||
|
operator: TSupportedOperators,
|
||||||
|
_expression: TFilterExpression<P>,
|
||||||
|
_conditionId: string
|
||||||
|
): TAllAvailableOperatorsForDisplay =>
|
||||||
|
// Otherwise, return the operator as-is
|
||||||
|
operator;
|
||||||
42
packages/utils/src/rich-filters/operators/core.ts
Normal file
42
packages/utils/src/rich-filters/operators/core.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import get from "lodash/get";
|
||||||
|
// plane imports
|
||||||
|
import { DATE_OPERATOR_LABELS_MAP, EMPTY_OPERATOR_LABEL, OPERATOR_LABELS_MAP } from "@plane/constants";
|
||||||
|
import {
|
||||||
|
TAllAvailableOperatorsForDisplay,
|
||||||
|
TFilterValue,
|
||||||
|
TAllAvailableDateFilterOperatorsForDisplay,
|
||||||
|
} from "@plane/types";
|
||||||
|
|
||||||
|
// -------- OPERATOR LABEL UTILITIES --------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the label for a filter operator
|
||||||
|
* @param operator - The operator to get the label for
|
||||||
|
* @returns The label for the operator
|
||||||
|
*/
|
||||||
|
export const getOperatorLabel = (operator: TAllAvailableOperatorsForDisplay | undefined): string => {
|
||||||
|
if (!operator) return EMPTY_OPERATOR_LABEL;
|
||||||
|
return get(OPERATOR_LABELS_MAP, operator, EMPTY_OPERATOR_LABEL);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the label for a date filter operator
|
||||||
|
* @param operator - The operator to get the label for
|
||||||
|
* @returns The label for the operator
|
||||||
|
*/
|
||||||
|
export const getDateOperatorLabel = (operator: TAllAvailableDateFilterOperatorsForDisplay | undefined): string => {
|
||||||
|
if (!operator) return EMPTY_OPERATOR_LABEL;
|
||||||
|
return get(DATE_OPERATOR_LABELS_MAP, operator, EMPTY_OPERATOR_LABEL);
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------- OPERATOR TYPE GUARDS --------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to check if an operator supports date filter types.
|
||||||
|
* @param operator - The operator to check
|
||||||
|
* @returns True if the operator supports date filters
|
||||||
|
*/
|
||||||
|
export const isDateFilterOperator = <V extends TFilterValue = TFilterValue>(
|
||||||
|
operator: TAllAvailableOperatorsForDisplay
|
||||||
|
): operator is TAllAvailableDateFilterOperatorsForDisplay<V> =>
|
||||||
|
Object.keys(DATE_OPERATOR_LABELS_MAP).includes(operator);
|
||||||
2
packages/utils/src/rich-filters/operators/index.ts
Normal file
2
packages/utils/src/rich-filters/operators/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./core";
|
||||||
|
export * from "./shared";
|
||||||
24
packages/utils/src/rich-filters/operators/shared.ts
Normal file
24
packages/utils/src/rich-filters/operators/shared.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { TAllAvailableOperatorsForDisplay, TSupportedOperators } from "@plane/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result type for operator conversion
|
||||||
|
*/
|
||||||
|
export type TOperatorForPayload = {
|
||||||
|
operator: TSupportedOperators;
|
||||||
|
isNegation: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a display operator to the format needed for supported by filter expression condition.
|
||||||
|
* @param displayOperator - The operator from the UI
|
||||||
|
* @returns Object with supported operator and negation flag
|
||||||
|
*/
|
||||||
|
export const getOperatorForPayload = (displayOperator: TAllAvailableOperatorsForDisplay): TOperatorForPayload => {
|
||||||
|
const isNegation = false;
|
||||||
|
const operator = displayOperator;
|
||||||
|
|
||||||
|
return {
|
||||||
|
operator,
|
||||||
|
isNegation,
|
||||||
|
};
|
||||||
|
};
|
||||||
68
packages/utils/src/rich-filters/types/core.ts
Normal file
68
packages/utils/src/rich-filters/types/core.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
FILTER_FIELD_TYPE,
|
||||||
|
FILTER_NODE_TYPE,
|
||||||
|
LOGICAL_OPERATOR,
|
||||||
|
TFilterAndGroupNode,
|
||||||
|
TFilterConditionNode,
|
||||||
|
TFilterExpression,
|
||||||
|
TFilterFieldType,
|
||||||
|
TFilterGroupNode,
|
||||||
|
TFilterProperty,
|
||||||
|
TFilterValue,
|
||||||
|
} from "@plane/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to check if a node is a condition node.
|
||||||
|
* @param node - The node to check
|
||||||
|
* @returns True if the node is a condition node
|
||||||
|
*/
|
||||||
|
export const isConditionNode = <P extends TFilterProperty, V extends TFilterValue>(
|
||||||
|
node: TFilterExpression<P>
|
||||||
|
): node is TFilterConditionNode<P, V> => node.type === FILTER_NODE_TYPE.CONDITION;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to check if a node is a group node.
|
||||||
|
* @param node - The node to check
|
||||||
|
* @returns True if the node is a group node
|
||||||
|
*/
|
||||||
|
export const isGroupNode = <P extends TFilterProperty>(node: TFilterExpression<P>): node is TFilterGroupNode<P> =>
|
||||||
|
node.type === FILTER_NODE_TYPE.GROUP;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to check if a group node is an AND group.
|
||||||
|
* @param group - The group node to check
|
||||||
|
* @returns True if the group is an AND group
|
||||||
|
*/
|
||||||
|
export const isAndGroupNode = <P extends TFilterProperty>(
|
||||||
|
group: TFilterGroupNode<P>
|
||||||
|
): group is TFilterAndGroupNode<P> => group.logicalOperator === LOGICAL_OPERATOR.AND;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to check if a group node has children property
|
||||||
|
* @param group - The group node to check
|
||||||
|
* @returns True if the group has children property
|
||||||
|
*/
|
||||||
|
export const hasChildrenProperty = <P extends TFilterProperty>(
|
||||||
|
group: TFilterGroupNode<P>
|
||||||
|
): group is TFilterAndGroupNode<P> => {
|
||||||
|
const groupWithChildren = group as { children?: unknown };
|
||||||
|
return "children" in group && Array.isArray(groupWithChildren.children);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely gets the children array from an AND group node.
|
||||||
|
* @param group - The AND group node
|
||||||
|
* @returns The children array
|
||||||
|
*/
|
||||||
|
export const getAndGroupChildren = <P extends TFilterProperty>(group: TFilterAndGroupNode<P>): TFilterExpression<P>[] =>
|
||||||
|
group.children;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to check if a filter type is a date filter type.
|
||||||
|
* @param type - The filter type to check
|
||||||
|
* @returns True if the filter type is a date filter type
|
||||||
|
*/
|
||||||
|
export const isDateFilterType = (
|
||||||
|
type: TFilterFieldType
|
||||||
|
): type is typeof FILTER_FIELD_TYPE.DATE | typeof FILTER_FIELD_TYPE.DATE_RANGE =>
|
||||||
|
type === FILTER_FIELD_TYPE.DATE || type === FILTER_FIELD_TYPE.DATE_RANGE;
|
||||||
2
packages/utils/src/rich-filters/types/index.ts
Normal file
2
packages/utils/src/rich-filters/types/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./core";
|
||||||
|
export * from "./shared";
|
||||||
35
packages/utils/src/rich-filters/types/shared.ts
Normal file
35
packages/utils/src/rich-filters/types/shared.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
// plane imports
|
||||||
|
import { TFilterAndGroupNode, TFilterExpression, TFilterGroupNode, TFilterProperty } from "@plane/types";
|
||||||
|
// local imports
|
||||||
|
import { getAndGroupChildren, isAndGroupNode } from "./core";
|
||||||
|
|
||||||
|
type TProcessGroupNodeHandlers<P extends TFilterProperty, T> = {
|
||||||
|
onAndGroup: (group: TFilterAndGroupNode<P>) => T;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic helper to process group nodes with type-safe handlers.
|
||||||
|
* @param group - The group node to process
|
||||||
|
* @param handlers - Object with handlers for each group type
|
||||||
|
* @returns Result of the appropriate handler
|
||||||
|
*/
|
||||||
|
export const processGroupNode = <P extends TFilterProperty, T>(
|
||||||
|
group: TFilterGroupNode<P>,
|
||||||
|
handlers: TProcessGroupNodeHandlers<P, T>
|
||||||
|
): T => {
|
||||||
|
if (isAndGroupNode(group)) {
|
||||||
|
return handlers.onAndGroup(group);
|
||||||
|
}
|
||||||
|
throw new Error(`Invalid group node: unknown logical operator ${group}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the children of a group node, handling AND/OR groups (children array) and NOT groups (single child).
|
||||||
|
* Uses processGroupNode for consistent group type handling.
|
||||||
|
* @param group - The group node to get children from
|
||||||
|
* @returns Array of child expressions
|
||||||
|
*/
|
||||||
|
export const getGroupChildren = <P extends TFilterProperty>(group: TFilterGroupNode<P>): TFilterExpression<P>[] =>
|
||||||
|
processGroupNode(group, {
|
||||||
|
onAndGroup: (andGroup) => getAndGroupChildren(andGroup),
|
||||||
|
});
|
||||||
52
packages/utils/src/rich-filters/validators/core.ts
Normal file
52
packages/utils/src/rich-filters/validators/core.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
// plane imports
|
||||||
|
import { SingleOrArray, TFilterExpression, TFilterProperty, TFilterValue } from "@plane/types";
|
||||||
|
// local imports
|
||||||
|
import { getGroupChildren } from "../types";
|
||||||
|
import { isConditionNode, isGroupNode } from "../types/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether to notify about a change based on the filter value.
|
||||||
|
* @param value - The filter value to check
|
||||||
|
* @returns True if we should notify, false otherwise
|
||||||
|
*/
|
||||||
|
export const hasValidValue = (value: SingleOrArray<TFilterValue>): boolean => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's an array, check if it's empty or contains only null/undefined values
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return value.some((v) => v !== null && v !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether to notify about a change based on the entire filter expression.
|
||||||
|
* @param expression - The filter expression to check
|
||||||
|
* @returns True if we should notify, false otherwise
|
||||||
|
*/
|
||||||
|
export const shouldNotifyChangeForExpression = <P extends TFilterProperty>(
|
||||||
|
expression: TFilterExpression<P> | null
|
||||||
|
): boolean => {
|
||||||
|
if (!expression) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a condition, check its value
|
||||||
|
if (isConditionNode(expression)) {
|
||||||
|
return hasValidValue(expression.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a group, check if any of its children have meaningful values
|
||||||
|
if (isGroupNode(expression)) {
|
||||||
|
const children = getGroupChildren(expression);
|
||||||
|
return children.some((child) => shouldNotifyChangeForExpression(child));
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
2
packages/utils/src/rich-filters/validators/index.ts
Normal file
2
packages/utils/src/rich-filters/validators/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./core";
|
||||||
|
export * from "./shared";
|
||||||
22
packages/utils/src/rich-filters/validators/shared.ts
Normal file
22
packages/utils/src/rich-filters/validators/shared.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// plane imports
|
||||||
|
import { TFilterGroupNode, TFilterProperty } from "@plane/types";
|
||||||
|
// local imports
|
||||||
|
import { getGroupChildren } from "../types/shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if a group should be unwrapped based on the number of children and group type.
|
||||||
|
* @param group - The group node to check
|
||||||
|
* @param preserveNotGroups - Whether to preserve NOT groups even with single children
|
||||||
|
* @returns True if the group should be unwrapped, false otherwise
|
||||||
|
*/
|
||||||
|
export const shouldUnwrapGroup = <P extends TFilterProperty>(group: TFilterGroupNode<P>, _preserveNotGroups = true) => {
|
||||||
|
const children = getGroupChildren(group);
|
||||||
|
|
||||||
|
// Never unwrap groups with multiple children
|
||||||
|
if (children.length !== 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap AND/OR groups with single children, and NOT groups if preserveNotGroups is false
|
||||||
|
return true;
|
||||||
|
};
|
||||||
24
packages/utils/src/rich-filters/values/core.ts
Normal file
24
packages/utils/src/rich-filters/values/core.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import type { SingleOrArray, TFilterValue } from "@plane/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts any value to a non-null array for UI components that expect arrays
|
||||||
|
* Returns empty array for null/undefined values
|
||||||
|
*/
|
||||||
|
export const toFilterArray = <V extends TFilterValue>(value: SingleOrArray<V>): NonNullable<V>[] => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.isArray(value) ? (value as NonNullable<V>[]) : ([value] as NonNullable<V>[]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the length of a filter value
|
||||||
|
*/
|
||||||
|
export const getFilterValueLength = <V extends TFilterValue>(value: SingleOrArray<V>): number => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.isArray(value) ? value.length : 1;
|
||||||
|
};
|
||||||
1
packages/utils/src/rich-filters/values/index.ts
Normal file
1
packages/utils/src/rich-filters/values/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./core";
|
||||||
@ -15,6 +15,9 @@ catalogs:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: 18.3.1
|
specifier: 18.3.1
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
|
'@types/uuid':
|
||||||
|
specifier: 9.0.8
|
||||||
|
version: 9.0.8
|
||||||
axios:
|
axios:
|
||||||
specifier: 1.12.0
|
specifier: 1.12.0
|
||||||
version: 1.12.0
|
version: 1.12.0
|
||||||
@ -481,6 +484,9 @@ importers:
|
|||||||
'@plane/services':
|
'@plane/services':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/services
|
version: link:../../packages/services
|
||||||
|
'@plane/shared-state':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/shared-state
|
||||||
'@plane/types':
|
'@plane/types':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/types
|
version: link:../../packages/types
|
||||||
@ -1095,9 +1101,27 @@ importers:
|
|||||||
|
|
||||||
packages/shared-state:
|
packages/shared-state:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@plane/constants':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../constants
|
||||||
|
'@plane/types':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../types
|
||||||
|
'@plane/utils':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../utils
|
||||||
|
lodash:
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 4.17.21
|
||||||
mobx:
|
mobx:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 6.12.0
|
version: 6.12.0
|
||||||
|
mobx-utils:
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 6.0.8(mobx@6.12.0)
|
||||||
|
uuid:
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 10.0.0
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.22.2
|
specifier: ^3.22.2
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@ -1108,9 +1132,15 @@ importers:
|
|||||||
'@plane/typescript-config':
|
'@plane/typescript-config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../typescript-config
|
version: link:../typescript-config
|
||||||
|
'@types/lodash':
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 4.17.20
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.5.4
|
specifier: ^22.5.4
|
||||||
version: 22.18.0
|
version: 22.18.0
|
||||||
|
'@types/uuid':
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 9.0.8
|
||||||
typescript:
|
typescript:
|
||||||
specifier: 5.8.3
|
specifier: 5.8.3
|
||||||
version: 5.8.3
|
version: 5.8.3
|
||||||
@ -1584,14 +1614,14 @@ packages:
|
|||||||
'@date-fns/tz@1.4.1':
|
'@date-fns/tz@1.4.1':
|
||||||
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
|
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
|
||||||
|
|
||||||
'@emnapi/core@1.5.0':
|
'@emnapi/core@1.4.5':
|
||||||
resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==}
|
resolution: {integrity: sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==}
|
||||||
|
|
||||||
'@emnapi/runtime@1.5.0':
|
'@emnapi/runtime@1.5.0':
|
||||||
resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==}
|
resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==}
|
||||||
|
|
||||||
'@emnapi/wasi-threads@1.1.0':
|
'@emnapi/wasi-threads@1.0.4':
|
||||||
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
|
resolution: {integrity: sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==}
|
||||||
|
|
||||||
'@emotion/babel-plugin@11.13.5':
|
'@emotion/babel-plugin@11.13.5':
|
||||||
resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==}
|
resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==}
|
||||||
@ -8401,9 +8431,9 @@ snapshots:
|
|||||||
|
|
||||||
'@date-fns/tz@1.4.1': {}
|
'@date-fns/tz@1.4.1': {}
|
||||||
|
|
||||||
'@emnapi/core@1.5.0':
|
'@emnapi/core@1.4.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/wasi-threads': 1.1.0
|
'@emnapi/wasi-threads': 1.0.4
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@ -8412,7 +8442,7 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@emnapi/wasi-threads@1.1.0':
|
'@emnapi/wasi-threads@1.0.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
@ -8982,14 +9012,14 @@ snapshots:
|
|||||||
|
|
||||||
'@napi-rs/wasm-runtime@0.2.12':
|
'@napi-rs/wasm-runtime@0.2.12':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.5.0
|
'@emnapi/core': 1.4.5
|
||||||
'@emnapi/runtime': 1.5.0
|
'@emnapi/runtime': 1.5.0
|
||||||
'@tybys/wasm-util': 0.10.0
|
'@tybys/wasm-util': 0.10.0
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@napi-rs/wasm-runtime@1.0.3':
|
'@napi-rs/wasm-runtime@1.0.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.5.0
|
'@emnapi/core': 1.4.5
|
||||||
'@emnapi/runtime': 1.5.0
|
'@emnapi/runtime': 1.5.0
|
||||||
'@tybys/wasm-util': 0.10.0
|
'@tybys/wasm-util': 0.10.0
|
||||||
optional: true
|
optional: true
|
||||||
@ -11137,7 +11167,7 @@ snapshots:
|
|||||||
camel-case@4.1.2:
|
camel-case@4.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
pascal-case: 3.1.2
|
pascal-case: 3.1.2
|
||||||
tslib: 2.5.3
|
tslib: 2.8.1
|
||||||
|
|
||||||
camelcase-css@2.0.1: {}
|
camelcase-css@2.0.1: {}
|
||||||
|
|
||||||
@ -11148,7 +11178,7 @@ snapshots:
|
|||||||
capital-case@1.0.4:
|
capital-case@1.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
no-case: 3.0.4
|
no-case: 3.0.4
|
||||||
tslib: 2.5.3
|
tslib: 2.8.1
|
||||||
upper-case-first: 2.0.2
|
upper-case-first: 2.0.2
|
||||||
|
|
||||||
case-sensitive-paths-webpack-plugin@2.4.0: {}
|
case-sensitive-paths-webpack-plugin@2.4.0: {}
|
||||||
@ -11190,7 +11220,7 @@ snapshots:
|
|||||||
path-case: 3.0.4
|
path-case: 3.0.4
|
||||||
sentence-case: 3.0.4
|
sentence-case: 3.0.4
|
||||||
snake-case: 3.0.4
|
snake-case: 3.0.4
|
||||||
tslib: 2.5.3
|
tslib: 2.8.1
|
||||||
|
|
||||||
character-entities@2.0.2: {}
|
character-entities@2.0.2: {}
|
||||||
|
|
||||||
@ -11331,7 +11361,7 @@ snapshots:
|
|||||||
constant-case@3.0.4:
|
constant-case@3.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
no-case: 3.0.4
|
no-case: 3.0.4
|
||||||
tslib: 2.5.3
|
tslib: 2.8.1
|
||||||
upper-case: 2.0.2
|
upper-case: 2.0.2
|
||||||
|
|
||||||
constants-browserify@1.0.0: {}
|
constants-browserify@1.0.0: {}
|
||||||
@ -11659,7 +11689,7 @@ snapshots:
|
|||||||
dot-case@3.0.4:
|
dot-case@3.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
no-case: 3.0.4
|
no-case: 3.0.4
|
||||||
tslib: 2.5.3
|
tslib: 2.8.1
|
||||||
|
|
||||||
dotenv@16.0.3: {}
|
dotenv@16.0.3: {}
|
||||||
|
|
||||||
@ -12509,7 +12539,7 @@ snapshots:
|
|||||||
header-case@2.0.4:
|
header-case@2.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
capital-case: 1.0.4
|
capital-case: 1.0.4
|
||||||
tslib: 2.5.3
|
tslib: 2.8.1
|
||||||
|
|
||||||
helmet@7.2.0: {}
|
helmet@7.2.0: {}
|
||||||
|
|
||||||
@ -13049,7 +13079,7 @@ snapshots:
|
|||||||
|
|
||||||
lower-case@2.0.2:
|
lower-case@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.5.3
|
tslib: 2.8.1
|
||||||
|
|
||||||
lowlight@2.9.0:
|
lowlight@2.9.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -13421,7 +13451,7 @@ snapshots:
|
|||||||
no-case@3.0.4:
|
no-case@3.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
lower-case: 2.0.2
|
lower-case: 2.0.2
|
||||||
tslib: 2.5.3
|
tslib: 2.8.1
|
||||||
|
|
||||||
node-abort-controller@3.1.1: {}
|
node-abort-controller@3.1.1: {}
|
||||||
|
|
||||||
@ -13604,7 +13634,7 @@ snapshots:
|
|||||||
param-case@3.0.4:
|
param-case@3.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
dot-case: 3.0.4
|
dot-case: 3.0.4
|
||||||
tslib: 2.5.3
|
tslib: 2.8.1
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -13628,14 +13658,14 @@ snapshots:
|
|||||||
pascal-case@3.1.2:
|
pascal-case@3.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
no-case: 3.0.4
|
no-case: 3.0.4
|
||||||
tslib: 2.5.3
|
tslib: 2.8.1
|
||||||
|
|
||||||
path-browserify@1.0.1: {}
|
path-browserify@1.0.1: {}
|
||||||
|
|
||||||
path-case@3.0.4:
|
path-case@3.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
dot-case: 3.0.4
|
dot-case: 3.0.4
|
||||||
tslib: 2.5.3
|
tslib: 2.8.1
|
||||||
|
|
||||||
path-exists@4.0.0: {}
|
path-exists@4.0.0: {}
|
||||||
|
|
||||||
@ -14542,7 +14572,7 @@ snapshots:
|
|||||||
sentence-case@3.0.4:
|
sentence-case@3.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
no-case: 3.0.4
|
no-case: 3.0.4
|
||||||
tslib: 2.5.3
|
tslib: 2.8.1
|
||||||
upper-case-first: 2.0.2
|
upper-case-first: 2.0.2
|
||||||
|
|
||||||
serialize-javascript@6.0.2:
|
serialize-javascript@6.0.2:
|
||||||
@ -14663,7 +14693,7 @@ snapshots:
|
|||||||
snake-case@3.0.4:
|
snake-case@3.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
dot-case: 3.0.4
|
dot-case: 3.0.4
|
||||||
tslib: 2.5.3
|
tslib: 2.8.1
|
||||||
|
|
||||||
sonic-boom@2.8.0:
|
sonic-boom@2.8.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -15301,11 +15331,11 @@ snapshots:
|
|||||||
|
|
||||||
upper-case-first@2.0.2:
|
upper-case-first@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.5.3
|
tslib: 2.8.1
|
||||||
|
|
||||||
upper-case@2.0.2:
|
upper-case@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.5.3
|
tslib: 2.8.1
|
||||||
|
|
||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@ -19,6 +19,7 @@ catalog:
|
|||||||
react-dom: 18.3.1
|
react-dom: 18.3.1
|
||||||
"@types/react": 18.3.11
|
"@types/react": 18.3.11
|
||||||
"@types/react-dom": 18.3.1
|
"@types/react-dom": 18.3.1
|
||||||
|
"@types/uuid": 9.0.8
|
||||||
typescript: 5.8.3
|
typescript: 5.8.3
|
||||||
tsdown: 0.14.2
|
tsdown: 0.14.2
|
||||||
uuid: 10.0.0
|
uuid: 10.0.0
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user